Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby"ならでは"の型強制を探検する(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Ruby"ならでは"の型強制を探検する(翻訳)

Rubyは極めて柔軟性が高いので、実行時にあたかも型強制を行っているかのように振る舞うことが可能です。これは、データの「ガード」をシステマチックに形式化するには十分です。

ただしお断りしておきますが、実を言うとこの方法はあまり私の好みではありません。その理由は、コンストラクタやイミュータブルオブジェクトの良い書き方を学べば、実行時に型強制するのと同じ結果を得られるからです。私は、データフローを最初から最後まで制御することに専念すべきであり、あらゆる一般的なユースケースを保護するようなデータ型を宣言すべきではないと信じています。

しかしながら、既存の多くのコードベースでは、実行時レベルの型でメンテナンス性を高める方が正しい場合もありそうなので、私が独自に考案したアプローチを試してみることにしました。

その前に、実行時にチェックする型を宣言できるライブラリが既にいくつか存在していることに触れておきたいと思います。これらのライブラリは、独自の型を構築するために独自のクラスやメソッドを多数提供しています。こういう方法は認知に負荷をかけるので、私は反対です。単なるブーリアン式を記述するためだけに多くのボキャブラリーを学ぶぐらいなら、最初からブーリアン式を書かせてくれればよさそうなものです。

私の実験における前提でもあるのですが、独自の型ライブラリの使い方を考案するよりも、シンプルにRubyで値をチェックする方が楽だと思います。

少し前に私が書いたportrayalというささやかなライブラリは、シンプルなStruct風オブジェクトビルダーです。

maxim/portrayal - GitHub

portrayalではキーワードを宣言可能で、これは単なるattr_accessorとデフォルトのinitializeにいくつかの便利機能を加えたものです。このportrayalをベースに、Portrayal::Guardsという概念実証用の拡張機能を書きました。本記事では、この拡張機能のしくみを解説します。

🔗 ブーリアン式を強制する

たとえば、以下のclass Person があり、そこにagefavorite_beerがあるとします。

class Person
  extend Portrayal

  keyword :age
  keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=
end

注: 通常のセッターメソッドはprotectedですが、ここではガードのしくみを示すためにpublicにしてあります。

データ型の要件は以下のようなものとします。

  • age: 0~130の整数値でなければならない
  • favorite_beer: nilまたは任意の文字列でなければならない
  • favorite_beernilでない場合は、ageが21以上でなければならない

以下は、Portrayal::Guardsで上の条件を強制するシンプルな方法のひとつです。

class Person
  extend Portrayal

  keyword :age
  keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=

  guard('人間の年齢で、かつビールは21歳以上でなければなりません') {
    age.is_a?(Integer) && (0..130).cover?(age) &&
      (favorite_beer.nil? || (favorite_beer.is_a?(String) && age >= 21))
  }
end

このガードは、クラス本体のどこで宣言しても構いません。このガードにはブーリアン式が1つあります。ガードは、truthyな値を返す場合はパスし、falseまたはnilを返す場合は失敗します。引数に渡している文字列は、失敗時のエラーメッセージとして使われます。実はガードが1個あれば、問題はすべて解決されるのです。

このガードがオブジェクトをどんなふうに保護するかを見てみましょう。

# 無効な年齢でPersonを初期化した場合
> Person.new(age: 200)
ArgumentError: 人間の年齢で、かつビールは21歳以上でなければなりません

# Personが有効な場合
> person = Person.new(age: 5)
=> #<Person @age=5, @favorite_beer=nil>

# 未成年(21歳未満)がセッターメソッドでビールを注文した場合
> person.favorite_beer = 'corona'
ArgumentError: 人間の年齢で、かつビールは21歳以上でなければなりません

# `update`メソッドで一度に複数を変更する
# 値が無効な場合
> person.update(age: 200, favorite_beer: 9)
=> {:base=>["人間の年齢で、かつビールは21歳以上でなければなりません"]}

# `update`の値が有効な場合
> person.update(age: 30, favorite_beer: 'corona')
=> nil

> person
=> #<Person @age=30, @favorite_beer="corona">

ここで以下の3つの点にご注目ください。

  1. このガードは、initialize.new)とライターメソッドを両方とも保護している。
  2. 特殊メソッドupdateを使うと、複数の値を一度に変更できる。ガードはクロスチェックを行うので、値を1度に1つずつ更新できない場合の問題を解決できる。
  3. updateで発生したエラーは:baseキーの下に保存されている(詳しくは後述するので、頭の隅にだけ置いてください)。

