ブロク記事からの転載です。
Ruby Advent Calendar 2017 1日目の記事になります。
Ruby 3 では静的型づけが入るとか言われていますが、今回は Ruby で動的に『型チェック』を行うコードを実装してみようと思います。
前書きという名の注意点
- 型チェックといいながら一般的な意味での型の話はしない
- あくまでも Ruby で『型チェックのようなもの』を実装するという話
- 型過激派の人は生暖かい目で見てください
- 今回は動的に型チェックを行うのでパフォーマンスに関しては考慮しない
今回実装する機能
- メソッドの引数に対して型チェック(精査)を行う
- 型によってメソッドの多重定義を行う
ゴール
class Person
attr_accessor :name, :age
def initialize
end
# name は String
# age は Integer
# で受け取る initialize メソッドを定義
define_typed_method(:initialize, name: String, age: Integer){
# Hash のキーで変数を参照する
self.name = name
self.age = age
}
define_typed_method(:set, name: String, age: Integer){
self.name = name
self.age = age
}
def print
puts "name:#{name} age:#{age}"
end
# フォーマットを渡して出力したり
define_typed_method(:print, fmt: String){
printf(fmt, name, age)
}
end
homu = Person.new("homu", 14)
homu.print
# => name:homu age:14
homu.print("%s : %d\n")
# => homu : 14
mami = Person.new
mami.set("mami", 15)
mami.print
# => name:mami age:15
とりあえず、最終的には上記のようなコードが動作するようにしたいと思います。
Ruby における型チェックとは
そもそも Ruby の型とは…型チェックとは…という話になるんですが、本記事では
- 型:
#===
が定義されているオブジェクト - 型チェック: 型(
#===
) を使用して引数の値を精査する事
としたいと思います。
平たくいえば『#===
を使って引数の値をチェックする』って感じですね。
なのでどちらかといえばバリデーションのような機構に近い形になると思います。
では、なぜ #===
を使用するのかというと…。
例えばクラスオブジェクトでは #===
は『引数がレシーバかそのサブクラスのインスタンスである場合』に真を返します。
String === "homu" # => true
String === 42 # => false
Integer === 42 # => true
Integer === 3.14 # => false
Numeric === 42 # => true
Numeric === 3.14 # => true
上記のコードを見るとなんとなく型チェックっぽく見えますよね?
#===
は本来 when
で呼び出される事が想定されていますが、今回は
-
#===
が真を返せば OK -
#===
が偽を返せば NG
という風にしてみたいと思います。
他にも #===
を使用して型チェックを行う利点はあるのですが、それは後で記述します。
型チェックを行うメソッドを定義する
さて、では早速コードを書いていきましょう。
まずは以下のような感じで実装してみます。
- 型を渡してメソッドを定義する
- メソッドの呼び出し時に引数に対して型チェックを行う
- 引数が定義した型に対して OK ならそのままメソッドを呼び出す
- NG なら
super()
を呼び出す
# クラスメソッドとして定義するので Module クラスを拡張
class Module
# 型チェック付きメソッドを定義する
# 第二引数には #=== で精査する値を渡す
def define_typed_method name, *sig, &defmethod
# 型リストと引数を #=== で比較する
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
define_method(name){ |*args|
# 型チェックを行い OK ならブロックを呼び出し、NG なら super() を呼ぶ
valid === args ? defmethod.call(*args) : super(*args)
}
end
end
define_method
のラッパーとして define_typed_method
というメソッドを定義します。
このメソッドに対して『精査するする型』を渡し、内部で『メソッドの呼び出し時に型チェックを行うメソッド』を定義します。
ちなみに super()
を呼び出すのは型チェックに失敗した場合にスーパークラスのメソッドにフォワードするためですね。
使い方は以下のような感じです。
class X
# func(Integer) のみ受け付けるメソッドを定義
define_typed_method(:func, Integer){ |x|
"func(Integer: #{x})"
}
# plus(Integer, String) のみ受け付けるメソッドを定義
define_typed_method(:plus, Integer, String){ |a, b|
a + b.to_i
}
end
x = X.new
p x.func 42
# => "func(Integer: 42)"
p x.plus 1, "2"
# => 3
# Error: super: no superclass method `func' for #<X:0x0000000001376eb8> (NoMethodError)
p x.func "homu"
とりあえず、まずはこれを基準として魔改良していきたいと思います。
多重定義する
次は以下のように『型によって複数のメソッドを定義する』ことを考えてみましょう。
class X
# func(Integer) のみ受け付けるメソッドを定義
define_typed_method(:func, Integer){ |x|
"func(Integer: #{x})"
}
# func(String) のみ受け付けるメソッドも定義したい
define_typed_method(:func, String){ |x|
"func(String: #{x})"
}
end
x = X.new
p x.func 42
# => "func(Integer: 42)"
p x.func "homu"
# => "func(String: "homu")"
いわゆる多重定義というやつですね。
これを実装するにあたって『Ruby で同名の複数のメソッドを保持する』必要があります。
今回は実装を簡単にしたかったので mixin を利用したいと思います。
class Module
def define_typed_method name, *sig, &defmethod
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
# 動的にメソッドを追加していく
prepend Module.new {
define_method(name){ |*args|
# super() を介すことで以前に定義されたメソッドを呼び出す
valid === args ? defmethod.call(*args) : super(*args)
}
}
end
end
class X
# func(Integer) のみ受け付けるメソッドを定義
define_typed_method(:func, Integer){ |x|
"func(Integer: #{x})"
}
# func(String) のみ受け付けるメソッドも定義したい
define_typed_method(:func, String){ |x|
"func(String: #{x})"
}
end
x = X.new
p x.func 42
# => "func(Integer: 42)"
p x.func "homu"
# => "func(String: "homu")"
上記のように prepend
を利用して継承リストに対して動的にメソッドを追加していきます。
これにより super()
を利用して『以前に定義したメソッド』を呼び出すことが出来るようになります。
また、prepend
なので『後から定義したメソッド』が優先して呼び出されます。
class X
define_typed_method(:func, String){ |x|
"func(String: #{x})"
}
# こちらのほうが優先して呼び出される
define_typed_method(:func, Object){ |x|
"func(Object: #{x})"
}
end
x = X.new
p x.func 42
# => "func(Object: 42)"
# Object === "homu" # => true
# なので Object で定義したメソッドのほうが優先して呼び出される
p x.func "homu"
# => "func(Object: "homu")"
内部で『定義したメソッドのリストを保持する』みたいな方が柔軟性は高いんですが、今回は実装をシンプルにしたかったのでこれで行きます。
定義するメソッド内の self
をインスタンスオブジェクトにする
さて、次のように define_typed_method
で定義したメソッド内で他のメソッドを呼び出すとエラーになってしまいます。
class X
def twice x
x + x
end
define_typed_method(:func, String){ |x|
# Error: undefined method `twice' for X:Class (NoMethodError)
twice x.to_i
}
end
x = X.new
p x.func "42"
これは define_typed_method
に渡したブロック内の self
が X のインスタンスではなくて『define_typed_method
を呼び出したコンテキスト(上記の場合では X
)』になってしまうからです。
class X
define_typed_method(:func, String){ |x|
self
}
end
x = X.new
p x.func "42"
# => X
これを回避するために #instance_exec
を経由してブロックの呼び出しを行います。
class Module
def define_typed_method name, *sig, &defmethod
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args|
# instance_exec 経由で呼び出すことでコンテキストをインスタンスオブジェクトにする
valid === args ? instance_exec(*args, &defmethod) : super(*args)
}
}
end
end
class X
def twice x
x + x
end
define_typed_method(:func, String){ |x|
# ここのコンテキストは X のインスタンスオブジェクトになる
twice x.to_i
}
end
x = X.new
p x.func "42"
# => 84
これにより define_typed_method
のブロック内でも他のインスタンスメソッドを呼び出すことができます。
問題点:#instance_exec
にはブロック引数を渡すことができない(未解決)
#instance_exec
で先ほどの問題は回避することができましたが、#instance_exec
を使用した場合では別の問題が発生します。
#instance_exec
では以下のようにブロック引数に対して任意の引数を渡す事ができます。
show = proc { |fmt| printf(fmt, self) }
42.instance_exec "%04d\n", &show
# => 0042
上記の場合は show
に渡す引数を #instance_exec
経由で渡しています。
では、show
に対してブロック引数を渡したい場合はどうでしょう。
# ブロックを受け取って処理を行う
show = proc { |fmt, &block| ... }
# #instance_exec に対してブロック引数も渡したいがすでに &show をブロック引数として渡してる
42.instance_exec("%04d\n", ???, &show)
#instance_exec
にはすでに &show
をブロック引数として渡しているので『show
で受け取るためのブロック引数を #instance_exec
で渡すこと』ができません。
うーん、ややこしいですね。
これを回避するには #instance_exec
に対して『複数のブロック引数を渡す必要』がありますが、残念ながら Ruby ではそれを行うことができません。
さて、define_typed_method
の話に戻ります。
define_typed_method
でも同様の問題が発生し、
class Module
def define_typed_method name, *sig, &defmethod
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args, &block|
valid === args ?
# 呼び出すメソッドに対してブロック引数も渡したいができない
# instance_exec(*args, &block, &defmethod)
instance_exec(*args, &defmethod) :
# super() は普通に渡せるが…
super(*args, &block)
}
}
end
end
class X
define_typed_method(:func, Integer){ |x, &block|
p x
# => 42
p block
# => nil
}
end
x = X.new
# ブロック引数を渡したいが…
x.func(42){}
これに関しては Ruby の標準機能だけで解決するのはちょっと難しいので、後ほど gem を使った回避方法を記述します。
型と一緒に変数名も定義したい
ここからちょっと複雑になってきます。
現状の仕様では、
-
define_typed_method
にメソッドの引数型を渡す -
define_typed_method
のブロックでメソッドの引数を受け取る
という風になっています。
しかし、以下のように『型と変数名』を一緒に定義したほうがすっきりしますよね。
class X
attr_accessor :name, :age
# こんな感じで Hash 引数を使用して変数名と型を一緒に渡したい
define_typed_method(:set, name: String, age: Integer){
# 引数を自身に代入
self.name = name
self.age = age
}
end
これを実装していきたいと思います。
とりあえず、Hash
版の実装を define_typed_method_with_hash
として定義します。
class Module
def define_typed_method name, *sig, &defmethod
# sig が Hash なら Hash 版を呼び出す
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args|
valid === args ? instance_exec(*args, &defmethod) : super(*args)
}
}
end
# とりあえず、別メソッドとして定義
def define_typed_method_with_hash name, sig, &defmethod
# この内部で実装していく
end
end
この define_typed_method_with_hash
対して実装を記述していきます。
変数をメソッドとして定義する
まずは、name
や age
のような変数名で参照する為に name
や age
をメソッドとして定義します。
class Module
def define_typed_method name, *sig, &defmethod
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args|
valid === args ? instance_exec(*args, &defmethod) : super(*args)
}
}
end
def define_typed_method_with_hash name, sig, &defmethod
# Hash の値が型なのでそれを、define_typed_method に渡して型チェックを行うメソッドを定義する
define_typed_method(name, *sig.values){ |*args|
# メソッド内部の実装
# 変数名で参照する為に Hash のキーを名前としたメソッドを定義する
sig.keys.each_with_index { |name, i|
# 引数の値を返す(特異)メソッドを定義
define_singleton_method name, &args[i].method(:itself)
}
# 引数を渡さないでメソッドの実装を呼び出す
instance_exec &defmethod
}
end
end
こんな感じで『Hash
のキーを名前として引数を返すメソッド』を定義することで name
や age
などを変数のように参照することができます。
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
p "#{name} : #{age}"
# => "homu : 14"
# name や age などがメソッドとして定義されているので擬似的に変数を参照できる
self.name = name
self.age = age
}
end
x = X.new
x.set("homu", 14)
インスタンスオブジェクトに直接メソッドを定義しない
これで変数名で参照することはできますが、インスタンスオブジェクトに直接(特異)メソッドを定義するのは大変危険です。
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
# ...
}
end
x = X.new
# これの内部で #name メソッド等が書き換えられてしまう
x.set("homu", 14)
# 内部の値を書き換えても
x.name = "mami"
# 先ほど #set で上書きされたメソッドが呼び出される
p x.name
# => "homu"
この問題を解決するために『変数を参照するメソッドを別のオブジェクトで定義』して、それをコンテキストとしてメソッドを呼び出してみたいと思います。
class Module
def define_typed_method name, *sig, &defmethod
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args|
valid === args ? instance_exec(*args, &defmethod) : super(*args)
}
}
end
def define_typed_method_with_hash name, sig, &defmethod
define_typed_method(name, *sig.values){ |*args|
# 変数を参照するメソッドを定義するオブジェクトを生成
Object.new.instance_exec {
# 生成したオブジェクトの内部で変数を参照するメソッドを定義する
sig.keys.each_with_index { |name, i|
define_singleton_method name, &args[i].method(:itself)
}
# 変数を参照するメソッドが呼び出せるように
# #instance_exec で set メソッドのコンテキストをこのオブジェクトにする
instance_exec &defmethod
}
}
end
end
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
# ...
}
end
x = X.new
# 書き換えるのは別のオブジェクトのメソッドなので
x.set("homu", 14)
# 内部の値を書き換えても
x.name = "mami"
# x のメソッドは上書きされない
p x.name
# => "mami"
このように Object.new
を利用して動的にオブジェクトを定義して、そのオブジェクトに対して変数を参照するメソッドを定義します。
X のメソッドを参照出来るようにする
『別のオブジェクトを生成すること』で副作用なく『変数を参照するメソッド』を定義する事ができました。
しかし、この『別のオブジェクトををコンテキストにする』ことによりメソッド内のコンテキストが変わってしまい、X
のメソッドにアクセスすることができなくなってしまいました。
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
# self が Object.new になる
p self
# => #<Object:0x000000000175dba8>
# 当然 X のインスタンスオブジェクトではないので X#name を呼ぼうとするとエラーになる
# self.name = name
}
end
そこで、method_missing
を利用して『参照したメソッドが存在しなければ元のコンテキストのメソッドを参照する』というような処理を追加します。
class Module
def define_typed_method name, *sig, &defmethod
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args|
valid === args ? instance_exec(*args, &defmethod) : super(*args)
}
}
end
def define_typed_method_with_hash name, sig, &defmethod
define_typed_method(name, *sig.values){ |*args|
# 変数を参照するメソッドを定義するオブジェクトを生成
receiver = self
Object.new.instance_exec {
sig.keys.each_with_index { |name, i|
define_singleton_method name, &args[i].method(:itself)
}
# method_missing を利用して参照するメソッドが存在しない場合は
define_singleton_method(:method_missing){ |name, *args, &block|
# 元々のレシーバのメソッドを参照するようにする
receiver.__send__ name, *args, &block
}
instance_exec &defmethod
}
}
end
end
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
# self は Object.new のままだが
p self
# => #<Object:0x000000000175dba8>
# name= や age= メソッドは定義されていないが
# method_missing 経由で X のメソッドが呼び出される
self.name = name
self.age = age
}
end
x = X.new
x.set("homu", 14)
p x.name
# => "homu"
p x.age
# => 14
このように method_missing
を利用することで擬似的に X のメソッドを参照する事ができます。
問題点:インスタンス変数を参照できない(未解決)
method_missing
を介すことで X のメソッドを参照する事ができました。
しかし、 method_missing
でメソッドを参照することはできても『インスタンス変数』を参照することはできません。
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
# メソッド経由でインスタンス変数を参照できても
self.name = name
# 直接インスタンス変数を参照することはできない
@age = age
}
end
x = X.new
x.set("homu", 14)
p x.name
# => "homu"
p x.age
# => nil
この問題を解決するためには『存在しない変数を参照した時』にフックできる variable_missing
のようなメソッドが必要なのですが、残念ながらこのようなメソッドは存在しません。
この問題を解決するためには別のアプローチが必要になってきますが、これも標準の機能では解決できないので後で解決方法を記述します。
まとめ
と、言うことで当初やりたかった
- メソッドの引数に対して型チェック(精査)を行う
- 型によってメソッドの多重定義を行う
という実装できたので一旦まとめます。
実装コード
class Module
def define_typed_method name, *sig, &defmethod
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args|
valid === args ? instance_exec(*args, &defmethod) : super(*args)
}
}
end
def define_typed_method_with_hash name, sig, &defmethod
define_typed_method(name, *sig.values){ |*args|
receiver = self
Object.new.instance_exec {
sig.keys.each_with_index { |name, i|
define_singleton_method name, &args[i].method(:itself)
}
define_singleton_method(:method_missing){ |name, *args, &block|
receiver.__send__ name, *args, &block
}
instance_exec &defmethod
}
}
end
end
使用コード
class Person
attr_accessor :name, :age
def initialize
end
# name は String
# age は Integer
# で受け取る initialize メソッドを定義
define_typed_method(:initialize, name: String, age: Integer){
# Hash のキーで変数を参照する
self.name = name
self.age = age
}
define_typed_method(:set, name: String, age: Integer){
self.name = name
self.age = age
}
def print
puts "name:#{name} age:#{age}"
end
# フォーマットを渡して出力したり
define_typed_method(:print, fmt: String){
printf(fmt, name, age)
}
end
homu = Person.new("homu", 14)
homu.print
# => name:homu age:14
homu.print("%s : %d\n")
# => homu : 14
mami = Person.new
mami.set("mami", 15)
mami.print
# => name:mami age:15
いい感じですね。
問題点
- メソッドにブロック引数を渡すことができない
- メソッド内でインスタンス変数を参照する事ができない
欠点
- 1つメソッドを定義するごとにモジュールを mixin しているので継承リストがどんどん増えていく
- Ruby の言語機能だと『mixin したモジュールを削除する事が出来ない』ので定義したメソッドを削除するのが難しい
- mixin しまくってるので明示的にスーパークラスのメソッドを呼ぶことが出来ない
- Refinements で
#===
を定義しても内部でusing
することができない(影響を受けない
所感
と、いうことで簡単に『型チェックを行う処理』を実装してみました。
ポイントとしては
- 型チェックとは何かを考える
- 今回は型チェックというものを抽象化して
#===
にフォーカスを当てた - あまり Ruby で型というものを意識しないほうがいい気はする
- 今回は型チェックというものを抽象化して
- mixin して型チェックを行うメソッドを追加していく
- 個人的に Ruby らしくて好き
- メソッド内のコンテキストを意識する
- どうやってブロックを呼び出すのか
- ブロック内のコンテキストを何にするのか
という感じでしょうか。
単にメソッド定義をラップするだけならそこまで難しくはありませんが、コンテキストを意識し始めるとちょっと複雑になってきますね。
とはいえ、その割にはコード量自体は思ったよりも少なくなったので個人的には満足。
今回の実装では mixin を利用したり、動的にオブジェクトを生成したり、method_missing
したりと結構 Ruby らしいコードにはなったような気はします。
あと型チェックを『#===
でチェックする』と抽象化したので思ったよりも拡張性は高いです(理由は後述。
ちなみに似たようなライブラリをつくっているので気になる方はこちらも参照してみください(リファクタリングしたまま放置中なので近々破壊的変更する予定ですが…。
と、言うことで簡単ですが『Ruby で型チェック』を行ってみました。
Ruby のメタプログラミングたのしいのでみんなもやってみましょう。
そしてここからが本番は番外編です。
クラスオブジェクト以外で型チェックする
さて、今まで散々説明してきましたが型チェックは #===
で行っています。
つまり『#===
が定義されているオブジェクト』であれば別にクラスオブジェクトである必要はありません。
Ruby では #===
が when
で使用されることを想定しているのでクラスオブジェクト以外にも様々なオブジェクトで #===
が定義さいれています。
と、いうことでクラスオブジェクト以外でもいろいろと試してみましょう。
class X
# Regexp で受け取る文字列を制限する
# 文字列が http の URL のみ受け付ける
define_typed_method(:get, url: /http:.*/){
url
}
# Range で受け取る範囲を制限
# 1〜12のみ受け付ける
define_typed_method(:month, month: (1..12)){
"#{month}月"
}
# Proc で任意の条件を設定
# サイズが 5 以下のオブジェクトのみ受け付ける
define_typed_method(:func, x: -> x { x.size <= 5 }){
x.size
}
end
x = X.new
p x.get "http://example.com"
# => "http://example.com"
# Error
# x.get "ftp://example.com"
p x.month 2
# => "2月"
# Error
# p x.month 0
p x.func [1, 2, 3, 4, 5]
# => 5
p x.func "homu"
# => 4
# Error
# p x.func (1..10)
# p x.func "homuhomu"
上記のコードのように Regexp
、Range
、Proc
ではそれそれ独自に #===
を定義しているので型チェックのような形で引数を精査する事ができます。
このように『#===
で型チェックすること』でクラスオブジェクト以外にも様々なオブジェクトに対して使用することができるので割と柔軟性は高いです。
Array#===
を定義する
さてさて、Array#===
を定義することで次のように利用することもできます。
class Array
def === other, &block
size == other.size && zip(other).all? { |a, b| a.=== b, &block }
end
end
class Person
attr_accessor :name, :age
# set [name, age]
# みたいな配列で渡す
define_typed_method(:set, data: [String, Integer]){
self.name = data[0]
self.age = data[1]
}
end
mado = Person.new
mado.set(["mado", 14])
p mado.name
# => "mado"
p mado.age
# => 14
こんな感じで簡単に型チェックするオブジェクトを拡張する事ができます。
今回は実装しませんでしたが、Hash#===
なんかも定義してみると面白そうですね。
#instance_exec
にブロック引数を渡す
さて、ここからは先程解決できなかった問題を解決していきます。
まずは、『#instance_exec
にはブロック引数を渡すことができない』問題です。
これは proc-unbind
を利用して解決します。
proc-unbind
を使用することで『Proc
オブジェクトを任意のコンテキスト』で呼び出す事ができます。
インストール
$ gem install proc-unbind
使い方
require "proc/unbind"
using Proc::Unbind
expr = proc { |*args, &block|
p self
p args
p block
}
# unbind することで UnboundMethod を返す
expr_unbind = expr.unbind
p expr_unbind.class
# => UnboundMethod
# 任意のオブジェクトで bind する
# bind 後に call を呼び出すことで
# bind したオブジェクトのコンテキストで expr を評価する
expr_unbind.bind(42).call(1, 2, 3, &:to_s)
# => 42
# => [1, 2, 3]
# => #<Proc:0x00000000025a6b10>
expr_unbind.bind("homu").call {}
# => "homu"
# => []
# => #<Proc:0x0000000001012088@/tmp/vnzeaBx/11738:27>
こんな感じで Proc#unbind
から UnboundMethod
を生成します。
UnboundMethod
は任意のオブジェクトをバインドすることでそのオブジェクトをレシーバとして処理を呼び出す事ができます。
さて、これを利用して define_typed_method
でブロック引数を渡すことが出来るようにしてみましょう。
require "proc/unbind"
class Module
using Proc::Unbind
def define_typed_method name, *sig, &defmethod
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args, &block|
# proc/unbind を利用してレシーバを self にしながらブロックを渡す
# rebind(self) は unbind.bind(self) と同等
valid === args ? defmethod.rebind(self).call(*args, &block) : super(*args, &block)
}
}
end
def define_typed_method_with_hash name, sig, &defmethod
define_typed_method(name, *sig.values){ |*args, &block|
receiver = self
Object.new.instance_exec {
sig.keys.each_with_index { |name, i|
define_singleton_method name, &args[i].method(:itself)
}
define_singleton_method(:method_missing){ |name, *args, &block|
receiver.__send__ name, *args, &block
}
defmethod.rebind(self).call &block
}
}
end
end
class X
# ブロック引数はブロックの引数として受け取る
define_typed_method(:func){ |&block|
block.call 42
}
end
p X.new.func { |x| x + x }
# => 84
|&block|
のようにブロック引数を受け取る必要はありますが、これでブロック引数を渡して受け取る事ができました。
これで問題の1つであった『メソッドにブロック引数を渡すことができない』ということが解決できましたね!!
メソッド内でインスタンス変数を参照出来るようにしてみる(仮)
先に結論から書いておくと一応対応してみましたが、これでも完全な解決にはなりません。
なのでまあこういうアプローチもあるよーぐらいな感じで書いていきます。
さて、そもそもの問題点として『メソッド内から引数を変数名で参照したい』を解決するための手段として
- 変数を参照するメソッドを定義したオブジェクトを新しく生成する
- そのオブジェクトを経由して変数を参照する
- 元のレシーバには
method_missing
経由で参照する
という手段を用いました。
しかし、この結果、レシーバのコンテキストが異なってしまい新たな問題が発生してしまいました。
そこでアプローチを変えて以下のような感じにしてみたいと思います。
- 変数を参照するメソッドを定義した『モジュール』を新しく生成する
- そのモジュールをレシーバに mixin する
- ブロックを呼び出すを呼び出す
- mixin したモジュールを削除する
まあつまり『一時的にモジュールを mixin する』ということで副作用を最小限に抑えようと言うようなアプローチになります。
じゃあ、どうやって『一時的にモジュールを mixin する』のかというと Ruby の標準の機能ではできないので gem-unmixer
を使います。
gem-unmixer
に関しては以下の記事を参照してください。
今回はこれを利用して先ほどのような機能を実装します。
require "proc/unbind"
require "unmixer"
class Module
using Proc::Unbind
using Unmixer
def define_typed_method name, *sig, &defmethod
return define_typed_method_with_hash(name, sig.first, &defmethod) if Hash === sig.first
valid = -> other { sig.size == other.size && sig.zip(other).all? { |a, b| a === b } }
prepend Module.new {
define_method(name){ |*args, &block|
valid === args ? defmethod.rebind(self).call(*args, &block) : super(*args, &block)
}
}
end
def define_typed_method_with_hash name, sig, &defmethod
define_typed_method(name, *sig.values){ |*args, &block|
# キーを変数名として参照するためのモジュールを定義する
accessor = Module.new {
sig.keys.each_with_index { |name, i|
define_method name, &args[i].method(:itself)
}
}
# defmethod を呼び出す時のみ一時的に mixin する
extend(accessor){
# このブロック内では accessor モジュールが有効になっている
return defmethod.rebind(self).call &block
}
}
end
end
class X
attr_accessor :name, :age
define_typed_method(:set, name: String, age: Integer){
p self.name
# self は X のインスタンスオブジェクト
p self
# => #<X:0x0000000001e98508>
# なので変数名などを直接参照できる
@name = name
@age = age
# String か Symbol のみを受け付ける
define_typed_method(:func, str: (String | Symbol)){
str.capitalize
}
}
end
x = X.new
x.set("homu", 14)
p x.name
# => "homu"
p x.age
# => 14
これで、コンテキストを X のインスタンスオブジェクトにしたままブロック内で変数が参照できるようになりました。
やったね!!
ただし、この実装でも『ブロック内以外(例えば、ブロック内から呼び出した他のメソッド内とか)』でも mixin したモジュールの影響を受けてしまうので解決したとはいえません。
うーん、アプローチとしてはいいと思ったんですがむずかしいですね…。
複数の型でチェックする
最後の最後に本当のおまけです。
例えば以下のように複数の型を許容したい場合があると思います。
# String か Symbol のみを受け付ける
define_typed_method(:func, str: (String | Symbol)){
str.capitalize
}
これを解決する場合 gem-laurel
が利用できます(と、いうかこういう目的のために作った gem になります。
インストール
$ gem install laurel
使い方
require "laurel"
using Laurel::Refine
(x & y).any_method # => x.any_method && y.any_method
こんな感じで (a & b).hoge
と呼び出した場合、a.hoge && b.hoge
と評価するようなライブラリになります。
つまり (String | Symbol) === a
と呼びだされた場合は String === a || Symbol === a
となるような感じですね。
require "laurel"
using Laurel::Refine
class X
# String か Symbol のみを受け付ける
define_typed_method(:func, str: (String | Symbol)){
str.capitalize
}
# こんな感じで Regexp を組み合わせたり
# 文字列かつ、数値なら呼ばれる
define_typed_method(:twice, n: (String & /^-?\d+$/)){
n.to_i + n.to_i
}
# 数値の場合は普通に計算
define_typed_method(:twice, n: Integer){
n + n
}
end
x = X.new
p x.func "homu"
p x.func :mado
# Error
# p x.func 42
p x.twice "-6"
# => 12
p x.twice "5"
# => 10
p x.twice 42
# => 84
# Error
# p x.twice ""
# p x.twice "-"
# p x.twice "-42homu"
これでかなり引数に対する制約が柔軟に定義しやすくなったと思います。
所感
と、調子にのって書きまくったらめっちゃ長くなってしまいました…。
まあこんな感じでお手軽に Ruby でも型チェックできるよーという感じの内容でしたね!!
Ruby で型チェックを行う機構自体は前からいろいろと考えているんですが、型チェック自体は今回のように #===
みたいなメソッドでダックタイピングするのがいいような気はします。
拡張性や柔軟性も高いですし。
最初は Type
クラスみたいなのも考えてみたんですが、こっちは『Ruby に置いて型とはなんなのか』みたいなのが定義できなかったので考えるのをやめました…。
ただ、型チェックを考えるにあたって型チェック自体よりも
- 定義するメソッド(ブロック)の呼び出し方
- て型チェックを行うメソッドの定義方法
-
define_typed_method
みたいなのでラップするのがよいのか?
-
みたいな『どうやって型チェックを行うメソッドを定義するべきか』みたいなところで悩んだりすることが多かったです。
今回は define_typed_method(:func, name: String, age: Integer)
みたいに define_methodo
をラップするような形にしてみましたが。
requires [String, Integer]
def func name, age
end
みたいな感じで定義したほうがすっきりしそうな気もしますし、もしくは
def_.func(name: String, age: Integer){
}
みたいな定義方法も考えられますしね。
この辺りは好みもあるし、1人で考えていても答えが見つからないと思うんですが、いかんせん相談できる Rubyist がまわりに少ないので厳しい…。
slack とかに ruby-jp みたいなコミュニティができないかなー
あとは今のところ動的に型チェックを行うことを想定していますが、じゃあ静的に型チェックするには…みたいなのも考える必要が出てきますね。
今のところパフォーマンスに関しては完全に考えていないですし、ここら辺を考えると更にむずかしくなってきますね…。
まあそんな感じで『Ruby で型チェックを行う』というのを考えてみました。
これがベストだとは思いませんが、他の方も型に関して興味を持ってもらえると嬉しいです。
なにか質問や気になる点があればコメントや Twitter なんかで聞いてください。