跳至內容

類型推斷

Crystal 的理念是盡可能減少類型限制。但是,有些限制是必要的。

考慮像這樣的類別定義

class Person
  def initialize(@name)
    @age = 0
  end
end

我們可以很快看到 @age 是一個整數,但我們不知道 @name 的類型。編譯器可以從 Person 類別的所有用法推斷其類型。但是,這樣做會有一些問題

  • 對於閱讀程式碼的人來說,類型並不明顯:他們也必須檢查 Person 的所有用法才能找出答案。
  • 一些編譯器最佳化,例如只需分析一次方法,以及增量編譯,幾乎不可能實現。

隨著程式碼庫的增長,這些問題變得更加重要:理解專案變得更加困難,並且編譯時間變得難以忍受。

因此,Crystal 需要以明顯的方式(對人類來說盡可能明顯)知道實例和類別變數的類型。

有幾種方法可以讓 Crystal 知道這一點。

具有類型限制

最簡單但可能最繁瑣的方法是使用明確的類型限制。

class Person
  @name : String
  @age : Int32

  def initialize(@name)
    @age = 0
  end
end

不具類型限制

如果省略明確的類型限制,編譯器將嘗試使用一組語法規則來推斷實例和類別變數的類型。

對於給定的實例/類別變數,當可以應用規則並可以猜測類型時,該類型會被添加到一個集合中。當無法應用更多規則時,推斷的類型將是這些類型的聯合。此外,如果編譯器推斷實例變數並非總是初始化,它也會包含Nil類型。

規則很多,但通常最常用的是前三個。無需記住所有規則。如果編譯器產生錯誤,指出無法推斷實例變數的類型,您可以隨時添加明確的類型限制。

以下規則僅提及實例變數,但它們也適用於類別變數。它們是

1. 賦值字面值

當字面值被賦值給實例變數時,字面值的類型會被添加到集合中。所有字面值都有相關聯的類型。

在以下範例中,@name 被推斷為 String,而 @age 被推斷為 Int32

class Person
  def initialize
    @name = "John Doe"
    @age = 0
  end
end

此規則以及後續每個規則也將應用於 initialize 以外的方法。例如

class SomeObject
  def lucky_number
    @lucky_number = 42
  end
end

在上述情況下,@lucky_number 將被推斷為 Int32 | NilInt32 是因為 42 被賦值給它,而 Nil 是因為它沒有在所有類別的初始化方法中被賦值。

2. 賦值呼叫類別方法 new 的結果

當像 Type.new(...) 這樣的表達式被賦值給實例變數時,類型 Type 會被添加到集合中。

在以下範例中,@address 被推斷為 Address

class Person
  def initialize
    @address = Address.new("somewhere")
  end
end

這也適用於泛型類型。這裡 @values 被推斷為 Array(Int32)

class Something
  def initialize
    @values = Array(Int32).new
  end
end

注意new 方法可能會被類型重新定義。在這種情況下,推斷的類型將是 new 返回的類型,如果可以使用一些後續規則推斷出該類型。

3. 賦值一個具有類型限制的方法參數的變數

在以下範例中,@name 被推斷為 String,因為方法參數 name 具有 String 類型的類型限制,並且該參數被賦值給 @name

class Person
  def initialize(name : String)
    @name = name
  end
end

請注意,方法參數的名稱並不重要;這也有效

class Person
  def initialize(obj : String)
    @name = obj
  end
end

使用較短的語法從方法參數賦值實例變數具有相同的效果

class Person
  def initialize(@name : String)
  end
end

另請注意,編譯器不會檢查方法參數是否被重新賦值為不同的值

class Person
  def initialize(name : String)
    name = 1
    @name = name
  end
end

在上述情況下,編譯器仍然會推斷 @nameString,稍後在完全輸入該方法時會產生編譯時錯誤,指出 Int32 無法賦值給類型為 String 的變數。如果 @name 不應該是 String,請使用明確的類型限制。

4. 賦值一個具有回傳類型限制的類別方法結果

在以下範例中,@address 被推斷為 Address,因為類別方法 Address.unknown 具有 Address 的回傳類型限制。

class Person
  def initialize
    @address = Address.unknown
  end
end

class Address
  def self.unknown : Address
    new("unknown")
  end

  def initialize(@name : String)
  end
end

事實上,上述程式碼在 self.unknown 中並不需要回傳型別的限制。原因是編譯器也會查看類別方法的主體,如果它可以應用先前的規則之一(它是 new 方法,或是字面值等等),它將從該表達式推斷型別。因此,上面的程式碼可以簡單地寫成這樣

class Person
  def initialize
    @address = Address.unknown
  end
end

class Address
  # No need for a return type restriction here
  def self.unknown
    new("unknown")
  end

  def initialize(@name : String)
  end
end

這個額外規則非常方便,因為除了 new 之外,擁有「類似建構函式」的類別方法是很常見的。

5. 將帶有預設值的方法參數賦值給變數

在以下範例中,因為 name 的預設值是字串字面值,並且稍後將其賦值給 @name,因此 String 將被添加到推斷型別的集合中。

class Person
  def initialize(name = "John Doe")
    @name = name
  end
end

當然,這也適用於簡短語法

class Person
  def initialize(@name = "John Doe")
  end
end

預設參數值也可以是 Type.new(...) 方法或具有回傳型別限制的類別方法。

6. 賦值調用 lib 函數的結果

因為 lib 函數 必須具有明確的型別,所以編譯器可以在將其賦值給實例變數時使用回傳型別。

在以下範例中,@age 會被推斷為 Int32

class Person
  def initialize
    @age = LibPerson.compute_default_age
  end
end

lib LibPerson
  fun compute_default_age : Int32
end

7. 使用 out lib 表達式

因為 lib 函數 必須具有明確的型別,所以編譯器可以使用 out 引數的型別,該型別應為指標型別,並使用解參考後的型別作為猜測。

在以下範例中,@age 會被推斷為 Int32

class Person
  def initialize
    LibPerson.compute_default_age(out @age)
  end
end

lib LibPerson
  fun compute_default_age(age_ptr : Int32*)
end

其他規則

編譯器會盡可能地聰明,以減少明確的型別限制。例如,如果賦值一個 if 表達式,則將從 thenelse 分支推斷型別

class Person
  def initialize
    @age = some_condition ? 1 : 2
  end
end

因為上面的 if (嚴格來說是三元運算子,但它類似於 if)具有整數字面值,所以 @age 成功地被推斷為 Int32,而不需要多餘的型別限制。

另一種情況是 ||||=

class SomeObject
  def lucky_number
    @lucky_number ||= 42
  end
end

在上面的範例中,@lucky_number 將被推斷為 Int32 | Nil。這對於延遲初始化的變數非常有用。

常量也會被追蹤,因為對於編譯器(和人類)來說,這樣做非常簡單。

class SomeObject
  DEFAULT_LUCKY_NUMBER = 42

  def initialize(@lucky_number = DEFAULT_LUCKY_NUMBER)
  end
end

這裡使用了規則 5(預設參數值),並且因為常量解析為整數字面值,所以 @lucky_number 被推斷為 Int32