これは何も難しくありません。単なるブーリアン式なので、属性を完全に保護できました。しかし、このエラーメッセージは、どこが間違っていたかをピンポイントで教えてくれない点が少々不便です。大丈夫、以下のように3つのguardに分けて書き直せばよいのです。

guard('人間の年齢で、かつ整数でなければなりません') {
  age.is_a?(Integer) && (0..130).cover?(age)
}

guard('favorite_beerは文字列またはnilでなければなりません') {
  favorite_beer.nil? || favorite_beer.is_a?(String)
}

guard('favorite_beerを呼べるのは21歳以上です') {
  favorite_beer.nil? || age >= 21
}

だいぶよくなりましたね。同じコードを再実行してみましょう。

> Person.new(age: 200)
ArgumentError: 人間の年齢で、かつ整数でなければなりません

> person = Person.new(age: 5)
=> #<Person @age=5, @favorite_beer=nil>

> person.favorite_beer = 'corona'
ArgumentError: favorite_beerを使えるのは21歳以上です

> person.update(age: 200, favorite_beer: 9)
=> {:base=>["人間の年齢で、かつ整数でなければなりません", "favorite_beerは文字列またはnilでなければなりません"]}

> person.update(age: 30, favorite_beer: 'corona')
=> nil

> person
=> #<Person @age=30, @favorite_beer="corona">

エラーメッセージが具体的になってきて嬉しいですね。

要約すると、guardと普通のRubyコードを使えば何でもやれるのです。

🔗 これは再利用できるの?

はい、再利用もデフォルトで使えます。以下のようなモジュールを書けばよいのです。

module ReusableTypes
  def int(name)
    guard("#{name}は整数でなければなりません") { send(name).is_a?(Integer) }
  end

  def age(name)
    int(name)
    guard("#{name}は0〜130でなければなりません") { (0..130).cover?(send(name)) }
  end

  def nullable_string(name)
    guard("#{name}はnilか文字列でなければなりません") {
      value = send(name)
      value.nil? || value.is_a?(String)
    }
  end
end
class Person
  extend Portrayal
  extend ReusableTypes

  keyword :age
  keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=

  # ガードを呼び出す
  age :age
  nullable_string :favorite_beer

  guard('favorite_beerを呼べるのは21歳以上です') {
    favorite_beer.nil? || age >= 21
  }
end

ここでは、モジュール内にガードを置いてそれらを呼び出しています。特別なことは何もしていないのに、再利用可能な型が突然手に入ったのです。

Rubyでは一般に、宣言されているものの名前を返すのが伝統となっています。portrayalのkeywordもこの伝統を踏まえて、キーワード名を返すようにしています。必要なら、keywordの直前に型メソッドを置いても同じように動いてくれます。

class Person
  extend Portrayal
  extend ReusableTypes

  # keywordと同じ行でガードをインライン呼び出している
  age keyword :age
  nullable_string keyword :favorite_beer, default: nil
  public :age=, :favorite_beer=

  guard('favorite_beerを呼べるのは21歳以上です') {
    favorite_beer.nil? || age >= 21
  }
end

上のスタイルが好みに合わなければ、他の方法でも書けます。
たとえば、モジュール内のメソッドからnameを返すようにして、その中にキーワード名をラップしてもよいのです。ついでにメソッド名の単語冒頭も大文字にしておきましょう。

