コンテンツへスキップ
GitHubリポジトリ フォーラム RSSニュースフィード

型推論規則

Ary Borenzweig

ここでは、Crystalがプログラムの各変数と式にどのように型を割り当てるかを説明し続けます。この投稿は少し長いですが、結局のところ、プログラマーにとって最も直感的な方法でCrystalを動作させ、Rubyと可能な限り類似した動作をさせることについてです。

まず、リテラル、C関数、およびいくつかのプリミティブから始めます。次に、ifwhile、ブロックなどのフロー制御構造に進みます。次に、特別な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'

これを実現するために、コンパイラーは変数に最後に代入された式を記憶します。上記の例では、最初の行の後、コンパイラーはaInt32型であることを認識しているため、absの呼び出しは有効です。3行目では、Stringを代入しているため、コンパイラーはこれを記憶し、4行目ではsizeを呼び出すのは完全に有効です。

さらに、コンパイラーは、Int32Stringの両方が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への最初の呼び出しは、aInt32が割り当てられているため、失敗することはありません。sizeへの最初の呼び出しも、aStringが割り当てられているため、失敗することはありません。ただし、ifの後、aInt32またはStringのいずれかになります。

したがって、Crystalは、aの型に関するこの直感的な推論を維持しようとします。変数がifのthenまたはelseブランチ内で割り当てられると、コンパイラーは、ifが終了するか、新しい式が割り当てられるまで、その型を保持することを認識します。ifが終了すると、コンパイラーはaに各ブランチで最後に割り当てられた式の型を持たせます。

Crystalの最後の行では、「Int32に対する未定義のメソッド'size'」というコンパイラーエラーが発生します。これは、Stringsizeメソッドがある場合でも、Int32にはないためです。

言語を設計する際に、2つの選択肢がありました。(現在のように)上記のものをコンパイル時エラーにするか、(Rubyのように)実行時エラーにするかです。コンパイル時エラーにする方が良いと信じています。場合によっては、コンパイラーよりもよく知っており、変数があなたが思うような型であると確信しているかもしれません。しかし、場合によっては、コンパイラーがケースやロジックを見落としたことを知らせてくれるため、感謝するでしょう。

ifには、考慮すべきケースがいくつかあります。たとえば、変数aifの前に存在しない可能性があります。この場合、いずれかのブランチで割り当てられていない場合、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内で考慮すべきその他の点は、breaknextです。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の後では、変数の型は両方のブランチの型の和集合になることを思い出してください。しかし、最初のブランチはraiseNoReturnであるため、そこで終了するため、コンパイラは、そのブランチが実行された場合、ifの後のコードは決して実行されないことを知っています。したがって、コンパイラは、aelseブランチの型のみを持つと断言できます。

同様のロジックは、ifの中にreturnbreak、または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!は、aInt32の場合は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の型は、ifthenブランチ内で、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内でxnilであった場合、メソッドが返されることを知っています。したがって、xはその後nilになることはないため、absを呼び出すことは問題ありません。コンパイラはこれをどのように知っているのでしょうか?

まず、コンパイラはunlessifに書き換えます。

def foo(x)
  if x
  else
    return
  end

  x.abs # ok
end

次に、ifthenブランチ内では、xnilではないことを知っています。elseブランチ内ではメソッドが返されるため、その後xの型を気にする必要はありません。したがって、ifの後では、xInt32型のみになる可能性があります。これはRubyでは慣用的なコードであり、言語規則を注意深く守ればCrystalでも同様です。

メソッドとインスタンス変数についても話す必要がありますが、この記事はすでに十分に長くなっているため、それは次の記事で説明する必要があります。乞うご期待!