Crystal言語にはジェネリクスの仕組みがありますが,型変数に対する制約をかける構文は,v0.21.1時点ではまだ用意されていないようです1。
Ary Borenszweig氏などは,ジェネリクスを単純なコンテナ以外の目的で使用することについては消極的なよう2ですが,Scalaと同じまでとは言わないまでも,特定のクラスの子孫だけを受け入れるジェネリクスがあると良いな,と思うことは間々あります。
ジェネリクスの定義時点では型変数は何型でもありませんが,ジェネリクスを使用する時点ではその型変数が何型なのかがわかっているはずです。また,型変数はジェネリクス型の定義の中であれば通常の型と同じように使用できますので,ある型が他の型の子孫であるかどうかを判定できれば,オブジェクトの生成時に型チェックをして例外を投げることはできそうな気がします。
ところが,RubyだとSomeClass < OtherClass
でSomeClass
がOtherClass
を継承しているかどうかを調べられるのですが,CrystalのClass
型には#<
や#>
などは用意されていません。
Int32 < Number
#=> Error in line 1: undefined method '<' for Int32:Class
さてどうしたものかと思ったのですが,Crystalコンパイラや標準添付ライブラリのソースを眺めていた時にそれっぽい記述を見たような薄っすらとした記憶があったため諸々調べてみると,マクロが使用するTypeNode
型3にはRubyのような#>
や#<
などのインスタンスメソッドが用意されており,これらを使って型の継承関係をチェックすることができるようです。
という訳で,N
型が先祖にNumber
を持っていない場合には,マクロを使って#initialize
中に例外を吐くコードを埋め込むという強引な方法ですが,数値だけを入れられる箱NumBox
型が実現できました。(何気に,型変数N
を{{N}}
で展開すると,実行時点の型名になってくれています)
class NumBox(N)
class InvalidType < Exception; end
getter value
def initialize(@value : N)
{% unless N < Number %}
raise InvalidType.new("{{N}} is not a Number type.")
{% end %}
end
def to_s(io)
io << @value
end
end
puts NumBox(Int32).new(1)
puts NumBox(Float64).new(1.0)
puts NumBox(String).new("1")
$ crystal run numbox.cr
1
1.0
String is not a Number type. (NumBox::InvalidType)
0x1073e3f52: *CallStack::unwind:Array(Pointer(Void)) at ??
0x1073e3ef1: *CallStack#initialize:Array(Pointer(Void)) at ??
0x1073e3ec8: *CallStack::new:CallStack at ??
0x1073e3221: *raise<NumBox::InvalidType>:NoReturn at ??
0x107410eef: *NumBox(String)@NumBox(N)#initialize<String>:NoReturn at ??
0x107410ecc: *NumBox(String)@NumBox(N)::new<String>:NumBox(String) at ??
0x1073ddb2e: __crystal_main at ??
0x1073e3068: main at ??
#<
や#>
以外にも,マクロのTypeNode
型には,その型がユニオン型かどうかを判定する#union?
や,インスタンス変数やメッソドのリストを返すメソッドなども用意されていて,使い様によってはかなり強力な道具になりそうです。
追記
実行時の例外よりもコンパイル時にエラーになって欲しいよね,ということで,マクロでコンパイル時エラーが出せるっぽいです。(#initialize
の中でエラーを出しちゃったせいで,エラー箇所の表示が変なことになっちゃってますが)
class NumBox(N)
class InvalidType < Exception; end
getter value
def initialize(@value : N)
{% unless N < Number %}
{% raise "#{N} is not a Number type." %}
{% end %}
end
def to_s(io)
io << @value
end
end
puts NumBox(Int32).new(1)
puts NumBox(Float64).new(1.0)
puts NumBox(String).new("1")
$ crystal run numbox2.cr
Error in test.cr:20: String is not a Number type.
puts NumBox(String).new("1")
^~~