Rubyにおける定数は意図せず書き換えられることがある
以下のような場合、警告すら出ずに定数が書き換えられてしまう。
PERMIT_ID = ['0001', '0002', '1234']
PERMIT_ID.reject! { |id| id == '1234' }
p PERMIT_ID
# => ["0001", "0002"]
Rubyにおける定数は不変の値というより、グローバル変数に近い。
また、Rubyでは先頭が大文字になっている識別子のことを定数としていることから、クラス名であるStringやArrayなども定数として扱っている。
そう考えると、クラスはメソッドを追加したり等いつでも書き換えられるミュータブルなオブジェクトなので、定数が参照するオブジェクトもミュータブルにする必要がある。
よって上記のコードはRuby的に正しい挙動なので警告は出されない。
とはいえ、定数を定義するときは一般的にはイミュータブルな値を定義したいときがほとんどなので、そういった場合はfreezeを使う。
イミュータブルな定数を定義したいときはfreezeを使う
PERMIT_ID = ['0001', '0002', '1234']
PERMIT_ID.freeze
PERMIT_ID.reject! { |id| id == '1234' }
# => RuntimeError: can't modify frozen Array
p PERMIT_ID
# => ["0001", "0002", "1234"]
Rubyでは、定数等のオブジェクトに対して意図しない書き換えを防ぐためにObject#freeze
というものが用意されている。
ドキュメントによると、以下のようなことが書かれている。
オブジェクトを凍結(内容の変更を禁止)します。
凍結されたオブジェクトの変更は 例外 RuntimeError を発生させます。 いったん凍結されたオブジェクトを元に戻す方法はありません。
凍結されるのはオブジェクトであり、変数ではありません。代入などで変数の指すオブジェクトが変化してしまうことは freeze では防げません。 freeze が防ぐのは、 '破壊的な操作' と呼ばれるもの一般です。変数への参照自体を凍結したい 場合は、グローバル変数なら Kernel.#trace_var が使えます。
コレクションの場合、要素もfreezeするべき
PERMIT_ID = ['0001', '0002', '1234'].freeze
PERMIT_ID.map!{|id| id << 'hogefuga'}
# => RuntimeError: can't modify frozen Array
PERMIT_ID.push('9999')
# => RuntimeError: can't modify frozen Array
PERMIT_ID.pop
# => RuntimeError: can't modify frozen Array
PERMIT_ID.map{|id| id << 'hogefuga'}
p PERMIT_ID
# => ["0001hogefuga", "0002hogefuga", "1234hogefuga"]
ドキュメントにもあるが、freeze が防ぐのは、'破壊的な操作'であり、定数の指すオブジェクトが変化してしまうことは防げない。
この場合、定数の指す配列(オブジェクト)の要素が書き換わってしまった。
これを防ぐには要素もfreezeする必要がある。
PERMIT_ID = ['0001', '0002', '1234'].map(&:freeze).freeze
PERMIT_ID.map{|id| id << 'hogefuga'}
# => RuntimeError: can't modify frozen String
p PERMIT_ID
# => ["0001", "0002", "1234"]
ここで挙げた例は配列だが、Hashの場合も同様である。
再代入も不可にさせたい場合、moduleをfreezeする
PERMIT_ID = ['0001', '0002', '1234'].map(&:freeze).freeze
p PERMIT_ID
# => ["0001", "0002", "1234"]
PERMIT_ID = 'hogefuga'
# => warning: already initialized constant PERMIT_ID
# => warning: previous definition of PERMIT_ID was here
p PERMIT_ID
# => "hogefuga"
Rubyにおける定数は、再代入時に警告を出してくれるがエラーにはしてくれない。
そもそも定数を書き換えるコードなんて書くなという話だが、そういったコードは開発者が意図せずに紛れ込ませてしまい、レビュアーも気づかない場合がある。
そこで、再代入すらエラーにさせたい場合は、名前空間を切ってmoduleをfreezeしてしまえば良い。
module MyConstant
PERMIT_ID = ['0001', '0002', '1234'].map(&:freeze).freeze
end
MyConstant.freeze
MyConstant::PERMIT_ID = 'hogefuga'
# => RuntimeError: can't modify frozen Module
参考資料
- Effective Ruby: 項目4 定数がミュータブルなことに注意しよう