Ruby に型があると便利か

はじめに

この記事は東京 RubyKaigi11の発表である、「Ruby に型があると便利か」(スライド)(動画) を元に、るびま用に書き起こしたものです。

TypeStructという gem の紹介と解説記事になります。

TypeStruct のきほん

TypeStruct は Ruby 組み込みの Struct のように class を作る class です

TypeStruct を一言で言うと「C 言語や golang の struct を Ruby で再現したもの」と言えます。実際に、TypeStruct は golangcrystal-lang に影響を受けています。

インストール

TypeStructはgem化しているのでrubygemsからインストールできます。

 $ gem install type_struct

ruby v2.1以上をサポート対象としています。

定義

TypeStruct ではデータの集まりを「型」として定義できます。

定義には TypeStruct.new を使います。

定義は Ruby のコードになっているので、 Ruby のコード上ならどこにでも書けます。

NewClassName = TypeStruct.new(
  key_1: Integer,
  key_2: String,
)

これで NewClassName という class が定義されたことになります。

どこか他言語を想起させる形ですね。

渡した Hash の key はそのままメンバー名に、value は class チェック時に使われます。

これが TypeStruct の型定義文になります。

NewClassName.new とすることで、新たに作った型 class のインスタンスを作ることができます。

作成・代入

インスタンス作成時には、最初に定義した class を new するだけです。

インスタンス作成後も最初の定義にそっている限り代入もできます。

foo = NewClassName.new(
  key_1: 123,
  key_2: 'hello',
)
p foo # #<NewClassName key_1=123, key_2="hello">
p foo.key_1 #=> 123
p foo.key_2 #=> 'hello'

foo.key_1 = 0
foo.key_2 = 'world'
p foo #<NewClassName key_1=0, key_2="world">
p foo.key_1 #=> 0
p foo.key_2 #=> 'world'

型定義に沿っていれば、TypeStruct は、ただの Struct とそれほど差はありません。

TypeStruct はマジカルなことをするライブラリではなく、あくまでただのデータの入れ物なのです。

よくある注意点としては、インスタンス作成時も型定義にそっていなければならないので「インスタンス作成時は nil を入れておいて、

後で定義通りの値を入れよう」といったことが__できません__。

理由は、一瞬でも定義とは違う値が入ることを許してしまうと、その一瞬は定義から外れた値が入っていることになるので、定義の意味がなくなってしまうからです。

例外

型定義にそっていない値でインスタンス化すると、エラーになります。

途中の代入でも型定義に合っていなければ同じくエラーになります。

p NewClassName.new(
  key_1: '123',
  key_2: 'hello',
)
# TypeStruct::MultiTypeError:
# ...:in TypeError NewClassName#key_1 expect Integer got "123"

foo = NewClassName.new(
  key_1: 123,
  key_2: 'hello',
)
foo.key_1 = '123'
#=> TypeError: NewClassName#key_1 expect Integer got "123"

このエラーこそ TypeStruct の真骨頂です。

静的言語のような静的チェックではなく、実行時での動的チェックになりますが、想定していなかった挙動をエラーという形で検知できます。

誤解を恐れずに言うと「実行時にテストしているようなもの」なのです。

ところで、TypeStruct::MultiTypeErrorTypeError が出てきました。

TypeError は Ruby の組み込み class ですが、名前が Type* なのであえて使っています。

MultiTypeError は何でしょうか。 これは、もし複数の型チェックエラーがあった場合、全ての情報を出したいという要求から生まれました。

MultiTypeError により、複数の型エラーがあった場合、全てのエラーを列挙してくれるので、一つなおしては実行してエラーを確認してまた一つなおす。といったストレスを緩和します。

TypeStruct は、ドキュメント化として読み手にもメリットがあり、デバッグのしやすさに力を入れているので書き手にもメリットがあるライブラリーなのです。

サポート class

TypeStruct にはその機能をサポートするための追加 class がいくつかあり、 よく使うのがこの TypeStruct::ArrayOfTypeStruct::Union の二つの class です。

この二つをTypeStructと組み合わせることで、より柔軟なデータ構造が表現できます。

ArrayOf

ArrayOf は「〜の Array」を表すもので、ArrayOf.new(String) とすると、「String の Array」という型であることを TypeStruct で定義できるようになります。

Name = TypeStruct.new(
  values: TypeStruct::ArrayOf.new(String)
)
name = Name.new(values: ['foo', 'bar', 'baz'])

p name.values
#=> ["foo", "bar", "baz"]

name.values = [1, 2, 3]
#=> TypeError: Name#values expect TypeStruct::ArrayOf(String) got [1, 2, 3]

