HashじゃなくStructを使ってみようメモ

ネットから HTML を取得し、 Nokogiri でパースして取れた項目をテキトーに Hash に入れて、それを配列に入れておく、ようなことをしょっちゅうしています。
Hash にしておくと当然ながら項目は Hash[:title] のように Hash#[] で取得することになります。メソッドじゃないです。ちょっと面倒だと思いつつ使っていました。
最近購入した

Effective Ruby

Effective Ruby

の項目10に「構造化データの表現には Hash ではなく Struct を使おう」という記事が載っています。 Struct だとキーをメソッドとして使うことが出来ます。
ということでネットの中に載っている Struct に関する項目を引用します いろいろ書きました。

作り方(るりまを参考に)

singleton method Struct.[]

dog = Struct.new("Dog", :name, :age)
#=> Struct::Dog
fred = dog.new("fred", 5)
#=> #<struct Struct::Dog name="fred", age=5>

Dog2 = Struct.new(:name, :age)
#=> Dog2
fred2 = Dog2.new("fred", 5)
#=> #<struct Dog2 name="fred", age=5>

fred.class.ancestors
#=> [Struct::Dog, Struct, Enumerable, Object, PP::ObjectMixin, Kernel, BasicObject]
fred2.class.ancestors
#=> [Dog2, Struct, Enumerable, Object, PP::ObjectMixin, Kernel, BasicObject]
# PP::ObjectMixin は pry で組み込まれています。

Struct の二種類の作り方でできるインスタンスは見かけ上似ているけど違うことが分かりました。
参考:RubyのStructクラス - A_few_resources

なおこの二番目の方法は

名前のないクラスは、最初に名前を求める際に代入されている定数名を検索し、見つかった定数名をクラス名とします。
singleton method Class.new

という匿名クラスの性質と同じ動作でクラス名が決定しています。

作り方2:クラスにメソッドを生やす

Struct.new のサブクラスを使ったり、 Struct.new にブロックを渡すと、メソッド定義が行えます。

class Dog3 < Struct.new(:name, :age) #サブクラス
  def say
    "Bow!"
  end
end
#=> nil
fred3 = Dog3.new("fred", 5)
#=> #<struct Dog3 name="fred", age=5>
[fred3.name, fred3.age, fred3.say]
#=> ["fred", 5, "Bow!"]
fred3.class.ancestors
#=> [Dog3, #<Class:0x007fd67ccf3a00>, Struct, Enumerable, Object, PP::ObjectMixin, Kernel, BasicObject]

Dog4 = Struct.new(:name, :age) do #ブロック渡し
  def say
    "Bow!"
  end
end
#=> Dog4
fred4 = Dog4.new("fred", 5)
#=> #<struct Dog4 name="fred", age=5>
[fred4.name, fred4.age, fred4.say]
#=> ["fred", 5, "Bow!"]
fred4.class.ancestors
=> [Dog4, Struct, Enumerable, Object, PP::ObjectMixin, Kernel, BasicObject]

継承関係を見ていると、四つ目のほうが良い感じがします。この書き方をスタンダードにしたいです。
ただし、ブロックなのでクラスのコンテキストが切り替わらず、例えばクラス内で定数を定義しても後で扱えないです(参考:RubyのStructのイディオムとアンドキュメンテッドな機能 - yarbの日記)。
またブロックでの再オープンは出来ないようです(定数再定義だと怒られたあとに再定義される)。でもクラス定義での再オープンはフツーに出来ますので、その手順でやりましょう。

Dog4 = Struct.new(:name, :age) do
  def eat
    "Yummy!"
  end
end
(pry): warning: already initialized constant Dog4
(pry): warning: previous definition of Dog4 was here
#=> Dog4
Dog4.new.respond_to?(:eat)
#=> true
Dog4.new.respond_to?(:say)
#=> false

クラス定義の再オープンはこんな感じ。

class Dog4
  def eat
    "Yummy!"
  end
end
#=> :eat
fred4.eat
#=> "Yummy!"

無名クラスだと使いづらいので、ちゃんと名付けてあげましょう。

使い方

Dog5 = Struct.new(:name, :age) do
  def say
    "Bow!"
  end
  
  def eat
    "Yummy!"
  end
