型推論規則
ここでは、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でも同様です。
メソッドとインスタンス変数についても話す必要がありますが、この記事はすでに十分に長くなっているため、それは次の記事で説明する必要があります。乞うご期待!