ちなみに require ‘type_struct/ext’ とすると ArrayOf はメソッドとして定義されるようになり、ArrayOf(String) のように使えます。 ネームスペースを消費する副作用があるので、別途 require するようにしています。

require 'type_struct/ext'
Name = TypeStruct.new(
   values: ArrayOf(String)
)


Union

Union は「A か B のどちらかのうちの一つ」を表す型です。

最も使う頻度が高いのは、true もしくは false がありえるメンバーと、nil がありえるメンバーでしょう。

Name = TypeStruct.new(
  is_show: TypeStruct::Union.new(true, false), # trueもしくはfalse
  value: TypeStruct::Union.new(String, nil), # Stringもしくはnil
)
name = Name.new(
  is_show: true,
  value: nil,
)
p name.value = 'ksss' #=> 'ksss'
p name.is_show = nil
#=> TypeError: Name#is_show expect #<Union true|false> got nil

このように、複数の型がありえるメンバーに有効なのが Union です。

上級者向け機能として、using TypeStruct::Union::Ext とすると、_Class# _ メソッドが定義され、以下の様な書き方ができるようになります。
using TypeStruct::Union::Ext
Foo = TypeStruct.new(
  num: Integer | nil #=> Integerもしくはnil
  name: Regexp | String #=> RegexpもしくはString
)

crystal-lang のようでカッコイイですね。

TypeStruct.from_hash

TypeStruct をさらに強力にする機能が、この from_hash です。

from_hash は TypeStructで作った型classから、Hash オブジェクトを元に TypeStruct のオブジェクトに変換します。そして、変換は定義にそって__再帰的__に行われます。

これは、Web API や設定ファイルなどの外部情報について、 TypeStruct のメリット(意味ある名前・期待した値・ドキュメント化)を享受できるようにと開発しました。

ここでは、from_hash の魅力をコードで紹介するため、とあるアプリケーション開発で TypeStruct を使う前と使った後で比較し丁寧に解説します。

from_hash の使用前

例として、Rails で組んだ Web API を実装する場合を考えます。 ユーザーが GUI 上で丸や三角などの図形を様々に配置して、配置情報をサーバーに保存する架空のアプリケーションです。

それぞれの図形には図形の ID ・ X,Y 座標・大きさ・回転角度などの情報を持っています。

これらの情報は JSON 形式でクライアントからバックエンドへと送られます。

送られた JSON は保存され、JSON の情報を元に一枚の画像として合成され、チーム間でシェアできる。 そんな架空アプリです。(くどい)

この「JSON の情報を元に、一枚の画像として合成され」の部分では JSON 文字列を Ruby で parse してループを回し、それぞれの ID から図形画像を参照して座標情報から合成する。 といったプログラムが想像できます。

composition = Composition.new
json["layers"].each do |layer|
  layer["figures"].each do |figure|
    case figure["typo"]
    when "circle"
      circle = Circle.find(figure["circle_id"])
      image = circle.download
      composition.add(image, figure["position"])
    when "triangle"
      # ...
    when "square"
      # ...
    end
  end
end
composition.to_png

こんなプログラムで怖いのは、プログラムのtypoによるミスではないでしょうか。(実際に、上のプログラム内にはtypoが潜んでいます)

Hash#[] では typo は nil として扱われます。 プログラミング中に、「クライアント側からの値がおかしい」のか「JSON の順番を間違えた」のかなどと考えてデバッグしている内に「ただの typo だった」というオチで時間を取られてしまったという経験はないでしょうか。 Hash#[] での typo は did_you_meanでも対応できません。

「typo 対策なら Hash#fetch がある」は良い案です。 typo したら KeyError として教えてくれますし、did_you_mean も最新版では対応されています。1 しかしながらプログラムの見た目は obj.fetch(“key”) ばかりになります。 またcase 文では自動的に === メソッドが使われます。 それに「数字を期待していたが文字列だった」のようなケースは防げません。

from_hash 使用後

そこでいよいよ TypeStruct の from_hash の出番です。

まず型定義を用意します。

require 'type_struct/ext'
module Type
  using TypeStruct::Union::Ext
  Position = TypeStruct.new(
    x: Numeric,
    y: Numeric,
    width: Numeric,
    height: Numeric,
    rotation: Numeric,
  )
  Circle = TypeStruct.new(
    type: "circle",
    circle_id: Integer,
    position: Position
  )
  Triangle = TypeStruct.new(
    type: "triangle",
    triangle_id: Integer,
    position: Position
  )
  Square = TypeStruct.new(
    type: "square",
    square_id: Integer,
    position: Position
  )
  Layer = TypeStruct.new(
    figures: ArrayOf(Circle | Triangle | Square),
  )
  Picture = TypeStruct.new(
    layers: ArrayOf(Layer),
  )
