型推論規則
ここでは、Crystalがプログラムの各変数と式にどのように型を割り当てるかを説明し続けます。この投稿は少し長いですが、結局のところ、プログラマーにとって最も直感的な方法でCrystalを動作させ、Rubyと可能な限り類似した動作をさせることについてです。
まず、リテラル、C関数、およびいくつかのプリミティブから始めます。次に、if
、while
、ブロックなどのフロー制御構造に進みます。次に、特別なNoReturn
型と型フィルターについて説明します。
リテラル
リテラルには、コンパイラーによって認識される独自の型があります。
true # Boolean
1 # Int32
"hello" # String
1.5 # Float64
C関数
C関数を定義するときは、コンパイラーにその型を伝える必要があります。
lib C
fun sleep(seconds : UInt32) : UInt32
end
sleep(1_u32) # sleep has type UInt32
allocate
allocateプリミティブは、オブジェクトの初期化されていないインスタンスを提供します。
class Foo
def initialize(@x)
end
end
Foo.allocate # Foo.allocate has type Foo
通常は直接呼び出しません。代わりに、new
を呼び出します。これは、コンパイラーによって自動的に次のようなものとして生成されます。
class Foo
def self.new(x)
foo = allocate
foo.initialize(x)
foo
end
def initialize(@x)
end
end
Foo.new(1)
同様のプリミティブはPointer#malloc
で、メモリ領域への型付きポインターを提供します。
Pointer(Int32).malloc(10) # has type Pointer(Int32)
変数
次に、式を変数に代入すると、変数はその式の型にバインドされます(式の型が変更された場合、変数の型も変更されます)。
a = 1 # 1 is Int32, so a is Int32
コンパイラーは、変数を使用する際に可能な限り賢く振る舞おうとします。たとえば、変数に複数回代入できます。
a = 1 # a is Int32
a.abs # ok, Int32 has a method 'abs'
a = "hello" # a is now String
a.size # ok, String has a method 'size'
これを実現するために、コンパイラーは変数に最後に代入された式を記憶します。上記の例では、最初の行の後、コンパイラーはa
がInt32
型であることを認識しているため、abs
の呼び出しは有効です。3行目では、String
を代入しているため、コンパイラーはこれを記憶し、4行目ではsize
を呼び出すのは完全に有効です。
さらに、コンパイラーは、Int32
とString
の両方がa
に代入されたことを記憶します。LLVMコードを生成する際、コンパイラーはa
をInt32またはStringのいずれかになる可能性のある共用体型として表現します。Cでは次のようになります。
struct Int32OrString { int type_id; union { int int_value; string string_value; } data; }
同じ変数に異なる型を継続的に代入すると、これは非効率に思えるかもしれません。ただし、コンパイラーは、abs
を呼び出したときに、a
がInt32であったことを認識しているため、type_id
フィールドをチェックすることはありません。直接int_value
フィールドを使用します。LLVMはこれを認識し、これを最適化するため、生成されたコードには共用体はありません(type_id
フィールドは読み取られません)。
Rubyに戻ると、変数に複数回続けて代入した場合、最後の値(および型)がその後の呼び出しでカウントされます。Crystalはこの動作を模倣します。したがって、変数は、代入した最後の式の名前になります。
If
Rubyコードを分析してみましょう。
if some_condition
a = 1
a.abs
else
a = "hello"
a.size
end
a.size
Rubyでは、実行時に失敗する可能性があるのは最後の行だけです。abs
への最初の呼び出しは、a
にInt32
が割り当てられているため、失敗することはありません。size
への最初の呼び出しも、a
にString
が割り当てられているため、失敗することはありません。ただし、if
の後、a
はInt32
またはString
のいずれかになります。
したがって、Crystalは、a
の型に関するこの直感的な推論を維持しようとします。変数がif
のthenまたはelseブランチ内で割り当てられると、コンパイラーは、if
が終了するか、新しい式が割り当てられるまで、その型を保持することを認識します。if
が終了すると、コンパイラーはa
に各ブランチで最後に割り当てられた式の型を持たせます。
Crystalの最後の行では、「Int32に対する未定義のメソッド'size'」というコンパイラーエラーが発生します。これは、String
にsize
メソッドがある場合でも、Int32
にはないためです。
言語を設計する際に、2つの選択肢がありました。(現在のように)上記のものをコンパイル時エラーにするか、(Rubyのように)実行時エラーにするかです。コンパイル時エラーにする方が良いと信じています。場合によっては、コンパイラーよりもよく知っており、変数があなたが思うような型であると確信しているかもしれません。しかし、場合によっては、コンパイラーがケースやロジックを見落としたことを知らせてくれるため、感謝するでしょう。
if
には、考慮すべきケースがいくつかあります。たとえば、変数a
はif
の前に存在しない可能性があります。この場合、いずれかのブランチで割り当てられていない場合、if
の終わりには、読み取られた場合にNil型も含まれます。
if some_condition
a = 1
end
a # here a is Int32 or Nil
これも、Rubyの動作を模倣しています。
最後に、if
の型は、両方のブランチの最後の式の和集合です。ブランチがない場合、それはNil
型を持っていると見なされます。
While
while
は、ある意味でif
に似ています。
a = 1
while some_condition
a = "hello"
end
a # here a is Int32 or String
それは、some_condition
が最初に偽になる可能性があるためです。
ただし、while
はループであるため、考慮すべき点がいくつかあります。たとえば、while
内で変数に最後に代入された式によって、次の反復でのその変数の型が決まります。このように、ループの開始時の型は、ループ前の型とループ後の型の和集合になります。
a = 1
while some_condition
a # here a is actually Int32 or String
a = false # here a is Bool
a = "hello" # here a is String
a.size # ok, a is String
end
a # here a is Int32 or String
while
内で考慮すべきその他の点は、break
とnext
です。break
は、breakの直前の型をwhile
の出口での型に追加します。
a = 1
while some_condition
a # here a is Int32 or Bool
if some_other_condition
a = "hello" # we break, so at the exit a can also be String
break
end
a = false # here a is Bool
end
a # here a is Int32 or String or Bool
next
は、型をwhile
の先頭に追加します。
a = 1
while some_condition
a # here a is Int32 or String or Bool
if some_other_condition
a = "hello" # we next, so in the next iteration a can be String
next
end
a = false # here a is Bool
end
a # here a is Int32 or String or Bool
ブロック
ブロックはwhile
と非常によく似ています。0回以上実行できます。したがって、変数の型のロジックは、while
のロジックと非常によく似ています。
NoReturn
Crystalには、NoReturn
という不思議な型があります。その一例が、C言語のexit
関数です。
lib C
fun exit(status : Int32) : NoReturn
end
C.exit(1) # this is NoReturn
puts "hello" # this will never be executed
もう一つ、非常に便利なNoReturnメソッドは、例外を発生させるraise
です。
この型は基本的に、この時点以降は何もない、という意味です。何も返されず、その後ろのコードは実行されません(もちろん、コードを囲むrescue
があれば実行されますが、通常のパスは実行されません)。
コンパイラはNoReturn
について知っています。例えば、次のコードを見てください。
a = some_int
if a == 1
a = "hello"
puts a.size # ok
raise "Boom!"
else
a = 2
end
a # here a can only be Int32
if
の後では、変数の型は両方のブランチの型の和集合になることを思い出してください。しかし、最初のブランチはraise
がNoReturn
であるため、そこで終了するため、コンパイラは、そのブランチが実行された場合、if
の後のコードは決して実行されないことを知っています。したがって、コンパイラは、a
がelse
ブランチの型のみを持つと断言できます。
同様のロジックは、if
の中にreturn
、break
、またはnext
がある場合にも適用されます。
また、型がNoReturn
であるメソッドを定義した場合、そのメソッドもまたNoReturn
になります。
def raise_boom
raise "Boom!"
end
if some_condition
a = 1
else
raise_boom
end
a.abs # ok
NoReturnの和集合
if
の型は、if
のブランチの最後の式の和集合であることを思い出してください。
次のif
(したがって、a
変数)の型は何ですか?
a = if some_condition
raise "Boom!"
else
1
end
a # a is...?
さて、then
ブランチは間違いなくNoReturn
です。else
ブランチは間違いなくInt32
です。したがって、a
の型はNoReturn
またはInt32
であると結論付けることができます。ただし、NoReturn
は、その後何も実行されないことを意味します。したがって、a
は前のスニペットの最後ではInt32
にしかなりえず、コンパイラはそのように動作します。
これにより、not_nil!
という小さなメソッドを実装できます。それがこちらです。
class Object
def not_nil!
self
end
end
class Nil
def not_nil!
raise "Nil assertion failed"
end
end
a = some_condition ? 1 : nil
a.not_nil!.abs # compiles!
a
の型は、Int32
またはNil
です。まだ言っていないことの1つは、和集合型があり、その型でメソッドを呼び出し、すべての型がそのメソッドに応答する場合、結果の型は各メソッドの型の和集合になるということです。
この場合、a.not_nil!
は、a
がInt32
の場合はInt32
の型を持ち、Nil
の場合は(raise
のため)NoReturn
の型を持ちます。これらの型を組み合わせると、単にInt32
になるため、上記のコードは完全に有効です。このようにして、変数からNil
を破棄し、nil
になった場合はランタイム例外に変えることができます。特別な言語構成は必要ありません。すべて、これまでに説明したロジックで作成されています。
型フィルター
さて、変数の型がInt32
またはNil
の場合、その変数がInt32
の場合にのみメソッドを実行したい場合はどうすればよいでしょうか。それがNil
の場合は、何もしたくありません。
not_nil!
は、nilの場合にランタイム例外を発生させるため、使用できません。
別のメソッドであるtry
を定義できます。
class Object
def try
yield self
end
end
class Nil
def try(&block)
nil
end
end
a = some_condition ? 1 : nil
b = a.try &.abs # b is Int32 or Nil
(&.abs
が何を意味するかわからない場合は、これを読んでください)
値がNil
であるかどうかによって何かを行うことが非常に一般的なため、Crystalは上記を行うための別の方法を提供しています。これはこちらで簡単に説明しましたが、今回はより詳しく説明し、前の説明と組み合わせます。
変数がif
の条件である場合、コンパイラはthen
ブランチ内では変数がnil
ではないと想定します。
a = some_condition ? 1 : nil # a is Int32 or Nil
if a
a.abs # a is Int32
end
これは理にかなっています。a
が真である場合、それはnil
ではないことを意味します。これだけでなく、コンパイラはa
の型を、if
の後で、else
ブランチでa
が持つ型と組み合わせたものにします。例えば
a = some_condition ? 1 : nil
if a
a.abs # ok, here a is Int32
else
a = 1 # here a is Int32
end
a.abs # ok, a can only be Int32 here
プログラマーが上記がRubyで常に機能することを期待しているように(ランタイムで「未定義のメソッド」エラーが発生しない)、Crystalでも機能します。
上記を「型フィルター」と呼びます。a
の型は、if
のthen
ブランチ内で、a
が持つ可能性のある型からNil
を削除することにより、フィルター処理されました。
別の型フィルターは、is_a?
を実行したときに発生します。
a = some_condition ? 1 : nil
if a.is_a?(Int32)
a.abs # ok
end
そして、別の型フィルターは、responds_to?
を実行したときに発生します。
a = some_condition ? 1 : nil
if a.responds_to?(:abs)
a.abs # ok
end
これらは、コンパイラによって認識されている特別なメソッドであり、そのため、コンパイラは型をフィルター処理できます。反対に、メソッドnil?
は現在特別なメソッドではないため、次のコードは機能しません。
a = some_condition ? 1 : nil
if a.nil?
else
a.abs # should be ok, but now gives error
end
nil?
も特別なメソッドにして、言語の他の部分との一貫性を高め、上記が機能するようにするでしょう。また、単項!
メソッドも特別なメソッドにし、オーバーロードできないようにして、次のようにできるようにするでしょう。
a = some_condition ? 1 : nil
if !a
else
a.abs # should be ok, but now gives error
end
結論
結論として、この記事の冒頭で述べたように、Crystalは可能な限りRubyのように動作することを望んでおり、プログラマーにとって直感的で理にかなっていることは、コンパイラにも理解させたいと考えています。例えば
def foo(x)
return unless x
x.abs # ok
end
a = some_condition ? 1 : nil
b = foo(a)
上記はコンパイル時エラーを発生させるべきではありません。プログラマーは、foo
内でx
がnil
であった場合、メソッドが返されることを知っています。したがって、x
はその後nil
になることはないため、abs
を呼び出すことは問題ありません。コンパイラはこれをどのように知っているのでしょうか?
まず、コンパイラはunless
をif
に書き換えます。
def foo(x)
if x
else
return
end
x.abs # ok
end
次に、if
のthen
ブランチ内では、x
がnil
ではないことを知っています。else
ブランチ内ではメソッドが返されるため、その後x
の型を気にする必要はありません。したがって、if
の後では、x
はInt32
型のみになる可能性があります。これはRubyでは慣用的なコードであり、言語規則を注意深く守ればCrystalでも同様です。
メソッドとインスタンス変数についても話す必要がありますが、この記事はすでに十分に長くなっているため、それは次の記事で説明する必要があります。乞うご期待!