LoginSignup
0

More than 1 year has passed since last update.

Ruby の構造体的・ハッシュ的データ構造についての考察 (1)

Posted at

いや,まあ「考察」なんて立派なものじゃないんだけどさ。

とりあえず長文にはなっちまったわい。

はじめに

プログラミングで扱うデータは,単一の数値だったり文字列だったりという単純なものもあるけど,もっと複雑なものもある。
例えば書誌情報の場合,一つのデータが「書名が〇〇で,著者が〇〇で,出版元が〇〇で,発行年が〇〇」といった複数の情報からなっている。以下のように。

属性名 属性値
書名 Ruby on Fails
著者 ルビ子
出版者 譜露蔵民具社
発行年 2023

本記事では,このように「◎◎が〇〇で,◎◎が〇〇で」といった情報(のカタマリ)を表すデータ型について考える。

どんな方法があるか

このようなデータを表す方法はたくさんあるが,大きく分けて

  • 汎用のクラスを使う方法
  • (書誌情報なら書誌情報専用の)クラスを作る方法

がある。

前者の代表としてハッシュを使う方法が考えられるし,後者の代表としてはクラス式でクラスを定義する方法が考えられる。

この節では,いくつかの方法を具体的に挙げる。あとの節でそれらの特徴や用途について考えてみる。

ハッシュで表す

Ruby のハッシュはキーとして数値などさまざまなクラスのオブジェクトが使えるが,この記事のテーマに沿ったハッシュの場合,キーには文字列かシンボルのどちらかを使うのがよさそうだ。
どちらを使っても大差ないが1,シンボルを採用する場合,ハッシュ式にはいわゆるコロン記法が使えて,

book = {
  title: "Ruby on Fails", 
  author: "ルビ子",
  publisher: "譜露蔵民具社",
  publish_year: 2023
}

book[:title] = "Ruby on Tails"
p book[:title] # => "Ruby on Tails"

といったふうに書ける。

OpenStruct を使う

Ruby の標準添付ライブラリーに ostruct というものがある。これを require すると,OpenStruct というクラスが使える。
OpenStruct はいわゆる構造体の一種だ。

OpenStruct は,以下のように書ける。

require "ostruct"

book = OpenStruct.new(title: "Ruby on Fails", author: "ルビ子", publisher: "譜露蔵民具社", publish_year: 2023)

book.title = "Ruby on Tails"
p book.title # => "Ruby on Tails"

属性名(例:title)を名前にしたメソッド(title および title=)でアクセスできるところがハッシュとの大きな違いである。

クラス式でアクセッサーを持つクラスを作る

ここでは,属性名によるアクセッサー(ゲッターとセッターの総称)を定義したクラスを定義することを考える。
Ruby でクラスを作る方法は複数があるが,最も基本的なのは以下のようなクラス式を使う方法だろう。

class Book
  attr_accessor :title, :author, :publisher, :publish_year

  def initialize(title:, author:, publisher:, publish_year:)
    @title = title
    @author = author
    @publisher = publisher
    @publish_year = publish_year
  end
end

book = Book.new(title: "Ruby on Fails", author: "ルビ子", publisher: "譜露蔵民具社", publish_year: 2023)

book.title = "Ruby on Tails"
p book.title # => "Ruby on Tails"

アクセッサー(例:title, title=)を定義するところは attr_accessor を使って楽をしているが,initialize を書くのがめんどくさい。単純なことをやっているのに,こんなに行数を使わなくてはいけないものだろうか。アホくさい。

次に述べる Struct は,このアホらしさを解消する手段の一つと捉えることができるだろう。

Struct でクラスを作る

Struct は組込みクラスなので,require が必要ない。
クラス名はもちろん structure から来ており,いわゆる「構造体」と呼ばれるデータを表すことを目的としている。
しかし,Struct クラス自体がインスタンスを生成することはなく,サブクラスを生成して使うようになっている。

以下のようにする