end

先ほどのプログラムを書きなおしてみます。 Type::Picture.from_hash(json) の部分が TypeStruct を使っている部分です

composition = Composition.new
Type::Picture.from_hash(json).layers.each do |layer|
  layer.figures.each do |figure|
    case figure
    when Type::Circle
      circle = ::Circle.find(figure.circle_id)
      image = circle.download
      composition.add(image, figure.position)
    when Type::Triangle
      # ...
    when Type::Square
      # ...
    end
  end
end
composition.to_png

いかがでしょうか。

  • typo してもすぐ気がつく(did_you_mean が効く)
  • Class 名があるので p デバッグがやりやすい
  • 余計な文字や記号が減り、見た目がすっきりする
  • もし JSON が想定外の形式だった場合にエラーとして検知できる
  • case 文が文字列から class 名になったので、typo しても NameError で気付ける

TypeStruct 導入によってさまざまなメリットが生まれました。

TypeStruct の利用例

ここでは TypeStruct が有効になる利用シーンを 3 つ上げ、具体的な導入方法を合わせて紹介します。

JSON API のサーバー側実装

from_hash の説明と被ってしまうので要点だけ。

複数人開発の場合は特に、「どんな key があってどんな値がありえるのか、この key は nil になる可能性はあるのか」と言った情報が共有されているべきです。

そこで、TypeStruct で型情報を書いておけば正確なドキュメントにもなります。

しかしながら、既存のコードベースに TypeStruct を導入する場合、いちいち型を書くのが面倒になるでしょう。 この場合は自動で型コードを生成してくれるものがあると便利だろうと、TypeStruct 型定義 generator を書いてみました。

以下のように使います。

 $ echo '{"say": [{"hello": "world", "and": 4649}]}' | ruby -r type_struct/generator/json
 Say = TypeStruct.new(
   hello: String,
   and: Integer,
 )
 AutoGeneratedStruct = TypeStruct.new(
   say: ArrayOf(Say),
 )

json の部分を yaml に変えることで yaml 形式にも対応できます。

実際のレスポンスをドキュメントや curl の結果などから generator に渡してやれば、 自動的に TypeStruct の型コードを生成してくれます。 これをコピー&ペーストするなりして使うことで型を書く手間をある程度減らせるでしょう。

JSON API のクライアント側の実装

JSON を受け取って処理するクライアント実装を書く場合では、 Hash の問題は存在するものの、TypeStruct を有効に使える機会は少ないでしょう。

理由は大抵の Web API クライアント実装を Ruby で行いたい場合は、特定のパブリックなサービスに対して行う場合が多いからです。

  • Web API のドキュメントが公開されていることが多い
  • レスポンスを使った実装コードは小規模になりやすい

という場合がほとんどなので、TypeStruct のドキュメントとしてのメリットが効果を持ちにくいのです。

しかしながら、それでも便利に使っていただける可能性を考慮し、 例としてドキュメントサービスで有名な esa.io の API ドキュメントを元に TypeStruct の型コードを書いてみました。

https://github.com/ksss/type_struct-esa

このように Web API のレスポンスの TypeStruct コードを書いておけば、 レスポンスを使って整形して分解して……、などのコードが書きやすくなるでしょう。2

config.yml

YAML で書かれたなんらかの設定ファイルを定義する場合にも、TypeStruct を活用できます。 筆者が出会った経験談としては、何段にもネストする YAML で書かれた config ファイルを、作ったはいいが YAML のインデントが一段ズレており不具合の原因になったというものでした。

この事故も、TypeStruct を使っていればもしかしたら防げたでしょう。

---
foo:
  bar:
    baz:
    - 1
    - 2
    - 3
    qux: 'aaa' # 本当は一段左にあるべき行

require 'yaml'
require 'type_struct/ext'
Bar = TypeStruct.new(
  baz: ArrayOf(Integer),
)
Foo = TypeStruct.new(
  bar: Bar,
  qux: String,
)
Root = TypeStruct.new(
  foo: Foo,
)
Root.from_hash(YAML.load_file("config.yml"))
#=> TypeStruct::MultiTypeError:
t.rb:13:in TypeError Foo#qux expect String got nil

既存の設定ファイルから型定義をつくる場合も、yaml の generator を使うことで作りやすくなります。

 $ cat config.yml | ruby -r type_struct/generator/yaml
 AutoGeneratedStruct = TypeStruct.new(
   ...
 )

おわりに

今回はTypeStruct gemについて紹介しました。

