Code Journey

30代未経験からプログラミング挑戦中(追うものは追われる者に勝る)

【対策はfreezeメソッド】Rubyの定数は再代入・変更ができるので注意が必要

はじめに

前提

まず前提として、この記事を書いているのは未経験からプログラミングに挑戦中の初学者になりますのでご注意ください。

学習はフィヨルドブートキャンプに参加しながら取り組んでいて、現役のエンジニアの方からレビューをもらいながら進めています。

現在の学習状況は以下を随時更新しているので興味ある方はご覧ください。

hirano-vm4.hatenablog.com

Rubyの定数は書き換えができてしまうので注意が必要

学習を進めていく中で定数を扱うタイミングがありました。

「定数」と文字だけみると変更できない印象を持ちますが、Rubyにおける定数は警告はでるものの、変更ができてしまうため、変更されたくない場合は対策が必要になります。

特に再代入できるだけでなくミュータブルなオブジェクト(文字列・配列・ハッシュなど)であれば定数の値を変更できてしまうので注意しなければならないことを学びました。

この記事では定数が変更できてしまう点について自分でコードを打っていろいろと確認してみたので、自己学習の記録も兼ねてアウトプットしている記事になります。

結論:freezeメソッドでオブジェクトを凍結(内容の変更を禁止)する

結論は「freezeメソッドを使ってオブジェクトを凍結する」になります。凍結されたオブジェクトを変更しようとすると例外 FrozenError を発生させることができます。

instance method Object#freeze

ただし、配列やハッシュを使う場合は使い方に注意が必要だと感じました。理由はこれ以降の対策の部分で解説したいと思います。

そもそもなぜRubyの定数は変更ができてしまうのか調べてみた

理由を検索していたらRubyを作った松本行弘氏がQuoraの質問で以下のように回答で以下のように解説していました。

Q.なぜRubyでは定数も再代入可能で、わざわざfreezeを使わないとだめになっているのですか?ハナから再代入不可にしなかった理由は?

この質問は、定数という言葉が示すと考えられるふたつの役割が混同されています。そして、Rubyではこのふたつの役割は明確に異なるものです。

第一の役割は、参照している値(オブジェクト)が変化しない、というものです。Rubyの定数はこの役割だけを持ち、大文字から始まる名前は定数として、その参照先を変えないことになっています。質問のうち、「再代入可能で、ハナから再代入不可にしなかった」というのはこの役割を示しています。

これは確かにその通りで、Rubyでは定数に再代入すると、警告こそ出力されますが、それを無視さえすれば、定数の値を書き換えることができます。

なぜそうなっているかというと、Rubyをアプリ組込みに利用して、たとえばRubyを組み込んだエディタを開発した場合、Rubyで記述した設定ファイルに書いてある定数が書換不能でエラーになってしまった場合、設定ファイルとしての使い勝手が悪くなることが想定されたからです。そこで、定数であるにも関わらず、書き換え禁止は警告のみにとどめ、やろうと思えば書き換えられるが紳士協定として、定数とするという方針にしました。

定数のもうひとつの意味は、「参照先が書き換わらない」という意味です。Cのconstはこちらの意味も持っていますが、多くのオブジェクトが書換え可能(mutable)なRubyでは、定数にはこの役割は与えられていません。定数の参照先のオブジェクトが書き換わるのを禁止したければ、「わざわざfreezeを使わないとだめ」です。これはRubyの「定数」とは無関係です

定数には

1.「参照している値(オブジェクト)が変化しない」

2.「参照先が書き換わらない」

の2つの役割があり、Rubyの定数は「参照している値(オブジェクト)が変化しない役割」だけを持っているため再代入や変更ができると説明されていました。

なるほど。難しいけど興味深い…。

定数の変更ができてしまう事例と対策

クラスをfreezeして変更を防ぐ事例

class Cafe
  COFFE_PRICE = 300

  COFFE_PRICE = 500
end

p Cafe::COFFE_PRICE

#=> warning: already initialized constant Cafe::COFFE_PRICE
#=> warning: previous definition of COFFE_PRICE was here
#=> 500

実行してみると再代入後の値が返って変更ができていることが確認できます。

クラス外からも変更できました。

class Cafe
  COFFE_PRICE = 300
end

Cafe::COFFE_PRICE = 500
p Cafe::COFFE_PRICE

#=> warning: already initialized constant Cafe::COFFE_PRICE
#=> warning: previous definition of COFFE_PRICE was here
#=> 500

クラス自体をfreezeすると変更を防止できる

class Cafe
  COFFE_PRICE = 300
end

Cafe.freeze
Cafe::COFFE_PRICE = 500
p Cafe::COFFE_PRICE #=> can't modify frozen #<Class:Cafe>: Cafe (FrozenError)

しかし、私が学習しているフィヨルドブートキャンプでメンターもしていて、有名なチェリー本を書いている伊藤さんによれば「Rubyの場合、普通は定数を上書きする人はいないためクラスをfreezeまでする例は少ない」とのことです。