# Struct のサブクラスとして Book クラスを生成
Book = Struct.new(:title, :author, :publisher, :publish_year, keyword_init: true)

# ここから下は「クラス式でアクセッサーを持つクラスを作る」節と同じ
book = Book.new(title: "Ruby on Fails", author: "ルビ子", publisher: "譜露蔵民具社", publish_year: 2023)

book.title = "Ruby on Tails"
p book.title # => "Ruby on Tails"

Struct の使い方に関して 3 点注意しておきたい。

まず,Struct.new の最後の引数 keyword_init: true は何なのか,ということ。
これはインスタンスを生成するときにキーワード引数を用いる,ということを指定している。
これが無い場合,インスタンスの生成はキーワード引数ではなく,以下のように位置引数で行うことになる。

Position = Struct.new(:x, :y)
position = Position.new(2.4, -0.3)

しかし,「属性の順序」というものにとくに意味がない構造体や,属性が多い構造体ではこれはなかなかツラい。属性の順序が覚えられないからである。
したがって,本記事では全ての例でキーワード引数方式を採用する。
なお,Ruby 3.2 以降では,keyword_init: true を付けなくてよくなるらしい。
参考:Ruby 3.2 で Struct がチョイ楽になる? - Qiita

次に,Struct.newStruct のインスタンスではなくサブクラスを生成するということ。クラスに対する new がインスタンスを生成しないというのは極めて特異であり,なぜそんな設計にしたのか私には理解できない。

もう一つは,Struct.new しただけでは無名クラスが生成されるが,これを定数(上記コードでは Book)に代入することによってクラスの名前が付く(代入した定数と同名のクラスになる)ということ。
以下の例を見よう。

# Struct のサブクラスを生成するが定数にはまだ代入しない
s = Struct.new(:foo)

# クラス名は無い(クラス名を返すメソッドが nil を返す)
p s.name # => nil

# 定数に代入する
Hoge = s

# クラス名が Hoge になる
p s.name # => Hoge

しかし,これは Struct 特有ではなく,無名クラス全般に言えることだ。以下の例を見よう。

c = Class.new
p c.name # => nil
Kurasu = c
p c.name # => Kurasu

さて,「クラス式でアクセッサーを持つクラスを作る」節のやり方と比べると,Struct を使うほうがだいぶシンプルだ。
とりわけ initialize メソッドを書かなくてよいところが大きい。

なお,Struct には

といったメソッドがある。

求められる機能,特性

前節では,汎用クラスを用いる方法 2 種と専用クラスを定義する方法 2 種を紹介した。
ほかにもまだまだやり方はあるが,いったんおく。

この節では用途を考えながら,どのような機能や特性が求められるかを考える。

ここまで書誌情報をサンプルにしてきたが,ここでサンプルを一つ増やそう。
メソッドに何かデータを渡して何らかの処理をしてもらうが,その処理の仕方を細かく指示するために「オプションのカタマリ」を渡したい,という状況を考える。
たとえば,英文テキストを渡してそこに含まれる単語のリストを返してもらうとする。このとき,

  • 異なり語のリストが欲しいのか,重複を含めるのか
  • 同一語かを判定するのに大文字・小文字を同一視するのか

がオプションで変えられるようにしたい。
それぞれ,unique(真なら異なり語,偽なら重複あり),ignore_case(真なら大文字・小文字の差を無視する,偽なら無視しない)というオプションを使う。

これはキーワード引数を使って

def extract_text(text, unique:, ignore_case:)
  # 云々
end

とするのが一般的だが,このオプションを変数に保管したりしたい場合は,それをデータで表現することになる。
ハッシュを使うなら

# インスタンス変数に保管する場合
@extract_options = {
  unique: true,
  ignore_case: false
}

のようになる。

以下では「書誌情報」と「オプション」を例に取って考えていく。

require せずに使えるか

これは「組込みクラスか」という問いに近い。

HashStruct は組込みクラスであるし,クラス定義式はもちろん Ruby の文法の一部なのでライブラリーを要しない。

