ぱろっと・すたじお

技術メモなどをまったりと / my site : http://parrot-studio.com/

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