この記事は東京 RubyKaigi11の発表である、「Ruby に型があると便利か」(スライド)(動画) を元に、るびま用に書き起こしたものです。
TypeStructという gem の紹介と解説記事になります。
TypeStruct は Ruby 組み込みの Struct のように class を作る class です
TypeStruct を一言で言うと「C 言語や golang の struct を Ruby で再現したもの」と言えます。実際に、TypeStruct は golang と crystal-lang に影響を受けています。
TypeStructはgem化しているのでrubygemsからインストールできます。
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::MultiTypeError と TypeError が出てきました。
TypeError は Ruby の組み込み class ですが、名前が Type* なのであえて使っています。
MultiTypeError は何でしょうか。 これは、もし複数の型チェックエラーがあった場合、全ての情報を出したいという要求から生まれました。
MultiTypeError により、複数の型エラーがあった場合、全てのエラーを列挙してくれるので、一つなおしては実行してエラーを確認してまた一つなおす。といったストレスを緩和します。
TypeStruct は、ドキュメント化として読み手にもメリットがあり、デバッグのしやすさに力を入れているので書き手にもメリットがあるライブラリーなのです。
TypeStruct にはその機能をサポートするための追加 class がいくつかあり、 よく使うのがこの TypeStruct::ArrayOf と TypeStruct::Union の二つの class です。
この二つをTypeStructと組み合わせることで、より柔軟なデータ構造が表現できます。
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 は「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 です。
from_hash は TypeStructで作った型classから、Hash オブジェクトを元に TypeStruct のオブジェクトに変換します。そして、変換は定義にそって__再帰的__に行われます。
これは、Web API や設定ファイルなどの外部情報について、 TypeStruct のメリット(意味ある名前・期待した値・ドキュメント化)を享受できるようにと開発しました。
ここでは、from_hash の魅力をコードで紹介するため、とあるアプリケーション開発で TypeStruct を使う前と使った後で比較し丁寧に解説します。
例として、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 文では自動的に === メソッドが使われます。 それに「数字を期待していたが文字列だった」のようなケースは防げません。
そこでいよいよ 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
いかがでしょうか。
TypeStruct 導入によってさまざまなメリットが生まれました。
ここでは TypeStruct が有効になる利用シーンを 3 つ上げ、具体的な導入方法を合わせて紹介します。
from_hash の説明と被ってしまうので要点だけ。
複数人開発の場合は特に、「どんな key があってどんな値がありえるのか、この key は nil になる可能性はあるのか」と言った情報が共有されているべきです。
そこで、TypeStruct で型情報を書いておけば正確なドキュメントにもなります。
しかしながら、既存のコードベースに TypeStruct を導入する場合、いちいち型を書くのが面倒になるでしょう。 この場合は自動で型コードを生成してくれるものがあると便利だろうと、TypeStruct 型定義 generator を書いてみました。
以下のように使います。
json の部分を yaml に変えることで yaml 形式にも対応できます。
実際のレスポンスをドキュメントや curl の結果などから generator に渡してやれば、 自動的に TypeStruct の型コードを生成してくれます。 これをコピー&ペーストするなりして使うことで型を書く手間をある程度減らせるでしょう。
JSON を受け取って処理するクライアント実装を書く場合では、 Hash の問題は存在するものの、TypeStruct を有効に使える機会は少ないでしょう。
理由は大抵の Web API クライアント実装を Ruby で行いたい場合は、特定のパブリックなサービスに対して行う場合が多いからです。
という場合がほとんどなので、TypeStruct のドキュメントとしてのメリットが効果を持ちにくいのです。
しかしながら、それでも便利に使っていただける可能性を考慮し、 例としてドキュメントサービスで有名な esa.io の API ドキュメントを元に TypeStruct の型コードを書いてみました。
https://github.com/ksss/type_struct-esa
このように Web API のレスポンスの TypeStruct コードを書いておけば、 レスポンスを使って整形して分解して……、などのコードが書きやすくなるでしょう。2
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 を使うことで作りやすくなります。
今回はTypeStruct gemについて紹介しました。
「使ってるよ」とか「こうなっているともっと便利なのに」とか「ここがイケてない」など、フィードバックをいただけると大変嬉しいです。
TypeStructに限らず、他言語のパラダイムを覗いてみるといつものRubyプログラムが少し違って見えて楽しいですね。
TypeStruct は Struct をベースに拡張した class です。 筆者は Ruby の Struct が好きなのですが、Ruby の Hash が便利すぎるために一度も使用したことがない方も多いのではないでしょうか。 そこで、どんな場合に Struct が便利でどんな場合に Hash が便利なのか、どうやって使い分ければよいのか考えてみました。
「TypeStruct を使うほどではないけど、Struct は便利かもなあ」と思っていただければ幸いです。
筆者の考えは「どんな key があるか固定なら Struct、不定なら Hash」です。
Struct のメリットは
Hash のメリットは
だと考えています。
多分に主観が含まれてはいますが、これらの以下のメリットから導き出されます。
(デバッグが必要になりそうなほど複雑 && 寿命の長いオブジェクト && 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
プログラムを書くのが楽しすぎるプログラマ。RubyKaja 2014(from asakusa.rb)。㈱spicelife エンジニア。命より大事なものは家族。OSS開発を仕事にするのが夢。