読者です 読者をやめる 読者になる 読者になる

RubyでJSONをclassにmappingするやつと、2つの記法

僕はHashが嫌いだ。

できるだけHashは使いたくない。

h[:foo]

とか

h.fetch(:foo)

とか本気で使っているのかね君たちは(今日は何となくこんな感じですすみませんすみません)。

Hash

Hashはとにかくデバッグが面倒だ。

設定やら多値を表現するためか生存が長くなりがちなくせに、 どこで生成されたHashなのか、どこで追加されたkeyなのかよくわからなくなる。

class名がないから何を表したいオブジェクトなのか、デバッグした時にわからない。class名でgrepもできない。

JSONを扱うプログラムではついついHash#[]Hash#fetchばかりのコードになりがちで、 エラーが起こったらHashのkeyをtypoしたんだか値がnilなんだかよくわからなくなる。

Hash#fetchでkeyを間違えるとKeyErrorが発生することで検知できるけど、「どんなkeyがあったかな?」と思っても正しいkeyは自分でコードを読むかp h.keysするなりして探さないといけない。

いまのところdid_you_mean gemもKeyErrorに対応していないので、typoにおびえてコードを書くか、ひたすらデバッグするかだ。 *1

なにより

h[:foo]
h.fetch(:foo)

は長い。

Hashはどんなkeyが来るかわからない時だけ使うべきだ。 あと、「記述が少ない」「名前を覚えなくていい」という利点から、keyword argumentsのような引き数にも便利。 gemの外部APIとしては十分選択肢だと思う。

Struct

Structは好きだ。

どこで生成されたオブジェクトなのかclass名でgrepすればだいたいわかるし、key名を間違えてもNoMethodErrorで教えてくれる。 なんならdid_you_mean gemが正しそうな名前を教えてくれる。

「どんなkeyがあったかな?」と思ったらclassの定義を読めばいい。

s.foo

の方がシンプルだ。

だがしかし、JSONをスッとマッピングできるわけではない。

golangやcrystalのようにJSONを特定のclassに属するオブジェクトに変換する方法があれば、Hash的な使い方でなく、Struct的な使い方ができる。

TypeStruct

ようやく本題。

以前、keyword argumentsが使えてより厳しいStructということでTypeStruct gemを作った

これをさらに推し進めて、hashからマッピングできるように実装してみた。

github.com

README.mdから引用すると、

Point = TypeStruct.new(
  x: Integer,
  y: Integer,
)
Color = Struct.new(:code)
Line = TypeStruct.new(
  start: Point,
  end: Point,
  color: Color,
)

hash = JSON.parse(%({"start":{"x":3,"y":10},"end":{"x":5,"y":9},"color":{"code":"#CAFE00"}}))
line = Line.from_hash(hash)

p line
#=> #<Line start=#<Point x=3, y=10>, end=#<Point x=5, y=9>, color=#<struct Color code="#CAFE00">>
p line.start.y
#=> 10
line.stort
#=> NoMethodError

このように、key名とclassを設定すれば、HashオブジェクトをStruct(っぽい)オブジェクトに変換してくれる。

設定に違反するデータならエラーを出してその場で教えてくれるし、参照先のclassがTypeStructから作ったclassなら再帰的にオブジェクト化してくれる。

Structオブジェクトにも対応しているけど、Structの場合は値のclassを設定できないのでなんでも入れられて再帰的にオブジェクト化できなくなるが、既存のcodeで使いやすいようにつくってみた。

2つの記法

「値のclassを設定できる」を実現するために2つの記法を用意した。

Union

たとえば「truefalseになるメンバをつくりたい」と思うと、Rubyの場合困ったことになる。

Foo = TypeStruct.new(
  bar: TrueClass, # or FalseClass???
)
Foo.new(bar: false) #=> TypeError

TrueClassFalseClassに判別用のmoduleをincludeすればチェックできるけど、標準classを書き換えたくない人も多い。

そのため、独自のclassを用意してみた。

Foo = TypeStruct.new(
  bar: TypeStruct::Union.new(TrueClass, FalseClass)
)
Foo.new(bar: false)

標準classを少しなら拡張してもよいという方のために、こんな書き方もできるようにした。

require 'type_struct/ext'
using UnionExt

Foo = TypeStruct.new(
  bar: TrueClass | FalseClass
)
Foo.new(bar: false)
Foo.new(bar: true)

Class#|メソッドを用意して、classをつなげて複数のclassを表現できるようにした。refinementsを使っているので、Class#|が有効になる範囲も限定できる。*2

ArrayOf

メンバを配列にしたい場合もあるだろう。

Cだと

struct foo {
  int ary[];
};

golangだと

type Foo struct {    
        ary []int
}

みたいなやつだ。

その場合の表現方法も考えて、専用classを用意した。

Foo = TypeStruct.new(
  ary: TypeStruct::ArrayOf.new(Integer)
)
Foo.new(ary: [1,2,3])

名前が長いのが玉にキズなので、これも短縮できるようにしている。

require 'type_struct/ext'
Foo = TypeStruct.new(
  ary: ArrayOf.new(Integer)
)
Foo.new(ary: [1,2,3])

組み合わせ

もちろんUnionとArrayOfは組み合わすことができて、「配列の時もあるけどnilのときもあるメンバ」を表現できる

require 'type_struct/ext'
Foo = TypeStruct.new(
  ary: ArrayOf.new(Integer) | NilClass
)
Foo.new(ary: [1,2,3])
Foo.new(ary: nil)

これらの記法を組み合わせれば、かなり複雑な構造を持ったJSONでも、特定の名前のついたclassのインスタンスオブジェクトを作ることができるようになるので、お試しください。

もっとRubyならではの便利さもあるとうれしいのでアイデアも募集中です。*3

*1:とここまで書いたら「実装すればいいじゃん」と思い立ったのでやってみた

*2:この記法はcrystalを参考にしました。

*3:HashOfを用意していないのはHashが嫌いだからです。