一方,OpenStruct は標準添付ライブラリーなので require が必要だし,外部ライブラリーであればインストールしなければ使えない。

専用クラスが必要か

書誌情報のように,一つのプログラムで一度に多数の書誌情報を扱う場合はクラスを使いたい気がする。一方,オプションのように一つ作って渡すだけのものはクラスは要らない気がする。
つまり,

  • 同種のオブジェクトが多数作られるならクラスが必要
  • 一回性のデータ(同種のものを一つしか作らないもの)はクラスが不要

となりそうだが,果たしてそういう考え方でよいのか?

これは違うと思う。

たとえばあなたが,Ruby のおすすめ本リストを YAML ファイルで記述し,それをスクリプトで処理して HTML 化し,ウェブで公開したいとする。
YAML ファイルは

-
  title: Ruby on Fails
  author: ルビ子
  # 以下略
-
  title: オブジェ嗜好
  # 以下略

みたいになるだろう。この YAML ファイルをロードすると,「ハッシュの配列」の形のオブジェクトが得られる。
これを「Book オブジェクトの配列」に変換すべきだろうか?
これは場合によりけりだ。おすすめ本リストの単純な HTML を作るだけなら Book クラスの必要性はあまりない。

一方,オプションのほうはどうか。
ちょっと考えてほしい。ignore_case に意味があるのは unique が真のときだけであることを。
unique が偽なのに ignore_case が真というのは,何かがおかしいのだ。こういうオプションデータが作られたとしたらバグの可能性が高い。

これに対処するには,オプションを渡されたメソッドのほうでチェックすることも考えられるが,オブジェクトのほうに検査機能を持たせたほうがよいこともあるだろう(場合によりけり)。
となると,ExtractOptions のようなクラスを作る意味が出てくる。

再び書誌情報に戻ると,もし body_price(本体価格)という属性を持たせて,total_price(税込価格)を得るメソッドを設けたいなら Book クラスを作る意味が出てくる。

要するに,クラスを作るか否かには

  • 属性を保持する以上の機能が必要かどうか

という観点が関わってくるのだと思う。

もう一つの観点は,属性があらかじめ決まっているかどうか。専用クラスを作るのは基本的にあらかじめどんな属性を持たせるかが決まっている場合になるだろう。そのときどきで属性が増減するようなデータなら,ハッシュや OpenStruct のような汎用クラスが向いている。

メソッドでアクセスしたいか

ハッシュの場合,属性の読み書きは

# 読み出し
book[:title]

# 書込み
book[:title] = "Ruby on Tails"

のように Hash#[] および Hash#[]= をそれぞれ用いる。
属性の指定は,属性名を引数として渡すことで行う。
それでももちろんよいのだが,アクセッサー(属性の読み書きメソッド)でアクセスするのであれば

# 読み出し
book.title

# 書込み
book.title = "Ruby on Tails"

と書けて,いくぶんスッキリする。

[]/[]= 方式とアクセッサー方式には一長一短がある。

どの属性にアクセスするかが動的に決まる場合,アクセッサー方式だと send メソッドを使って

attr_name = :title

book.send(attr_name)

のようにする必要があるが,send はカジュアルに使ってよいメソッドではない。引数がユーザー入力によって決まるような場合には深刻なセキュリティーホールとなる可能性もある。
また,

send が再定義された場合に備えて別名 __send__ も用意されており、ライブラリではこちらを使うべき

という話もあったりして(公式リファレンスより),ややこしい。

それから,属性名がスペースやハイフンを含んでいたり,数字で始まっていたりすると,メソッド名としては使えない。
「そんな識別子を属性名に使うのがおかしい」という意見もあるだろう。でも,属性名って,何かに合わせて命名せざるを得ないこともしばしばあるんだよね。

その点,StructOpenStruct も,属性名のアクセッサーによる方法のほかに,[]/[]= による方法も用意されており,こちらであれば上記の問題は生じない:

