魔法のようなtypeof
typeofの物語
typeof
式の物語は、配列リテラルから始まります。Crystalでは、以下のように書くことができます。
array = [1, 2, 3]
コンパイラは、配列がArray(Int32)
、つまり32ビット整数のみを含むことができると推論します。また、以下のように書くこともできます。
array = [1, 'a', true]
コンパイラは、それがArray(Int32 | Char | Bool)
であると推論します。ここで、Int32 | Char | Bool
はこれらの型の和集合を意味します。配列は、プログラムの実行中にこれらの型のいずれかを保持できます。
配列、ハッシュ、正規表現(regex)リテラルなどの言語のリテラルは、標準ライブラリ呼び出しへの単純な構文書き換えです。正規表現の場合、これは
/fo(o+)/
以下のように書き換えられます。
Regex.new("fo(o+)")
配列リテラルの書き換えには、もう少し考慮が必要です。配列はジェネリックです。つまり、保持できる型を指定する型T
でパラメータ化されます。前述のArray(Int32)
やArray(Int32 | Char | Bool)
などです。リテラル以外の方法で作成するには、以下のようにします。
Array(Int32 | Char | Bool).new
配列リテラルの場合、型は配列リテラル内のすべての要素の和集合型である必要があります。そこで、typeof
が誕生しました。最初はtype merge
と呼ばれ、表現できないコンパイラ内部のものでしたが(構文がありませんでした)、コンパイラはこれらのリテラルに使用していました。書き換えの例
array = [1, 'a', true]
# Rewritten to this, where <type_merge>(exp1, exp2, ...) computes
# the union type of the expressions:
Array(<type_merge>(1, 'a', true)).build(3) do |buffer|
buffer[0] = 1
buffer[1] = 'a'
buffer[2] = true
3
end
これで、このリテラルは通常のメソッドを呼び出して配列を作成しています。問題は、これを書くことができなかったことです:<type_merge>
は、型を計算できるこの内部ノードの表現にすぎません。上記を書くと構文エラーが発生します。
私たちは後に、この<type_merge>
ノードが非常にうまく機能し、リテラルに魔法がないようにしたい、ユーザーにこの<type_merge>
ノードを使用させたいと考え、typeof
という名前を付けました。なぜなら、この名前は他の言語ではかなりなじみ深いからです。これでこれを書く
array = [1, 'a', true]
そしてこれ
Array(typeof(1, 'a', true)).build(3) do |buffer|
buffer[0] = 1
buffer[1] = 'a'
buffer[2] = true
3
end
は完全に等価です。魔法はありません(もちろん、最初の構文の方がはるかに書きやすく読みやすいです)。
typeof
が言語に多くの力を与えるとは、当時はほとんど知りませんでした。
typeofの簡単な使い方
typeofの明らかなユースケースの1つは、コンパイラに式の推論された型を尋ねることです。例えば
puts typeof(1) #=> Int32
puts typeof([1, 2, 3].map &.to_s) #=> Array(String)
この時点で、typeof(exp)
はexp.class
に似ていると思うかもしれません。ただし、前者はコンパイル時の型を返し、後者は実行時の型を返します。
exp = rand(0..1) == 0 ? 'a' : true
puts typeof(exp) #=> Char | Bool
puts exp.class #=> Char (or Bool, depending on the chosen random value)
もう1つの簡単なユースケースは、別のオブジェクトの型に基づいて型を作成することです。
hash = {1 => 'a', 2 => 'b'}
other_hash = typeof(hash).new #:: Hash(Int32, Char)
このようにして、型名を繰り返したりハードコーディングしたりすることを避けることができます。
しかし、これらは面白すぎるほど単純です。
typeofの高度な使い方
Array#compact
メソッドを書いてみましょう。このメソッドは、nil
インスタンスが削除されたArray
を返します。もちろん、Array(Int32 | Nil)
、つまり整数とnilの配列から始めると、Array(Int32)
で終わりたいと考えています。
型の文法では、和集合を作成できます。たとえば、Int32 | Char
はInt32
とChar
の和集合を作成します。ただし、型を減算する方法はありません。T - Nil
構文はありません。しかし、typeof
を使用すれば、このメソッドを書くことができます。
まず、必要な型になるメソッドを定義します。
def not_nil(exp)
if exp.is_a?(Nil)
raise "oops, nil"
else
exp
end
end
exp
がnil
の場合、例外が発生します。それ以外の場合は、exp
を返します。その型を確認してみましょう。
puts typeof(not_nil(1)) #=> Int32
puts typeof(not_nil(nil)) #=> NoReturn
if var.is_a?(…)の仕組みにより、nil
ではないものを指定すると、型が同じ型であることがわかります。nil
を指定すると、if
で実行できる分岐はraise
のみです。さて、raise
にはこのNoReturn
型があります。これは基本的に、その式によって値が返されないことを意味します...例外が発生するためです!NoReturn
を持つ別の式は、たとえばexit
です。
和集合型であるnot_nil
を与えてみましょう。
element = rand(0..1) == 0 ? 1 : nil
puts typeof(element) #=> Int32 | Nil
puts typeof(not_nil(element)) #=> Int32
NoReturn
型がなくなっていることに注意してください。最後の式の「予期される」型はInt32 | NoReturn
、つまりメソッドの可能な型の和集合になります。ただし、NoReturn
には具体的な値がないため、NoReturn
を任意の型T
と組み合わせると、基本的にT
が返されます。なぜなら、not_nil
メソッドが成功した場合(つまり、発生しない場合)、整数が返されます。それ以外の場合は、スタックを通じて例外が発生します。
これでcompactメソッドを実装する準備ができました。
class Array
def compact
result = Array(typeof(not_nil(self[0]))).new
each do |element|
result << element unless element.is_a?(Nil)
end
result
end
end
ary = [1, nil, 2, nil, 3]
puts typeof(ary) #=> Array(Int32 | Nil)
compacted = ary.compact
puts compacted #=> [1, 2, 3]
puts typeof(compacted) #=> Array(Int32)
魔法の行はメソッドの最初の行です。
Array(typeof(not_nil(self[0]))).new
配列の最初の要素でnot_nil
を呼び出した結果の型である配列を作成します。コンパイラは配列の各位置にある型を知らないため、0
、1
、または123
を使用しても同じです。
このようにして、型の文法を拡張することなく、Nil
を除外する型を作成することができました。型推論アルゴリズムのコンパイラの機構だけで十分でした。
しかし、これはまだ簡単です。**本当に**面白くて楽しいものに移りましょう。
typeofの魔法
次のタスクは、Array#flatten
を実装することです。このメソッドは、元の配列を1次元的にフラット化したもの(再帰的)であるArray
を返します。つまり、配列であるすべての要素について、その要素をこの新しい配列に抽出します。
これが再帰的に機能する必要があることに注意してください。期待される動作を見てみましょう。
ary1 = [1, [2, [3], 'a']]
puts typeof(ary1) #=> Array(Int32 | Array(Int32 | Array(Int32) | Char))
ary1_flattened = ary1.flatten
puts ary1_flattened #=> [1, 2, 3, 'a']
puts typeof(ary1_flattened) #=> Array(Int32 | Char)
前と同様に、フラット化された配列に必要な型を持つメソッドを書いてみましょう。
def flatten_type(object)
if object.is_a?(Array)
flatten_type(object[0])
else
object
end
end
puts typeof(flatten_type(1)) #=> Int32
puts typeof(flatten_type([1, [2]])) #=> Int32
puts typeof(flatten_type([1, [2, ['a', 'b']]])) #=> Int32 | Char
メソッドは簡単です。オブジェクトが配列の場合、その要素のいずれかのフラット化された型が必要です。それ以外の場合は、型はオブジェクトの型です。
これで、flattenを実装する準備ができました。
class Array
def flatten
result = Array(typeof(flatten_type(self))).new
append_flattened(self, result)
result
end
private def append_flattened(object, result)
if object.is_a?(Array)
object.each do |sub_object|
append_flattened(sub_object, result)
end
else
result << object
end
end
end
この2番目の例では、配列のフラット化である型を作成することができました。
結論
結局のところ、typeof
には本当に魔法のようなものは何もありません。式の型を非常によく推論するコンパイラの能力をクエリして使用できるだけです。
前の例では、通常の方法で他の型から型を作成することができました。型とメソッドです。学ぶべき新しいことは何もなく、型について話すための特別な構文もありません。そして、これはシンプルでありながら強力であるため、優れています。