Ruby"ならでは"の型強制を探検する(翻訳)
Rubyは極めて柔軟性が高いので、実行時にあたかも型強制を行っているかのように振る舞うことが可能です。これは、データの「ガード」をシステマチックに形式化するには十分です。
ただしお断りしておきますが、実を言うとこの方法はあまり私の好みではありません。その理由は、コンストラクタやイミュータブルオブジェクトの良い書き方を学べば、実行時に型強制するのと同じ結果を得られるからです。私は、データフローを最初から最後まで制御することに専念すべきであり、あらゆる一般的なユースケースを保護するようなデータ型を宣言すべきではないと信じています。
しかしながら、既存の多くのコードベースでは、実行時レベルの型でメンテナンス性を高める方が正しい場合もありそうなので、私が独自に考案したアプローチを試してみることにしました。
その前に、実行時にチェックする型を宣言できるライブラリが既にいくつか存在していることに触れておきたいと思います。これらのライブラリは、独自の型を構築するために独自のクラスやメソッドを多数提供しています。こういう方法は認知に負荷をかけるので、私は反対です。単なるブーリアン式を記述するためだけに多くのボキャブラリーを学ぶぐらいなら、最初からブーリアン式を書かせてくれればよさそうなものです。
私の実験における前提でもあるのですが、独自の型ライブラリの使い方を考案するよりも、シンプルにRubyで値をチェックする方が楽だと思います。
少し前に私が書いたportrayalというささやかなライブラリは、シンプルなStruct風オブジェクトビルダーです。
portrayalではキーワードを宣言可能で、これは単なるattr_accessor
とデフォルトのinitialize
にいくつかの便利機能を加えたものです。このportrayalをベースに、Portrayal::Guards
という概念実証用の拡張機能を書きました。本記事では、この拡張機能のしくみを解説します。
🔗 ブーリアン式を強制する
たとえば、以下のclass Person
があり、そこにage
とfavorite_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_beer
がnil
でない場合は、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つの点にご注目ください。
- このガードは、
initialize
(.new
)とライターメソッドを両方とも保護している。 - 特殊メソッド
update
を使うと、複数の値を一度に変更できる。ガードはクロスチェックを行うので、値を1度に1つずつ更新できない場合の問題を解決できる。 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
のようなものを扱うときに具合がよさそうです(あらゆる型でいちいちNullableString
やNullableInt
などを作りたくありませんよね)。
つまり、たとえば以下のように書けば
def Nullable(name)
guard("#{name}はnilの可能性があります") { send(name).nil? }
name
end
Nullable Int keyword :number
のように書ける...でしょうか?
残念ながら、このように書いても動きません。
理由は、Nullable
はnil
でないすべてのもので失敗し、Int
は整数でないすべてのもので失敗するからです。ガード同士で完全に効く&&
や||
が存在しないので、Nullable
とInt
はかみ合いません(しかしこんなチェインは実際にはおそらく不要なのが救いではあります)。
このような合成を可能にする方法を数日間あれこれ考えるうちに、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
でないものが渡されれば、Int
やString
のガードが引き継ぎます。
しかし残念ながら、まだ未解決の問題が残っています。すべてのガードが混在しているため、number
でNullable
チェックした時点ですべてのガードが発動しなくなり、text
のString
ガードを書いても実行されなくなってしまいます。これはガードをクラスに追加したのに、ガード同士がグループ化されていないことが原因です。
この問題を解決するために、ガードのグループ化機能を追加しました。グループ化では基本的に何もしないのでご心配なく。
🔗 ガードをグループ化する
先ほどのエラーハッシュの中にあった: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
もちろん普通のガードを宣言しても構いません。どの書き方を選ぼうと自由です。
ここで忘れて欲しくない点は、私たちがここまでに学んだメソッドはguard
とpass!
というたった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にする前に、皆さんがこれについてどう思うかが気になっています。ぜひ皆さんのご感想をお寄せください。この方法はクレイジーでしょうか?それとも、そこまでクレイジーでもないでしょうか?
概要
原著者の許諾を得て翻訳・公開いたします。