跳至內容
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'

為了實現這一點,編譯器會記住最後指派給變數的表達式。在上面的範例中,在第一行之後,編譯器知道 a 的類型為 Int32,因此呼叫 abs 是有效的。在第三行,我們將一個 String 指派給它,因此編譯器會記住這一點,並且在第四行中,在其上調用 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 永遠不會失敗,因為 Int32 指派給了 a。第一次呼叫 size 也永遠不會失敗,因為 String 指派給了 a。但是,在 if 之後,a 可以是 Int32String

因此,Crystal 會嘗試保持這種關於 a 類型的直覺推理。當在 if 的 then 或 else 分支內指派變數時,編譯器會知道它將繼續具有該類型,直到 if 結束或直到指派新的表達式為止。當 if 結束時,編譯器會讓 a 具有在每個分支中指派給它的最後一個表達式的類型。

Crystal 中的最後一行會產生編譯器錯誤:「未定義 Int32 的方法 'size'」。那是因為即使 String 有一個 size 方法,Int32 卻沒有。

在設計程式語言時,我們有兩個選擇:將以上內容設為編譯時期錯誤(如現在)或僅將其設為執行階段錯誤(如在 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 內部需要考慮的其他事項是 breaknextbreak 會讓 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 非常相似:它們可以執行零次或多次。因此,變數類型的邏輯與 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 包圍,則會執行 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 之後的程式碼將永遠不會執行。因此,它可以肯定地說:a 只會具有 else 分支的類型。

當你在 if 裡面使用 returnbreaknext 時,也適用相同的邏輯。

此外,當你定義一個類型為 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 分支肯定是 NoReturnelse 分支肯定是 Int32。那麼我們可以得出結論,a 的類型為 NoReturnInt32。然而,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 的類型是 Int32Nil。我們還沒有提到的一件事是,當你有一個聯集類型並在它上面調用一個方法,並且所有類型都回應了該方法時,結果的類型是每個方法的類型的聯集。

在這種情況下,如果 aInt32,則 a.not_nil! 的類型將為 Int32;如果它是 Nil,則為 NoReturn(因為 raise)。組合這些類型只會得到 Int32,因此上述程式碼完全有效。這就是你可以從變數中捨棄 Nil,如果它結果是 nil,則將其轉換為執行時期例外的方式。不需要任何特殊的語言結構。所有的一切都是用目前為止解釋的邏輯來完成的。

類型篩選器

現在,如果我們想在類型為 Int32Nil 的變數上執行一個方法,但前提是該變數是 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 之後的類型,並與 aelse 分支中的任何類型組合。例如:

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)

以上程式碼不應給出編譯時錯誤。程式設計師知道,如果 xfoo 內部為 nil,則該方法會回傳。因此,x 之後永遠不可能是 nil,因此可以對其調用 abs。編譯器如何知道這一點?

首先,編譯器將 unless 重寫為 if

def foo(x)
  if x
  else
    return
  end

  x.abs # ok
end

接下來,在 ifthen 分支內,我們知道 x 不是 nil。在 else 分支中,該方法回傳,因此我們不關心之後 x 的類型。因此,在 if 之後,x 的類型只能是 Int32。這是 Ruby 中的慣用程式碼,如果我們仔細遵循語言規則,在 Crystal 中也是如此。

我們仍然需要討論方法和實體變數,但是這篇文章已經夠長了,因此必須在後續的文章中解釋。敬請期待!