Struct の場合
Foo = Struct.new(:"1st_attr", :"x ord", keyword_init: true)
foo = Foo.new("1st_attr": 1, "x ord": 2)
p foo[:"1st_attr"] # => 1
p foo[:"x ord"] # => 2
OpenStruct の場合
require "ostruct"
foo = OpenStruct.new("1st_attr": 1, "x ord": 2)
p foo[:"1st_attr"] # => 1
p foo[:"x ord"] # => 2

既存のメソッドと属性の名前が被ったら

StructOpenStruct など,属性名のメソッドで読み書きするものでは,「属性の読み書きではないメソッド」と名前がかぶらないか気にしなくてはならない。

とくに,StructEnumerable が include してあるため,属性値をイテレートする Enumerable の豊富なメソッドが使える。
たとえば属性名として以下のような名前は十分にありうる。

  • min
  • max
  • sum
  • filter
  • sort(種類)
  • cycle(周期)

これらは,Enumerable のメソッドとして既にあるので,属性として使うと被ってしまう。

被った場合どうなるのか?
StructOpenStruct も,被った場合は属性値読み出しメソッドが上書きするので,元のメソッドは使えなくなるようだ。

個人の感想だが,StructEnumerable のメソッドを使う機会はあまりないと思うので,あまり困らないと思う。必要なら Struct#values で配列にしてから処理すればいい。なぜ StructEnumerable を include したのか私には不思議に思える。

属性名を間違えたときにどうなってほしいか

データを作るときに属性名を間違えたり,属性にアクセスするときに属性名を間違えたりすることがある。

例えば,書誌情報が

book = {
  tile: "Ruby on Fails", # 属性名が間違っている
  author: "ルビ子",
  publisher: "譜露蔵民具社",
  publish_year: 2023
}

となってしまっていた場合,タイトルを取り出そうとして

book[:title]

としても nil が返る。エラーは出ない。
逆に書誌情報が合っていて,読み出すほうが

book[:tile]

と誤っていても同じ。
これらは,OpenStruct を使っている場合もだいたい同じだ。存在しない属性の値を読み出そうとすると nil を返すのである。

Ruby である程度プログラミングの経験を積むと,「思わぬところで nil が出る」のがなかなか厄介と思い知らされるので,これを避けたい場合も多いのではないか。

その点,Struct や,「クラス式でアクセッサーを持つクラスを作る」節で示したやり方の場合,オブジェクトを作るときも属性を読み出すときも,属性名が間違っていたらエラーが出るので,間違いに気づきやすい。
例えば以下のようになる。

Foo = Struct.new(:name, keyword_init: true)

Foo.new(namae: "Scivola")
# => unknown keywords: namae (ArgumentError)
Foo = Struct.new(:name, keyword_init: true)
foo = Foo.new(name: "Scivola")

p foo.namae
# => undefined method `namae' for #<struct Foo name="Scivola"> (NoMethodError)

なお,ハッシュで存在しないキーの値を読み出そうとしたときにエラーを出させることはできる。
ブロック形式のデフォルトを使えばよい:

foo = Hash.new{ |hash, key| raise "Unknown key #{key}" }
foo[:name] = "Scivola"

p foo[:namae] # => Unknown key namae (RuntimeError)

ちょっと面倒ではある。

ここまでは,属性値の読み出しを取り上げたが,書込みはどうか。

Struct や,「クラス式でアクセッサーを持つクラスを作る」節で示したやり方の場合,属性名を間違えると NoMethodError が出る。

ハッシュや OpenStruct の場合,単純に要素なり属性なりが増えるだけである。「OpenStruct」の「open」は属性があとからいくらでも追加できるという意味のオープン性を表しているのだろう。

require "ostruct"

foo = OpenStruct.new(x: 3)
foo.y = 4

p foo # => #<OpenStruct x=3, y=4>

属性名の指定で文字列とシンボルの区別はあるか