「使ってるよ」とか「こうなっているともっと便利なのに」とか「ここがイケてない」など、フィードバックをいただけると大変嬉しいです。

TypeStructに限らず、他言語のパラダイムを覗いてみるといつものRubyプログラムが少し違って見えて楽しいですね。

おまけ Hash? それとも Struct?

TypeStruct は Struct をベースに拡張した class です。 筆者は Ruby の Struct が好きなのですが、Ruby の Hash が便利すぎるために一度も使用したことがない方も多いのではないでしょうか。 そこで、どんな場合に Struct が便利でどんな場合に Hash が便利なのか、どうやって使い分ければよいのか考えてみました。

「TypeStruct を使うほどではないけど、Struct は便利かもなあ」と思っていただければ幸いです。

筆者の考えは「どんな key があるか固定なら Struct、不定なら Hash」です。

Struct のメリットは

  • class 名が付いているのでデバッグしやすい
  • key 名を typo しても即座にわかるのでデバッグしやすい3
  • メンバー呼び出しの syntax が書きやすい

Hash のメリットは

  • リテラルがあるので生成 syntax が書きやすい
  • 未知の key でも格納できる
  • keyword argument など、言語組み込みの機能が豊富

だと考えています。

多分に主観が含まれてはいますが、これらの以下のメリットから導き出されます。

(デバッグが必要になりそうなほど複雑 && 寿命の長いオブジェクト && key が固定)なら、Struct を使うことで、 デバッグのしやすさや、メンバー呼び出しのコードの綺麗さといったメリットを享受できます。

そして、(デバッグが不要なほど単純 || 寿命が短いオブジェクト || key が不定)なら、 Hash を使うことでリテラルや未知の key に対応しているといったメリットを享受できるということです。

だいぶ Struct の利用条件は狭そうです。 ほとんどの Rubyist は Hash の便利さはご存知だと思うので、Struct が使えるシーンのみ紹介します。

CLI アプリケーションのオプションを定義する場合を考えます。 CLI のオプションは、Ruby のオブジェクトにまとめてアプリケーション内で扱うことが多いでしょう。 ここで、CLI のオプションとしてどんな key がありえるのか、実装者自身は知っているはずです。 こんなときは Struct が便利です。

Option = Struct.new(
  # オプションの種類
  :aaa,
  :bbb,
  :ccc,
)
o = Option.new(
  # オプションのデフォルト値
  false, # aaa
  1,     # bbb
  'ccc', # ccc
)
OptionParser.new do |opt|
  # オプションの設定
  opt.on("--aaa AAA", "set aaa") do |arg|
    o.aaa = arg
  end
end.parse!(ARGV)

CLI.run(o)

としておけば、どんなオプションがあるのか、デフォルト値は何か、 どのオプションに対応付けられているのかが読みやすいでしょう。 こういったオプションは複雑なロジックの中に使われたり、 コードの中での寿命が長くなりやすいので、Struct のメリットを享受しやすいでしょう。 また、Struct はオブジェクトの生成が Hash よりも高速というのもメリットでしょう。

#! /usr/bin/env ruby

require 'benchmark/ips'

class Foo < Struct.new(:a, :b, :c)
end

Benchmark.ips do |x|
  x.report("Foo.new(1,2,3)") do
    Foo.new(1,2,3)
  end
  x.report("{a: 1, b: 2, c: 3}") do
    {a: 1, b: 2, c: 3}
  end
  x.compare!
end

 $ ruby t.rb
 Warming up --------------------------------------
    Foo.new(1,2,3)   231.971k i/100ms
   {a: 1, b: 2, c: 3}   129.456k i/100ms
 Calculating -------------------------------------
    Foo.new(1,2,3)      4.826M (± 5.9%) i/s -     24.125M in   5.018127s
   {a: 1, b: 2, c: 3}      2.017M (± 9.3%) i/s -      9.968M in   5.003450s

 Comparison:
    Foo.new(1,2,3):  4825587.7 i/s
   {a: 1, b: 2, c: 3}:  2017008.2 i/s - 2.39x slower

著者について

栗原勇樹 (twitter github)

プログラムを書くのが楽しすぎるプログラマ。RubyKaja 2014(from asakusa.rb)。㈱spicelife エンジニア。命より大事なものは家族。OSS開発を仕事にするのが夢。


  1. 提案したのオレオレ https://github.com/yuki24/did_you_mean/pull/71 

  2. なぜesa.ioなのかというと、筆者がよくAPIを叩いていたため。 

  3. Struct#[]にも対応する提案したのオレオレ https://github.com/yuki24/did_you_mean/pull/73