module ReusableTypes
  def Int(name)
    guard("#{name}は整数でなければなりません") { send(name).is_a?(Integer) }
    name
  end

  def Age(name)
    Int(name)
    guard("#{name}は0〜130でなければなりません") { (0..130).cover?(send(name)) }
    name
  end

  def NullableString(name)
    guard("#{name}はnilか文字列でなければなりません") {
      value = send(name)
      value.nil? || value.is_a?(String)
    }
    name
  end
end

これで以下のように書けます。

class Person
  extend Portrayal
  extend ReusableTypes

  keyword Age(:age)
  keyword NullableString(:favorite_beer), default: nil
  public :age=, :favorite_beer=

  guard('favorite_beerを呼べるのは21歳以上です') {
    favorite_beer.nil? || age >= 21
  }
end

先ほど、メソッドはクラス本体のどこで宣言しても構わないと書きましたが、これは本当で、実際にこのコードは引き続き有効です。このガードの活用法は他にもいろいろ考えられるはずです。ここで紹介したのは私が考えたごく一部に過ぎません。

これらを見ていると、どんなに複雑な型でも簡単に実装できそうだと思えてくるでしょう。Portrayal::Guardsは、あなたの代わりにイニシャライザとライターメソッドをガードしてくれるのです。

🔗 ガードは合成できるの?

はい、ただガードの合成(composition)1を使いやすくするには何らかの機能を追加する必要がありそうです。いくつかのアイデアを試してみた結果、概念実証として以下の追加機能を盛り込むことにしました。

🔗 ガードのチェイン

ガードを合成する方法のひとつとして、上で行ったように、再利用可能なメソッドに渡されたnameを返すようにする方法が考えられます。どの宣言も、それが受け取ったnameを返すようになっていれば、以下のようにガードをチェインできるようになります。

# 型メソッド
def Odd(name)
  guard("#{name}は奇数でなければなりません") { value = send(name); value.respond_to?(:odd?) && value.odd? }
  name
end

def Int(name)
  guard("#{name}は整数でなければなりません") { send(name).is_a?(Integer) }
  name
end

# チェインの例
Odd Int keyword :odd_number

これは、特にNullableのようなものを扱うときに具合がよさそうです(あらゆる型でいちいちNullableStringNullableIntなどを作りたくありませんよね)。

つまり、たとえば以下のように書けば

def Nullable(name)
  guard("#{name}はnilの可能性があります") { send(name).nil? }
  name
end

Nullable Int keyword :numberのように書ける...でしょうか?

残念ながら、このように書いても動きません。
理由は、Nullablenilでないすべてのもので失敗し、Intは整数でないすべてのもので失敗するからです。ガード同士で完全に効く&&||が存在しないので、NullableIntはかみ合いません(しかしこんなチェインは実際にはおそらく不要なのが救いではあります)。

このような合成を可能にする方法を数日間あれこれ考えるうちに、pass!ガードを使うシンプルな解決方法を思いつきました。

🔗 特殊な pass!ガード

pass!は、通常のguardと同様にいくつでも置けます(しかし2つ以上必要になることはまずないでしょう)。そしてpass!は、常に最初に実行されます。

pass!がtruthyなものを返す場合は、そのオブジェクトは有効なので、それ以上ガードは呼び出されなくなります。

この新機能を使えば、Nullableを以下のように書けます。