ハッシュのキーでは,文字列 "abc" とシンボル :abc は区別される。
つまり,

h = {"abc" => 1, :abc => 2, "def" => 3}

p h["abc"] # => 1
p h[:abc]  # => 2
p h[:def]  # => nil

ということ。
実際のプログラムではこんなふうに文字列のキーとシンボルのキーを混用することはほぼ無く,ふつうはどちらかに統一する。
しかし,そのどちらであるかを間違えるとバグになる。つまり,「シンボルのキーを持つハッシュ」なのに,誤って文字列のキーでアクセスしようとすると正しい結果が得られない。

私自身はときどき,こういう間違いをしてしまう。
「全部シンボルに統一すれば記憶違いも無くせるのに」という意見もあろう。まあハッシュ式でハッシュを生成するぶんにはたいがいそれでいいし,私もそうしている。
しかし,たとえば CSV クラスで headers: true で取り込んだ CSV の行データ(これはハッシュではないがハッシュに似たインターフェースを持つ)だとキーは文字列であり,シンボルで読み出そうとすると nil を返す。
また,YAML や JSON から生成されるハッシュはキーが文字列だったりする。以下のように:

YAML 版
require "yaml"

book = YAML.load(<<~BOOK)
  title: "Ruby on Fails"
  author: "ルビ子"
  publisher: "譜露蔵民具社"
  publish_year: 2023
BOOK

p book["title"] # => "Ruby on Fails"
p book[:title]  # => nil
JSON 版
require "json"

book = JSON.parse(<<~BOOK)
  {
    "title": "Ruby on Fails",
    "author": "ルビ子",
    "publisher": "譜露蔵民具社",
    "publish_year": 2023
  }
BOOK

p book["title"] # => "Ruby on Fails"
p book[:title]  # => nil

YAML の場合,

:title: "Ruby on Fails"
:author: "ルビ子"
:publisher: "譜露蔵民具社"
:publish_year: 2023

のようにキー名の頭に : を付けることでシンボルを表すことができるが,煩わしい。

また,JSON の場合,オプションでキーをシンボルにすることができる:

require "json"

book = JSON.parse(<<~BOOK, symbolize_names: true)
  {
    "title": "Ruby on Fails",
    "author": "ルビ子",
    "publisher": "譜露蔵民具社",
    "publish_year": 2023
  }
BOOK

p book["title"] # => nil
p book[:title]  # => "Ruby on Fails"

しかし,これも symbolize_names: true が面倒だし,symbolize_names を間違って symbolize_name としたり,symbolize_keys などとしてしまってもエラーは出ない。

なおハッシュのキーをあとからシンボルに変換することはできる。以下のようにする:

book = book.transform_keys(&:to_sym)

さて,ハッシュのケースを長々と書いたが,StructOpenStruct ではどうか。
嬉しいことに,文字列かシンボルかは無頓着で構わない。以下のように:

Struct の場合
Foo = Struct.new(:a, :b, keyword_init: true)
foo = Foo.new(a: 1, b: 2)

p foo[:a]  # => 1
p foo["a"] # => 1
OpenStruct の場合
require "ostruct"

foo = OpenStruct.new(a: 1, b: 2)

p foo[:a]  # => 1
p foo["a"] # => 1

これは大変楽で良い。
ハッシュのキーがオブジェクト一般であるのに対し,StructOpenStruct の属性名は名前であり,String と Symbol を区別する必要がないためこのようになっているのだろう。

属性が順序を持つか

ハッシュ(他言語では連想配列とかマップ,ディクショナリーなどとも呼ばれる)は一般に,要素が順序を持たない。言い換えれば,要素を順に読み出すとき,その順序は不定だ。
Ruby のハッシュも昔はそうだったのだが,確か Ruby 1.9 で順序を持たせる仕様に変わった。したがって,

hash = {a: 1, b: 2}

hash.each do |key, value|
  p [key, value]
end
# => [:a, 1]
#    [:b, 2]

