Rubyっぽくリファクタリングするためのポイント(2)
予想外の第2回ですが、今回も自分用にポイントをメモする感覚で...φ(・ω・`)
<今回の仕様>
- リストに含まれるキーに応じた値でカウンタを加算していく
- 全部処理したらキーごとの値を取りだす
非常に単純ですが、やりたいことはこうです
data_list.each do |d| add_count(d.key, d.count) end @counter.each do |key, count| # do something end
この「add_count」をどう書くか、が今回のポイントです
(私のやっている仕事の一つが集計システムで、
単純なSQLで処理できない集計に頻出なのです)
JavaのHashMapのように、素直に実装を書くと・・・
def add_count(key, count) @counter ||= {} if @counter.has_key?(key) @counter[key] += count else @counter[key] = count end end
・・・こうなります
(私が最初に書いたバージョンがこれ)
なぜ場合分けが必要なのかといえば、
知らないkeyが渡された場合にHashがnilを返すため、
nilに対して「+」が呼べないからです
でも、考えてみれば、NilClass#to_iは0を返すので、
こう書くこともできます
def add_count(key, count) @counter ||= {} @counter[key] = @counter[key].to_i + count end
確かにこれでも動きますが、「@counter[key].to_i」が冗長に見えます
それに、カウンタの初期値が0だからこうできるのであって、
0以外の場合は・・・
def add_count(key, count) @counter ||= {} if @counter.has_key?(key) @counter[key] += count else @counter[key] = @init_count + count end end
・・・こうなってしまい、とてもわかりづらいです(´・ω・`)
こういうときは、Hash.new(val)を使います
def add_count(key, count) @counter ||= Hash.new(0) @counter[key] += count end
一気にシンプルになりました(`・ω・´) b
Hash.new(val)は、知らないkeyが渡された場合に、
valで値を初期化する、という意味です
今回の場合は0で初期化されるので、いきなり「+」を呼んでもOKなのです
もちろん、valに渡すのはオブジェクトでもいいのですが、
このやり方には落とし穴があります
下の例を見てください
SomeClass = Struct.new :val obj = SomeClass.new obj.val = "some value" # objで初期化して知らないkeyを渡す hash = Hash.new(obj) p hash['key1'].val #=>"some value" # 別なkeyから値を変更する hash['key2'].val = "value change!!!" p hash['key2'].val #=> "value change!!!" # 元の値も変わってしまった p hash['key1'].val #=> "value change!!!"
当然といえば当然ですが、Hash.new(val)で渡されるのは、
valへの参照であって、値ではないということです
なので、指し示すオブジェクトが変更されれば、全て変わってしまいます
こういう場合、Hash.new{|hash, key| ...}というように、
ブロックを渡すと意図したとおりになります
SomeClass = Struct.new :val # ブロックを使って初期化するように指定 init = "some value" hash = Hash.new do |h, k| obj = SomeClass.new obj.val = init # ブロックの外の値を格納 h[k] = obj end p hash['key1'].val #=> "some value" # 別なkeyから値を変更 hash['key2'].val = "value change!!!" p hash['key2'].val #=> "value change!!!" # 別なオブジェクトなので変わらない p hash['key1'].val #=> "some value"
Hash.newにブロックを渡した場合、
初期化するときにそのブロックを実行します
ブロックは作られた時の「環境」を保持しているので、
初期化で使われる文字列がブロックの外にあるにもかかわらず、
初期化の際にはその値が使われます
(確かこういうのをクロージャと言ったはず・・・)
このように、Hashクラスの初期化機能は強力です
Hashを作る時は{}と盲目的に考えるのではなく、
必要に応じてHash.newを使うことを考えてみてください(`・ω・´)ノ
<おまけ1>
考えてみれば、Rubyは数値だってオブジェクトです
ということは、Hash.new(0)だって、
参照先の状態を書き換えれば、初期値が変わるはずです
もし、数値を表すNumericクラスに、
状態を書き換える「破壊的メソッド」が存在すれば、
この懸念は現実となります
しかし、Numericクラスは破壊的なメソッドをもたない、
immutable(不変)なオブジェクトので、
内部の「値」を書き換えることができません
(TrueClassやNilClassも同じ)
なので、今回の用途でも安心して使うことができるのです
<おまけ2>
今回の話を応用すると、こういうことができます
map = Hash.new{|h, k| h[k] = Hash.new(0)} p map[7][8] #=> 0
以前書いたランダムダンジョンのロジックで、
座標の管理をするときに、これと似た手法を使ってます
直感的に「座標(7,8)の値」と読めますからね(`・ω・´) b