def Nullable(name)
  pass!("#{name}はnilの可能性があります) { send(name).nil? }
  name
end

これで以下のような合成が動くようになります。

Nullable Int keyword :number, default: nil
Nullable String keyword :text, default: nil

バンザイ!

pass!は常に最初に実行されるので、順序に影響されません。pass!nilが渡されれば、他のガードは発動しません。pass!nilでないものが渡されれば、IntStringのガードが引き継ぎます。

しかし残念ながら、まだ未解決の問題が残っています。すべてのガードが混在しているため、numberNullableチェックした時点ですべてのガードが発動しなくなり、textStringガードを書いても実行されなくなってしまいます。これはガードをクラスに追加したのに、ガード同士がグループ化されていないことが原因です。

この問題を解決するために、ガードのグループ化機能を追加しました。グループ化では基本的に何もしないのでご心配なく。

🔗 ガードをグループ化する

先ほどのエラーハッシュの中にあった:baseキーを覚えていますか?以下に再掲します。

{:base=>["人間の年齢で、かつビールは21歳以上でなければなりません"]}

実は、この:baseはガードのデフォルトのトピックになっています。そしてガードを別のトピックにグループ化するのは超簡単です。以下のようにガードに引数を1つ追加するだけでできます。

guard(:topic_name, 'エラーメッセージ') { <ブーリアン式> }

新しい第1引数:topic_name(実際は何でも構いません)がトピックです。つまり、実際のガードは、すべてトピックごとに発動します。あるトピックが失敗してもpass!しても、別のトピックのガードは止まりません。

これは、「属性ごとに」ガードするための、さらに一般的な方法です。これはもちろん、ReusableTypesモジュールで使うために作られたものです。これで、以下のように書けるようになります。

module ReusableTypes
  def int(name)
    guard(name, "#{name}は整数でなければなりません") { send(name).is_a?(Integer) }
  end

  def age(name)
    int(name)
    guard(name, "#{name}は0〜130でなければなりません") { (0..130).cover?(send(name)) }
  end

  def string(name)
    guard(name, "#{name}は文字列でなければなりません") { send(name).is_a?(String) }
  end

  def nullable(name)
    pass!(name, "#{name}はnilの可能性があります") { send(name).nil? }
  end
end

ところで、いつの間にかどのメソッドもnameを返さなくなっていることにお気づきでしょうか。その理由は、既に各ガードがトピックを返すようになったことで、nameを返す必要がなくなったからです。またひとつ小さな勝利を得ました。

以上を合わせれば、Personをたとえば以下のように宣言できます。

class Person
  extend Portrayal
  extend ReusableTypes

  age keyword :age
  nullable string keyword :favorite_beer, default: nil

  guard('favorite_beerを呼べるのは21歳以上です') {
    favorite_beer.nil? || age >= 21
  }
end

または、以下のようにメソッド名の冒頭単語を大文字にしても構いません。

Age keyword :age
Nullable String keyword :favorite_beer, default: nil

または、以下のようにkeywordを一番左に置くことも可能です。

keyword Age(:age)
keyword Nullable(String :favorite_beer), default: nil

keywordが目ざわりだと思うなら、以下のようにも書けます。

Age :age
Nullable String :favorite_beer

keyword :age
keyword :favorite_beer, default: nil

もちろん普通のガードを宣言しても構いません。どの書き方を選ぼうと自由です。

ここで忘れて欲しくない点は、私たちがここまでに学んだメソッドはguardpass!というたった2つだということです(「updateもあっただろ」というツッコミもあるかと思いますが)。それ以外はまったく素のRubyです。

🔗 ガードをリスト表示する

最後のお楽しみとして、クラスで宣言されたガードをリスト表示できるようにしたいと思います。Person.portrayal.list_guardsのように呼び出せば、以下の結果が返されます。

> Person.portrayal.list_guards
=> {:age=>["ageは整数でなければなりません", "ageは0〜130でなければなりません"],
 :favorite_beer=>["favorite_beerはnilの可能性があります", "favorite_beerは文字列でなければなりません"],
 :base=>["favorite_beerを呼べるのは21歳以上です"]}

🔗 このライブラリはどこにあるの?

この実装を書いている時点では、gist形式しかありません。これを適切なgemにする前に、皆さんがこれについてどう思うかが気になっています。ぜひ皆さんのご感想をお寄せください。この方法はクレイジーでしょうか?それとも、そこまでクレイジーでもないでしょうか?

関連記事

Rubyの型アノテーションの現状についていくつか思うこと(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。