end
#=> Dog5
fred5 = Dog5.new("fred", 5)
#=> #<struct Dog5 name="fred", age=5>
fred5.name    # メソッドとしてアクセス
#=> "fred"
fred5.age
#=> 5
fred5[0]      # 配列的なアクセス(メンバのインデックス)
#=> "fred"
fred5[1]
#=> 5
fred5[2]      # 存在しないインデックスだと IndexError
# IndexError: offset 2 too large for struct(size:2)
fred5.size
#=> 2
fred5["name"] # ハッシュ的なアクセス(文字列メンバ)
#=> "fred"
fred5[:name]  # ハッシュ的なアクセス(シンボルメンバ)
#=> "fred"
fred5.age = 7 # setter
#=> 7
fred5.age     # getter
#=> 7
fred5.to_a    # 全要素の配列返し
#=> ["fred", 7]
fred5.values
#=> ["fred", 7]
fred5.keys    # keys メソッドはない
#NoMethodError: undefined method `keys' for #<struct Dog5 name="fred", age=5>
fred5.members # 代わりに members メソッドを使う
#=> [:name, :age]
Dog5.members  # クラスメソッド members でも得られる
#=> [:name, :age]

るりまでの用語を読んでいると、ハッシュでの「キー・値」に相当する用語は Struct だと「メンバ・値」になるようです。

fred5.find { |item| item % 5 == 0 } # Enumerable のメソッドも使える
#=> 5
fred5.map { |item| item }
#=> ["fred", 7]
fred5.each_pair { |m, v| puts "#{m} => #{v}" }
name => fred
age => 5
=> #<struct Dog5 name="fred", age=5>
fred5.say       # ブロックで設定したメソッド
#=> "Bow!"
fred5.eat
#=> "Yummy!"
fred5.height    # 設定してないメソッドでは当然ながら NoMethodError
#NoMethodError: undefined method `height' for #<struct Dog5 name="fred", age=7>
fred5["height"] # ハッシュ的アクセスで存在しないキーの場合は NameError
#NameError: no member 'height' in struct
fred5["say"]    # メソッドをハッシュ的アクセスすることは(当然)出来ない
#NameError: no member 'say' in struct

参考1:ちゃぱてぃ商店IT部 @ ウィキ - ruby/サンプル/Ruby1.8 1.9 構造体のようなクラスを簡単に作るクラス「Struct」
参考2:

JavaScriptとかを見ていると、もうなんでもハッシュでいいんじゃないのという気もするわけだけど、ハッシュだと、

  • 属性定義が動的すぎる
  • 属性名のスペルミスに気付きづらい

ということ。逆に、実行時になるまで属性の数や種類が分からないときはハッシュのほうがいい。
RubyのStructのイディオムとアンドキュメンテッドな機能 - yarbの日記

参考3:

Struct vs. OpenStruct vs. Hash

Struct
  • データを格納するオブジェクトが必要で、且つコードを書く時点で必要なメンバが分かっている
  • ハッシュ的にも使いたい
  • メンバが少ないクラスを簡単に作りたい
  • メンバ名を間違えたときにエラーが出るようにしたい
OpenStruct

(略)

Hash
  • コードを書く時点で必要なメンバが決まっていない
  • メンバを追加できる必要がある

Ruby Best Practices- Structs inside out

参考4:

ポイント1: ハッシュは動的、構造体は静的

ハッシュは存在しない要素を参照したり、要素をあとから追加できます。一方、構造体は初めに定義した要素しか扱えません。…実行時まで属性名が決まらないような場合は、ハッシュの方がきれいに対応できます。

ポイント2: 等価の基準が違う

構造体は、たとえ内容が全く同じでも、元になった構造体自体が違う場合、等価になりません。

Person = Struct.new(:name, :age)
person = Person.new("Taro", 16)

Robot = Struct.new(:name, :age)
robot = Robot.new("Taro", 16)

p person == robot # => false
ポイント3: to_a の挙動が違う
ポイント4: 構造体はインスタンス生成に属性名がいらない

ポイント1の違いが一番大きいでしょう。参考リンク先では、例えば「文章中に出てくる単語をキー、その出現回数をバリューにしたいとき」のような動的な処理のときはハッシュがよいだろう、と結論付けられています。私もそう思います。
Ruby におけるハッシュ (Hash) と構造体 (Struct) の使い分け - Qiita

newとinitializeと

Dog = Struct.new(:name, :age) do
  def initialize(name, age)
    puts "I'm #{name}, #{age} years old. Bow!"
  end
end

fred = Dog.new("fred", 5)
I'm fred, 5 years old. Bow!
#=> #<struct Dog name=nil, age=nil>

