petitviolet_blog

@petitviolet blog

リッチなデータ構造を扱うためのGemを作ってみた

以前、StructをRubyで実装するというのをやった。

じゃあ次は、ということでEnumとAlgebraic Data Type(ADT)に相当するようなものが欲しいな〜と思ってGemとして作ってみた。

EnumRuby言語標準としては用意されていないが、RailsにはActiveRecord::Enumというものがあり、カラムをenum的に管理しつつ便利メソッドを山程生やしてくれるものがあるが、そういうのじゃなくてデータ構造としてenum欲しいんだよねという気持ち。 そうなるといわゆるADTみたいなやつ、Scalaでいうところのsealed trait + case class(object)のあれがわりと好きなので欲しくなる。

といっても静的にパターンマッチの網羅性チェックとか出来ているわけでもないしどれくらい嬉しいかは不明。 EnumEnumとして、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ですよという表明になり、constdataメソッドが生える。 constは属性を持たない単一オブジェクトで、dataは1つ以上のフィールドを持つデータ型。 Scalaでいうところのobjectcase 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::StructEnumADT両方で使いまわしているので新規でなにかテクニックを用いたかというとそうでもない。

まず、EnumやADTの各クラスにおいて独自のメソッドを生やしたいというのは容易に想像がつくのでblockを渡してメソッド定義できるようにした。 そのためにもまたblockが与えられていたらRstructが生成したClassオブジェクトに対してclass_evalするということをしている。 共通インタフェースをinterface(&block)で定義できるようにしてあるやつも結局class_evalしているだけではある。

またenumdataメソッド経由で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がインタフェースになりがちだけど少しでもインタフェースに対して制約をつけるためにはこういうのがあると便利かなとは改めて思った。