しかし、変更できるという点は知っておく必要はありそうです。

定数(配列)が変更されてしまう例と対策

定数に配列を入れた場合で実際にコードを入力してみると以下のように変更できることが確認できました。

MEMBER = ['Foo', 'Bar', 'Baz']

MEMBER[0].upcase!

p MEMBER #=> ["FOO", "Bar", "Baz"]

追加もできる

MEMBER = ['Foo', 'Bar', 'Baz']

MEMBER.push('Piyo') 

p MEMBER #=> ["Foo", "Bar", "Baz", "Piyo"]

削除もできる

MEMBER = ['Foo', 'Bar', 'Baz']

MEMBER.pop

p MEMBER #=> ["Foo", "Bar"]

freezeメソッドを使って凍結すると?

freezeを使って凍結すると変更に対してFrozenErrorが返ってくるようになる。

MEMBER = ['Foo', 'Bar', 'Baz'].freeze

MEMBER.push('Piyo')

p MEMBER #=> can't modify frozen Array: ["Foo", "Bar", "Baz"] (FrozenError)

しかし、この状態ではまだ完全に変更を防ぐことができているわけではないことに注意!

配列やハッシュをfreezeしても配列・ハッシュそのものは凍結できても各要素については凍結されていない。

MEMBER = ['Foo', 'Bar', 'Baz'].freeze
 
MEMBER[0].upcase!

p MEMBER #=> ["FOO", "Bar", "Baz"]

このように変更できることがわかります。要素に対する変更も防ぎたい場合は以下のように、各要素に対してもfreezeする必要があります!

MEMBER = ['Foo', 'Bar', 'Baz'].map(&:freeze).freeze

MEMBER[0].upcase!

p MEMBER #=> can't modify frozen String: "Foo" (FrozenError)

定数(ハッシュ)が変更されてしまう例と対策

追加できる

FILE_TYPE = {
  'file' => '-',
  'directory' => 'd',
  'link' => 'l'
}

FILE_TYPE.store('Foo', 'Bar')

p FILE_TYPE #=> {"file"=>"-", "directory"=>"d", "link"=>"l", "Foo"=>"Bar"}

削除もできる

FILE_TYPE = {
  'file' => '-',
  'directory' => 'd',
  'link' => 'l'
}

FILE_TYPE.delete('link')

p FILE_TYPE #=> {"file"=>"-", "directory"=>"d"}

変更もできる

FILE_TYPE = {
  'file' => '-',
  'directory' => 'd',
  'link' => 'l'
}

FILE_TYPE['link'].upcase!

p FILE_TYPE #=> {"file"=>"-", "directory"=>"d", "link"=>"L"}

freezeすると凍結できるが要素までは防げない

ハッシュ自体は凍結されている

FILE_TYPE = {
  'file' => '-',
  'directory' => 'd',
  'link' => 'l'
}.freeze

FILE_TYPE.delete('file')

p FILE_TYPE #=>  can't modify frozen Hash: {"file"=>"-", "directory"=>"d", "link"=>"l"} (FrozenError)

要素に対しての変更はできてしまう

FILE_TYPE = {
  'file' => '-',
  'directory' => 'd',
  'link' => 'l'
}.freeze

FILE_TYPE['link'].upcase!

p FILE_TYPE #=> {"file"=>"-", "directory"=>"d", "link"=>"L"}

ハッシュの場合はeachしても長ったらしいですし、mapは配列の戻り値になっていまいます。以下のように要素ごとにfreezeするのもなんか不恰好。

FILE_TYPE = {
  'file' => '-'.freeze,
  'directory' => 'd'.freeze,
  'link' => 'l'.freeze
}.freeze

FILE_TYPE['link'].upcase!

p FILE_TYPE #=> can't modify frozen String: "l" (FrozenError)

transform_valuesという便利なメソッドを発見したので以下のようにもできました。

transform_valuesはすべての値に対してブロックを呼び出した結果で置き換えたハッシュを返します。キーは変化しません。

Hash#transform_values (Ruby 3.2 リファレンスマニュアル)

FILE_TYPE = {
  'file' => '-',
  'directory' => 'd',
  'link' => 'l'
}.transform_values(&:freeze).freeze

FILE_TYPE['link'].upcase!

p FILE_TYPE #=> can't modify frozen String: "l" (FrozenError)

最後に

以上、Rubyの定数について深掘りしてみました。

今回調べてみて定数には

「参照している値(オブジェクト)が変化しない」

「参照先が書き換わらない」

という2つの役割がそもそもあり、Rubyの定数は 「参照している値(オブジェクト)が変化しない役割 だけを持っているため、再代入や変更ができる」 という理由を確認することができました。

またその対策にfreezeメソッドを使う方法があるが、ハッシュや配列に対してfreezeしても各要素には凍結が適用されないことにも注意が必要だと感じました。