Dog に initialize メソッドがあると Struct(で作ったクラス) の initialize メソッドが使われないので、 Struct のメソッドが使えません。

fred.name
#=> nil

なので super を使いましょう。

Dog = Struct.new(:name, :age) do
  def initialize(name, age)
    puts "I'm #{name}, #{age} years old. Bow!"
    super
  end
end

fred = Dog.new("fred", 5)
I'm fred, 5 years old. Bow!
#=> #<struct Dog name="fred", age=5>
fred.name
#=> "fred"

参考:Tips - Structクラスで簡単にクラスをつくろう | アルミナ解析室

なお、 Struct.new で作られたクラスの引数の数はチェックされず、少なければ nil が入り、多ければ切り捨てられます。引数の数までチェックするには initialize を入れておけばいいでしょう。

Dog2 = Struct.new(:name, :age) do
  def initialize(name, age)
    super
  end
end

fred = Dog2.new
# ArgumentError: wrong number of arguments (0 for 2)
fred = Dog2.new("fred", 5)
#=> #<struct Dog2 name="fred", age=5>

その他、「本当に Struct でいいのかい?」を考えるヒントになるリンク↓
Struct inheritance is overused - The Pug Automatic

同値性を変えてみる

生まれた場所が同じ場合に「同地」(…くだらん^^;)であるとします。

Dog = Struct.new(:name, :age, :birth_place) do
  def same_birth_place?(other)
    birth_place == other.birth_place
  end
end

fred = Dog.new("fred", 4, "osaka")
#=> #<struct Dog name="fred", age=4, birth_place="osaka">
john = Dog.new("john", 3, "osaka")
#=> #<struct Dog name="john", age=3, birth_place="osaka">
fred.same_birth_place?(john)
#=> true

このときに「同地であれば同値」、つまり fred.eql?(john) #=> true になるようにしてみましょう。そのためには Dog#eql? メソッドを作ると共に Dog#hash メソッドも作る必要があります。

instance method Object#hash

オブジェクトのハッシュ値を返します。Hash クラスでオブジェク トを格納するのに用いられています。
メソッド hash は Object#eql? と組み合わせて Hash クラスで利用されます。その際
A.eql?(B) ならば A.hash == B.hash
の関係を必ず満たしていなければいけません。eql? を再定義した時には必ずこちらも合わせて再定義してください。
instance method Object#hash

では二つを作ります。上で見たようにクラスを再オープンすればいいですね。

class Dog
  def eql?(other)
    same_birth_place?(other)
  end
  
  def hash
    code = 17
    code = 37 * code + birth_place.hash
    code
  end
end

この「汎用ハッシュレシピ」は「プログラミング言語Ruby」p.233に載っていました。

プログラミング言語 Ruby

プログラミング言語 Ruby

(ちなみにその元は
EFFECTIVE JAVA 第2版 (The Java Series)

EFFECTIVE JAVA 第2版 (The Java Series)

だそうです。)

この hash メソッドの定義をもうちょっと一般化しておきましょう。

class Dog
  def hash
    %i[birth_place].inject(17) do |code, member|
      37 * code + __send__(member).hash
    end
  end
end

inject メソッドの前の配列に比較するためのメンバをいれておけばいいです。
もしくは、比較するためのメンバを %i[birth_place] のように具体的に挙げるのではなく、逆に比較しないためのメンバ %i[name age] を全メンバ Dog#members から取り去るようにしてもよいでしょう。

class Dog
  def hash
    (members - %i[name age]).inject(17) do |code, member|
      37 * code + __send__(member).hash
    end
  end
end

何を以て比較したいか、何を外して比較したいか、視点の違いで選びましょう。

さて。

fred.eql?(john)
#=> true
fred.hash
#=> 2080544753624017431
john.hash
#=> 2080544753624017431
fred.hash == john.hash
#=> true

となりました。
Array#- で要素を削除する判断は hash メソッドでの返値によるので、

[fred] - [john]
#=> []

という性質が Dog クラスに加わりました!
同じく、ハッシュキーの同値性は hash メソッドでの返値で判断しますので

{ fred => 2 }[fred]
#=> 2
{ fred => 2 }[john]
#=> 2

にもなります。

eql? メソッドと hash メソッドをいじって同値をいじる方法は Struct オブジェクトに限定されず、どんなクラスのオブジェクトでも使えます。ただし両方のメソッドを変更しないと効果が現れない点が重要です。