ImmutableなセンチネルオブジェクトとコレクションをRactor対応に向けてフリーズ
Rails全体にわたって、センチネル値として使われる Object.new や Set・Hash・Arrayの定数を .freeze するPRがマージされました。#57323 に続くRactor対応の一環であり、Style/MutableConstant Copではカバーできなかったオブジェクト型の定数も含めて不変性が保証されます。
背景
RubyのRactorは、並行処理の安全性を確保するために「Ractorをまたいで共有できるオブジェクトはすべてfreezeされていなければならない」という制約を設けています。Railsをractor-safeにするためには、フレームワーク内で定義されたすべての定数がshareable(共有可能)である必要があります。
直前のPR #57323 では Style/MutableConstant Copを有効化し、リテラル(Array・Hash・文字列)として定義された定数を .freeze する対応が行われました。しかしRuboCopのこのCopは Object.new のようなオブジェクトや、定義後にエントリを追加してからフリーズする Set・Hash、さらに他の定数を組み合わせた演算結果のArrayには作用しません。本PRはその漏れを手動で埋める後続作業です。
技術的な変更
変更は大きく3つのパターンに分類できます。それぞれに対して .freeze を加えることで定数の不変性を明示しています。
パターン1: センチネルオブジェクト(Object.new)のフリーズ
デフォルト値や「未設定」を表すためにフレームワーク内各所で使われる Object.new に .freeze が追加されました。対象となったのは以下の定数です:
ActionDispatch::Http::Headers::DEFAULTActionDispatch::Routing::Mapper::POISONActionDispatch::Routing::Mapper::DEFAULTActionView::Template::NONEActiveJob::Exceptions::JITTER_DEFAULTActiveModel::Attribute::Uninitialized::UNINITIALIZED_ORIGINAL_VALUEActiveRecord::Attributes::NO_DEFAULT_PROVIDEDActiveSupport::HashWithIndifferentAccess::NOT_GIVENActiveSupport::Testing::Assertions::UNTRACKED
これらのセンチネルオブジェクトは equal? による同一性チェック(identity check)で使われており、freezeしても振る舞いは変わりません。
なお activerecord/lib/active_record/attribute_methods/primary_key.rb では、PRIMARY_KEY_NOT_SET が BasicObject.new から Object.new.freeze に変更されています。
# 変更前
PRIMARY_KEY_NOT_SET = BasicObject.new
# 変更後
PRIMARY_KEY_NOT_SET = Object.new.freeze
BasicObject は Object のサブセットであり、freeze メソッド自体は BasicObject にも存在しますが、.freeze を呼び出した結果を定数に代入するというパターンに統一しています。
パターン2: 定義後に要素を追加するHash・SetのフリーズをHashの内側まで波及
actionview/lib/action_view/helpers/javascript_helper.rb の JS_ESCAPE_MAP は、定義後に2つのUTF-8エントリ(U+2028、U+2029)を別行で追加していたため、rubocop:disable Style/MutableConstant を付けて可変のまま残されていました。本PRではこれらのエントリをHash定義の内側に移動し、キーを .freeze したうえでHash全体を .freeze しています。
# 変更前
JS_ESCAPE_MAP = { # rubocop:disable Style/MutableConstant
"\\" => "\\\\",
# ...
"$" => "\\$"
}
JS_ESCAPE_MAP[(+"\342\200\250").force_encoding(Encoding::UTF_8).encode!] = "
"
JS_ESCAPE_MAP[(+"\342\200\251").force_encoding(Encoding::UTF_8).encode!] = "
"
# 変更後
JS_ESCAPE_MAP = {
"\\" => "\\\\",
# ...
"$" => "\\$",
(+"\342\200\250").force_encoding(Encoding::UTF_8).encode!.freeze => "
",
(+"\342\200\251").force_encoding(Encoding::UTF_8).encode!.freeze => "
"
}.freeze
同様に activesupport/lib/active_support/xml_mini.rb の TYPE_NAMES および actionview/lib/action_view/helpers/tag_helper.rb の PRE_CONTENT_STRINGS も、定義ブロック末尾で .freeze が呼ばれるよう変更されました。
パターン3: 定数演算結果(Array・Set)のフリーズ
既存のfreezeされた定数から演算によって生成される新しい定数が、freezeされていないまま残っていたケースが修正されました。
# 変更前
VALUE_METHODS = MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS + CLAUSE_METHODS
# 変更後
VALUE_METHODS = (MULTI_VALUE_METHODS + SINGLE_VALUE_METHODS + CLAUSE_METHODS).freeze
# 変更前
NORMAL_VALUES = Relation::VALUE_METHODS - Relation::CLAUSE_METHODS -
[...]
# 変更後
NORMAL_VALUES = (Relation::VALUE_METHODS - Relation::CLAUSE_METHODS -
[...]).freeze
Rubyでは Array#+・Array#- などの演算子は 新しい可変Arrayを返す ため、元の定数がfreezeされていても結果はfreezeされません。Set.new([...]).freeze の形式も同様です。これらの演算結果を明示的にfreezeしています。
activerecord/lib/active_record/attribute_methods/primary_key.rb の ID_ATTRIBUTE_METHODS では to_set.freeze が追加されています。
# 変更前
ID_ATTRIBUTE_METHODS = %w(id id= id? id_before_type_cast id_was id_in_database id_for_database).to_set
# 変更後
ID_ATTRIBUTE_METHODS = %w(id id= id? id_before_type_cast id_was id_in_database id_for_database).to_set.freeze
設計判断
センチネルオブジェクトはfreezeしても同一性チェックに影響しないという性質が、今回の変更を安全なものにしています。これらの定数は equal? または obj.equal?(SENTINEL) によってidentityで比較されており、freezeはオブジェクトのidentityを変えないため、既存の利用箇所への影響はありません。
JS_ESCAPE_MAP のリファクタリングは、「定義後に可変オブジェクトとして追記していた」設計を「定義時に完全なHashを構築してからfreezeする」設計に改めるものです。rubocop:disable コメントを除去できたことは、CopによるMutableConstantの静的検出の網羅性が高まったことを意味します。
まとめ
このPRは、RuboCopの Style/MutableConstant Copが自動検出できないセンチネルオブジェクトや演算結果の定数を手動で修正することで、Ractor対応に向けた定数の不変性保証を一歩前進させたものです。各変更は既存の動作を変えず、定数のshareable化という単一の目的に絞られており、Ractor対応に向けた着実な地固めとなっています。