において,この順に表示されることは言語仕様として保証されている。

StructOpenStruct には属性に逐次アクセスする仕組み(メソッド)があるが,その際の順序は保証されているのだろうか? どちらのクラスもドキュメントには記述が見当たらなかった。

しかし,結論から言うと Struct については順序は保証されているはずだ。
本記事では,Struct のサブクラスを作るときに keyword_init: true として,キーワード引数によるインスタンス生成を可能にした例ばかり提示してきたが,Struct はもともと

Foo = Struct.new(:a, :b)
foo = Foo.new(1, 2)

p foo.a # => 1
p foo.b # => 2

foo.each do |value|
  p value
end
# => 1
#    2

のように位置引数でインスタンスを生成し,each では(属性名無しに)属性値だけをイテレートするものであったようだ。
このような機能は,属性が順序を持っていないならナンセンスである。よって順序はあると考えるほかない。
ちなみに Struct(のサブクラス)には Struct#each_pair というメソッドがあり,こちらは属性名(シンボル)と属性値のペアをイテレートする。Struct には Enumerable が include されているが,Enumerable の機能は each のほうを使うことに注意しよう2

では,OpenStruct はどうか。おそらく順序は保証されているのだろう。実装 を見ると,内部で @table というインスタンス変数を持っていて,ここにハッシュの形で属性群を保持しているので,実装上は確実に順序を持つようだが,「仕様として保証されている」かというと 100% の確信が持てない。

欠けているクラスは

この記事を書いた動機の一つは,日頃のモヤモヤだ。
ハッシュや OpenStruct のようにオブジェクトが手軽に作れて,属性値をメソッドで読み出せるものが欲しい,と思うことがよくある。
要件はさまざまだが,典型的なのは以下のようなケースだ。

  • クラスを新たに定義しない
  • メソッド一つでオブジェクトが作れる
  • 属性は固定(あとから増減できない)
  • 読み書きは属性名のメソッド,および []/[]= の両方でできる

組込みクラスや標準添付ライブラリーには無いんだな,これが!

こういうクラスを仮に Kzt(コウゾウタイより)と名付けると,以下のようなイメージだ。

require "kzt"

feed = Kzt.new(morning: 3, evening: 4)
# あるいは
feed = Kzt(morning: 3, evening: 4) # Kernel.#Kzt メソッドも定義される
# のどちらも使える

p feed.morning # => 3
p feed[:evening] # => 4
p feed["evening"] # => 4

feed[:morning] = 4
feed["evening"] = 3

feed.each_pair do |name, value|
  p [name, value]
end
# => [:morning, 4]
#    [:evening, 3]
# 順序は保証される

p feed.values_at("morning", :evening) # => [4, 3]
# このメソッドは無くていいかも

feed.noon = 5 # => NoMethodError
p feed.noon   # => NoMethodError
p feed[:noon] # => ArgumentError

おわりに

ハッシュや構造体のようなデータを作る方法として,以下の四つを例にとって検討を加えた。

  • Hash
  • Struct
  • OpenStruct
  • クラス式でアクセッサーを持つクラスを作る

それぞれに特徴があり,用途に応じて使い分けることが重要だという感触を得た。
また,筆者が欲する特徴を備えた構造体クラスが組込みクラスや標準添付ライブラリーに無いことも述べた。

この記事にはたぶん(?)続編がある。いつ書けるかは分からないけど。

続編では,構造を取り上げたいと思う。入れ子というのは,ある属性の属性値がまた構造体のようなものになっているようなものを指す。
そこでは,confighashie といった外部ライブラリーを例に取ることになるだろう。

また,それよりも前に,HashStructOpenStruct の使い分けについて何か書くかもしれない

  1. パフォーマンス上はシンボルが良いとされるが,この記事ではそこに触れない。

  2. 属性名と属性値のペアで Enumerable の機能が使いたければ,ブロック無しの each_pair を呼び出して Enumerator オブジェクトを生成すればよい。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0