リッチなデータ構造を扱うためのGemを作ってみた
以前、StructをRubyで実装するというのをやった。
じゃあ次は、ということでEnumとAlgebraic Data Type(ADT)に相当するようなものが欲しいな〜と思ってGemとして作ってみた。
EnumはRuby言語標準としては用意されていないが、RailsにはActiveRecord::Enumというものがあり、カラムをenum的に管理しつつ便利メソッドを山程生やしてくれるものがあるが、そういうのじゃなくてデータ構造としてenum欲しいんだよねという気持ち。 そうなるといわゆるADTみたいなやつ、Scalaでいうところのsealed trait + case class(object)のあれがわりと好きなので欲しくなる。
といっても静的にパターンマッチの網羅性チェックとか出来ているわけでもないしどれくらい嬉しいかは不明。 EnumをEnumとして、ADTをADTとして明示できるようにしたかっただけかも知れない。
使い方
READMEに書いてあるがここにも書く。
まずrequireしておく。
require 'rstructural'
Struct
まずStruct。名前をずらすためにRstruct
としてある。
これは前回の記事のやつ。
module StructSample Value = Rstruct.new(:value) puts Value.name #=> 'StructSample::Value' puts value = Value.new(100) #=> 'StructSample::Value(value: 100) puts value == Value.new(100) #=> true puts value.value == 100 #=> true Point = Rstruct.new(:x, :y) do def message "Point: [#{x}, #{y}]" end end p = Point.new(1, 2) puts p #=> StructSample::Point(x: 1, y: 2) puts p.message #=> Point: [1, 2] case Point.new(1, 2) in Point[x, 2]# pattern match (Ruby 2.7~) puts "here! x: #{x}" #=> here! x: 1 else raise end end
0個以上の属性を持つデータ構造を定義できる。 0個の属性を許す、という点でRuby標準のStructとは異なる、はず。
EOF = Rstruct.new puts EOF #=> EOF puts EOF.new #=> EOF puts EOF.new == EOF.new #=> true
to_s(inspect)
した結果が同じなので分かりづらいがnewするとインスタンスにはなっている。
Enum
例としてHTTPステータスコードをenumとして定義してみる。
extend Enum
でこれはenumですよという表明になり、enum
メソッドが生える。
module EnumSample module Status extend Enum OK = enum 200 NotFound = enum 404 InternalServerError = enum 500 do def message "Something wrong" end end end puts Status::OK #=> EnumSample::Status::OK(value: 200) puts Status::OK.value #=> 200 puts Status::InternalServerError.message #=> Something wrong case Status.of(404) in Status::NotFound puts "NotFound!!!" #=> NotFound!!! else raise end end
enum <Value>
という雰囲気で列挙するとクラス変数としてenumを保持しているのでStatus.of(value)
で取れる、というところが少し便利。
見つからなかったらnil。
一応、値の重複排除をやっていて
module Status extend Enum OK = enum 1 NG = enum 2 KO = enum 1 end
みたいに同じ値でenumを定義しようとすると、
Traceback (most recent call last): 3: from sample.rb:36:in `<main>' 2: from sample.rb:59:in `<module:EnumSample>' 1: from sample.rb:63:in `<module:Status>' /path/to/enum.rb:12:in `enum': Enum '1' already defined in EnumSample::Status::OK (ArgumentError)
という感じのエラーがraiseされて安心!
ADT
続いては代数的データ構造。
extend ADT
でこれはADTですよという表明になり、const
とdata
メソッドが生える。
const
は属性を持たない単一オブジェクトで、data
は1つ以上のフィールドを持つデータ型。
Scalaでいうところのobject
とcase class
の使い分け。
いい例が思えないが点と円と長方形を表現してみる。
module AdtSample module Shape extend ADT Point = const Circle = data :radius do def scale(i) Circle.new(radius * i) end def area 3.14 * radius * radius end end Rectangle = data :width, :height do |mod| def area width * height end end end puts Shape::Point #=> AdtSample::Shape::Point puts Shape::Rectangle.new(3, 4) #=> AdtSample::Shape::Rectangle(width: 3, height: 4) puts Shape::Rectangle.new(3, 4).area #=> 12 puts Shape::Circle.new(5).scale(2).area #=> 314.0 case Shape::Rectangle.new(1, 2) in Shape::Rectangle[1, Integer => j] if j % 2 == 0 puts "here! rectangle 1, #{j}" #=> here! rectangle 1, 2 else raise end end
なんかそれっぽい感じで定義出来ていそう。 ただ、ADTはADTとして共通のインタフェース(Enum#ofのような)ものが特に無いためStructでいいじゃん、というのはその通り。
じゃあ共通のインタフェースを持てるようにしよう、ということでやってみた。
module Option extend ADT None = const Some = data :value interface do def map(&f) case self in None None in Some[value] Some.new(f.call(value)) end end end end puts Option::None.map { |i| i * 2 } #=> None puts Option::Some.new(10).map { |i| i * 2 } #=> Option::Some(value: 20)
interface(&block)
で共通のメソッドを定義出来るようにした。
とはいえ実装ではパターンマッチするなりが必要になるので別々に実装するでも十分な気もするが、定義が分散しないというメリットもあるので使えなくはなさそう。
ここまで来るとようやく単なるStruct
を使うだけではないメリットが...あるかもしれない。
実装について
パターンマッチを使いたかったのでRuby2.7.0で実装している。 Gemのrequirementもそう。
前の記事で実装したRstructural::Struct
をEnum
、ADT
両方で使いまわしているので新規でなにかテクニックを用いたかというとそうでもない。
まず、EnumやADTの各クラスにおいて独自のメソッドを生やしたいというのは容易に想像がつくのでblockを渡してメソッド定義できるようにした。
そのためにもまたblockが与えられていたらRstruct
が生成したClass
オブジェクトに対してclass_eval
するということをしている。
共通インタフェースをinterface(&block)
で定義できるようにしてあるやつも結局class_eval
しているだけではある。
またenum
やdata
メソッド経由でRstruct.new
を実行しているためcaller
でクラス名を決定するところが壊れるので__caller
キーワードで雑にバイパス出来るようにしている。
ADT
について実装をはりつけてみるとこんな感じ。
module Rstructural::ADT def self.extended(klass) klass.class_variable_set(:@@adt_types, []) end def const(value = nil, &block) if value Rstructural::Struct.new(:value, __caller: caller, &block).new(value) else Rstructural::Struct.new(__caller: caller, &block).new end.tap do |k| self.class_variable_get(:@@adt_types) << k def k.name self.class.name end end end def data(*fields, &block) Rstructural::Struct.new(*fields, __caller: caller, &block).tap do |k| self.class_variable_get(:@@adt_types) << k end end def interface(&block) self.class_variable_get(:@@adt_types).each do |t| case t in Class t.class_eval(&block) else t.class.class_eval(&block) end end end end
const
は値を持たせることも出来るし、必須ではない。内部ではそれぞれごとにClass
オブジェクトを生成しているので==
で別のconst
同士を比較するとちゃんとfalse
になるので実質singleton object的に扱える。
data
は任意の個数のフィールドを持つタプルを生成する。こちらは同じ型でかつ全フィールドが==
でtrue
なら、==
がtrue
になるという実装をRstructural::Struct
でしてある。
感想
Rubyには静的型チェックがなくデータコンテナとしてのClassを定義するのも面倒だしHashがインタフェースになりがちだけど少しでもインタフェースに対して制約をつけるためにはこういうのがあると便利かなとは改めて思った。