跳至內容
GitHub 程式碼庫 論壇 RSS 新聞訂閱

內部構造

Ary Borenzweig

讓我們來談談 Crystal 如何處理您的程式碼:它如何在記憶體中表示類型、如何在執行時進行方法查找、如何進行方法調度等等。在使用程式語言時,了解這些總是有用的,以便以最有效的方式架構我們的程式碼,並精確了解我們的程式碼將轉換成什麼。

類型如何在記憶體中表示

為了討論類型表示,我們將使用 C 和 LLVM IR 程式碼,因此請務必查看參考資料

Crystal 有內建類型、使用者定義類型和聯合。

內建類型

這些是 Nil、Bool、Char 和各種數字類型(Int32、Float64 等)、Symbol、Pointer、Tuple、StaticArray、Enum、Proc 和 Class。

讓我們來看看 Bool 如何表示。為此,讓我們編寫這個小程式

# test.cr
x = true

要查看產生的 LLVM,我們可以使用此命令

crystal build test.cr --emit llvm-ir --prelude=empty

--emit llvm-ir 旗標會告訴編譯器將產生的 LLVM IR 程式碼轉儲到 test.ll 檔案中。--prelude=empty 告訴編譯器不要使用預設的前導檔案,例如,初始化 GC

這樣,我們可以得到一個非常簡單且乾淨的 LLVM IR 程式碼檔案,其中只有我們編寫的程式碼

; ModuleID = 'main_module'

%String = type { i32, i32, i32, i8 }

@symbol_table = global [0 x %String*] zeroinitializer

define i1 @__crystal_main(i32 %argc, i8** %argv) {
alloca:
  %x = alloca i1
  br label %entry

entry:                                            ; preds = %alloca
  store i1 true, i1* %x
  ret i1 true
}

declare i32 @printf(i8*, ...)

define i32 @main(i32 %argc, i8** %argv) {
entry:
  %0 = call i1 @__crystal_main(i32 %argc, i8** %argv)
  ret i32 0
}

要點在於 __crystal_main:我們可以看到編譯器在堆疊中為 x 配置了一個 i1,然後將 true 儲存在其中。也就是說,編譯器將 Bool 表示為單一位元,這相當有效率。

讓我們對 int 做同樣的事情

x = 1

對於 x 這次我們將得到

%x = alloca i32

在 LLVM 中,i32 是一個以 32 位元表示的 int,這再次相當有效率,而且是我們期望 Int32 的表示方式。

也就是說,即使 Crystal 是物件導向的,而且 Int32 的行為就像一個物件(它有方法),它的內部表示也盡可能有效率。

Symbol

現在讓我們看看 Symbol

x = :one
y = :two

這次讓我們看看完整的 LLVM IR 程式碼

; ModuleID = 'main_module'
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-darwin14.1.0"

%String = type { i32, i32, i32, i8 }

@one = private constant { i32, i32, i32, [4 x i8] } { i32 1, i32 3, i32 3, [4 x i8] c"one\00" }
@two = private constant { i32, i32, i32, [4 x i8] } { i32 1, i32 3, i32 3, [4 x i8] c"two\00" }
@symbol_table = global [2 x %String*] [%String* bitcast ({ i32, i32, i32, [4 x i8] }* @one to %String*), %String* bitcast ({ i32, i32, i32, [4 x i8] }* @two to %String*)]

define internal i32 @__crystal_main(i32 %argc, i8** %argv) {
alloca:
  %x = alloca i32
  %y = alloca i32
  br label %entry

entry:                                            ; preds = %alloca
  store i32 0, i32* %x
  store i32 1, i32* %y
  ret i32 1
}

declare i32 @printf(i8*, ...)

define i32 @main(i32 %argc, i8** %argv) {
entry:
  %0 = call i32 @__crystal_main(i32 %argc, i8** %argv)
  ret i32 0
}

這裡有三件事很重要。首先,我們可以看到 Symbol 表示為 i32,也就是四個位元組。其次,我們可以看到 x 被賦予值 0,而 y 被賦予值 1。第三,我們可以在頂部看到一些常數:hellobyesymbol_table

基本上,Crystal 中的 Symbol 只是一個賦予唯一數字的名稱。Symbol 無法動態建立(沒有 String#to_sym),而且建立它們的唯一方法是使用它們的字面值,因此編譯器可以知道整個程式中使用的所有符號。編譯器會為每個符號指定一個數字,從零開始,並且它還會建立一個將其數字對應到字串的表格,以便能夠以非常有效的方式實作 Symbol#to_s。這使得符號非常適合用於小組常數,因為它就像使用帶有名稱的魔術數字一樣。

指標

指標是一種泛型類型,表示指向某些記憶體位置的類型化指標。例如

x = Pointer(Int32).malloc(1_u64)
x.value = 1
x.value #=> 1

如果您查看產生的 LLVM IR 程式碼,您將看到一堆程式碼。首先,x 的表示方式如下

%x = alloca i32*

同樣,這只是一個指向 int32 的指標,它應該是這樣。接下來您將看到對 malloc 的呼叫(將使用常規的前導檔案從 GC 要求記憶體)和 memset 來清除記憶體,然後是一些在該記憶體位址中賦值 1 的指令。這對於這篇部落格文章的主題並不重要,因此我們跳過它,但重要的是要知道產生的程式碼與在 C 中產生的程式碼非常相似。

Tuple

Tuple 是一個固定大小、不可變的值序列,其中每個位置的類型在編譯時已知。

x = {1, true}

上面的 LLVM IR 程式碼片段是

%"{Int32, Bool}" = type { i32, i1 }
...
%x = alloca %"{Int32, Bool}"

正如我們所看到的,tuple 表示為 LLVM 結構,它只會依序打包值。tuple 的這種表示法允許我們將 Int32 分解為其位元組,例如,以這種方式

x = 1234
ptr = pointerof(x) as {UInt8, UInt8, UInt8, UInt8}*
puts ptr.value #=> {21, 205, 91, 7}

StaticArray

StaticArray 是固定大小、可變的相同類型值序列,配置在堆疊上並按值傳遞。前導檔案包含建立它們的安全方法,但是由於我們使用的是精簡的前導檔案,因此建立它們的不安全方法(將初始化為包含垃圾的資料)如下所示

x = uninitialized Int32[8]

它的 LLVM 表示法

%x = alloca [8 x i32]

我們不會再多談這種類型,因為它使用不多,主要用於 IO 緩衝區之類:Array 是所有其他操作的建議類型。

Enum

這是一個列舉

enum Color
  Red
  Green
  Blue
end

x = Color::Green

列舉在某種程度上類似於 Symbol:與名稱關聯的數字,因此我們可以在程式碼中使用名稱而不是魔術數字。正如預期的那樣,列舉表示為 i32,即四個位元組,除非在其宣告中另有指定

enum Color : UInt8
  Red
  Green
  Blue
end

列舉的好處是您可以列印它們,並且會獲得它們的名稱,而不是它們的值

puts Color::Green #=> Green

這與 Symbol 的方式不同,使用編譯時反射和巨集。但是,基本上,列舉的 to_s 方法僅在需要時產生。但列舉的記憶體和速度效率高,而且使用和偵錯也很方便(例如,列印它們時會獲得名稱而不是數字),這一點很好。

Proc

Proc 是一個帶有可選閉包資料資訊的函式指標。例如

f = ->(x : Int32) { x + 1 }

這是一個接收 Int32 並傳回 Int32 的函式指標。由於它不捕捉任何本機變數,因此它不是閉包。但是編譯器仍然像這樣表示它

%"->" = type { i8*, i8* }

這是一對指標:一個包含指向實際函式的指標,另一個包含指向封閉資料的指標。

上面的 LLVM IR 程式碼是

; ModuleID = 'main_module'

%String = type { i32, i32, i32, i8 }
%"->" = type { i8*, i8* }

@symbol_table = global [0 x %String*] zeroinitializer

define %"->" @__crystal_main(i32 %argc, i8** %argv) {
alloca:
  %f = alloca %"->"
  %0 = alloca %"->"
  br label %entry

entry:                                            ; preds = %alloca
  %1 = getelementptr inbounds %"->"* %0, i32 0, i32 0
  store i8* bitcast (i32 (i32)* @"~fun_literal_1" to i8*), i8** %1
  %2 = getelementptr inbounds %"->"* %0, i32 0, i32 1
  store i8* null, i8** %2
  %3 = load %"->"* %0
  store %"->" %3, %"->"* %f
  ret %"->" %3
}

declare i32 @printf(i8*, ...)

define i32 @main(i32 %argc, i8** %argv) {
entry:
  %0 = call %"->" @__crystal_main(i32 %argc, i8** %argv)
  ret i32 0
}

define internal i32 @"~fun_literal_1"(i32 %x) {
entry:
  %0 = add i32 %x, 1
  ret i32 %0
}

比上面的範例更難以理解,但它基本上是在第一個位置為 ~fun_literal_1 指定一個指標,在第二個位置指定 null。如果我們的 Proc 捕捉到本機變數

a = 1
f = ->(x : Int32) { x + a }

LLVM IR 程式碼會變更

; ModuleID = 'main_module'

%String = type { i32, i32, i32, i8 }
%"->" = type { i8*, i8* }
%closure = type { i32 }

@symbol_table = global [0 x %String*] zeroinitializer

define %"->" @__crystal_main(i32 %argc, i8** %argv) {
alloca:
  %f = alloca %"->"
  %0 = alloca %"->"
  br label %entry

entry:                                            ; preds = %alloca
  %malloccall = tail call i8* @malloc(i32 ptrtoint (i32* getelementptr (i32* null, i32 1) to i32))
  %1 = bitcast i8* %malloccall to %closure*
  %a = getelementptr inbounds %closure* %1, i32 0, i32 0
  store i32 1, i32* %a
  %2 = bitcast %closure* %1 to i8*
  %3 = getelementptr inbounds %"->"* %0, i32 0, i32 0
  store i8* bitcast (i32 (i8*, i32)* @"~fun_literal_1" to i8*), i8** %3
  %4 = getelementptr inbounds %"->"* %0, i32 0, i32 1
  store i8* %2, i8** %4
  %5 = load %"->"* %0
  store %"->" %5, %"->"* %f
  ret %"->" %5
}

declare i32 @printf(i8*, ...)

declare noalias i8* @malloc(i32)

define i32 @main(i32 %argc, i8** %argv) {
entry:
  %0 = call %"->" @__crystal_main(i32 %argc, i8** %argv)
  ret i32 0
}

define internal i32 @"~fun_literal_1"(i8*, i32 %x) {
entry:
  %1 = bitcast i8* %0 to %closure*
  %a = getelementptr inbounds %closure* %1, i32 0, i32 0
  %2 = bitcast i8* %0 to %closure*
  %3 = load i32* %a
  %4 = add i32 %x, %3
  ret i32 %4
}

這更難以理解,但基本上會要求一些記憶體來包含變數 a 的值,並且 Proc 會接收並使用它。在這種情況下,記憶體是使用 malloc 要求的,但是使用常規的前導檔案,記憶體將由 GC 配置,並在不再需要時釋放。

Class

類別也是物件

x = Int32

毫不奇怪,類別表示為 Int32

%x = alloca i32
...
store i32 45, i32* %x

由於類別無法在執行時建立,而且編譯器知道所有類別,因此它會為它們指定一個類型 ID,並以此方式識別它們。

使用者定義類型

使用者可以定義類別和結構。差異在於,建立一個類別會在堆積上配置它,並且指向該資料的指標會在變數和方法之間傳遞,而建立一個結構則會在堆疊上配置該記憶體,並且整個結構的值會在變數和方法之間傳遞、複製。

讓我們嘗試一下

class Point
  def initialize(@x, @y)
  end
end

x = Point.new(1, 2)

LLVM IR 程式碼包含

%Point = type { i32, i32, i32 }
...
%x = alloca %Point*

嗯...等等!Point 只有兩個實例變數,@x@y,都是 Int32 類型,那麼為什麼還有另一個 i32 在那裡?嗯,事實證明,Crystal 會新增一個 Int32 來儲存與類別關聯的類型 ID。這現在沒有多大意義,但是當我們談論聯合如何表示時,它會更有意義。

讓我們看看結構的相同情況

struct Point
  def initialize(@x, @y)
  end
end

x = Point.new(1, 2)

LLVM IR 程式碼包含

%Point = type { i32, i32 }
...
%x = alloca %Point

在這種情況下,結構體不包含用於類型 ID 的額外 Int32 欄位。

現在進入有趣的部分:聯合!

聯合

Crystal 支援任意類型的聯合。例如,您可以有一個變數,其值可以是 Int32 或 Bool。

if 1 == 2
  x = 3
else
  x = false
end

if 語句的結尾,變數 x 的值將會是 3false,這使其類型為 Int32 或 Bool。Crystal 使用管道符號來表示聯合,像是這樣:Int32 | Bool。在 LLVM IR 程式碼中,我們可以找到

%"(Int32 | Bool)" = type { i32, [1 x i64] }
...
%x = alloca %"(Int32 | Bool)"

我們可以看到,這個特定聯合的表示是一個包含兩個欄位的 LLVM 結構。第一個欄位會包含值的類型 ID。第二個欄位是值本身,它是一個位元陣列,其大小與該聯合中最大的類型一樣大(由於一些對齊考量,在 64 位元架構中,大小會擴展到 64 位元的邊界)。在 C 語言中會是這樣:

struct Int32OrBool {
  int type_id;
  union {
    int int_value;
    bool bool_value;
  };
}

當您在 x 上呼叫方法時,編譯器會使用第一個欄位,即類型 ID。

所以,到這裡,關於聯合類型如何表示的故事似乎就結束了。然而,有一些聯合非常常見:可為空的類型。

我們之前沒有談論過 Nil,但由於它只能包含單一值,而且您不能將 void 用於值,因此它會被表示為 i1。

x = nil # %x = alloca i1

現在讓我們建立一個 nil 和類別的聯合

if 1 == 2
  x = nil
else
  x = Point.new(1, 2)
end

如果我們檢查 LLVM IR 程式碼,我們會看到 x 的情況如下:

%x = alloca %Point*

所以,Point | Nil 的聯合,其中 Point 是一個類別,其表示方式與 Point 類別相同。我們如何判斷 x 是 Nil 還是 Point?很簡單:空指標表示它是 Nil,非空指標表示它是 Point。

事實上,所有僅涉及類別和/或 nil 的聯合都總是表示為單一指標。如果是空指標,則表示它是 Nil。否則,如果聯合包含許多可能的類別,我們可以使用值的第一個成員(Int32,還記得嗎?)來知道類型。將所有這些聯合表示為指標,可以使程式碼更加有效率,因為指標可以放入暫存器中,並且佔用非常少的記憶體。

然而,Nil 和結構體的聯合總是會被表示為標記聯合,就像 Int32 | Bool 的情況一樣。但這些聯合比較不常見。

現在我們了解了類型是如何表示的,以及在執行時我們如何知道聯合中包含的類型,讓我們來談談方法分派。

方法分派

儘管 Crystal 是物件導向的,但方法查找和分派的工作方式與其他物件導向語言非常不同。例如,沒有虛擬表,也沒有為類型儲存任何元數據(除了我們之前談到的類型 ID 欄位)。我們嘗試盡可能減少程式運作所需的執行時期資料,並盡可能提高其執行速度,有時會犧牲產生的二進制檔案大小(但二進制檔案大小也不會增加太多)。例如,讓我們考慮這個類別階層:

module Moo
  def foo
    1
  end
end

class Foo
  def foo
    2
  end
end

class Bar < Foo
  include Moo
end

class Baz < Bar
end

Baz.new.foo #=> 1

哇,一個龐大的類別階層,甚至包含了一個模組,而且有兩個 foo 的定義。看看程式碼,您能知道在這種情況下會呼叫哪個 foo 方法嗎?

嗯,是 Moo#foo,對吧?是的,的確是。事實證明,編譯器也知道這一點,如果您查看產生的程式碼,您會看到類似這樣的內容:

; Create a Bar
%0 = call %Baz* @"*Baz::new:Baz"()
; Invoke Moo#foo: no method lookup
%1 = call i32 @"*Baz@Moo#foo<Baz>:Int32"(%Baz* %0)

...

define internal i32 @"*Baz@Moo#foo<Baz>:Int32"(%Baz* %self) {
entry:
  ret i32 1
}

如果我們建立一個 Bar 的實例,並在它上面呼叫 foo,會發生什麼事?

Bar.new.foo
Baz.new.foo

現在 LLVM IR 程式碼包含這個:

%0 = call %Bar* @"*Bar::new:Bar"()
%1 = call i32 @"*Bar@Moo#foo<Bar>:Int32"(%Bar* %0)
%2 = call %Baz* @"*Baz::new:Baz"()
%3 = call i32 @"*Baz@Moo#foo<Baz>:Int32"(%Baz* %2)
...
define internal i32 @"*Bar@Moo#foo<Bar>:Int32"(%Bar* %self) {
entry:
  ret i32 1
}

define internal i32 @"*Baz@Moo#foo<Baz>:Int32"(%Baz* %self) {
entry:
  ret i32 1
}

糟糕,那裡不是有重複的 foo 定義嗎?嗯,是的。您可以認為編譯器將 foo 的定義複製到每個類別中,因此,實際上會有許多相同方法的副本。但這並不重要:大多數方法都不大,而且方法呼叫速度更重要。此外,小型方法在最佳化建置中無論如何都會被內聯,甚至還有一個 LLVM 轉換傳遞來偵測重複的函數並將其合併。

當然,如果 Moo#foo 呼叫一個實例方法或使用一個實例變數,情況會稍微改變。在這種情況下,「重複」的方法實際上會有所不同,同樣地,就好像我們將每個方法定義複製到最終包含它的每個類型中一樣。這使得方法呼叫盡可能有效率,代價是(可能)增加了可執行檔的大小。但最終使用者通常更關心速度而不是可執行檔的大小。

以上所有這些之所以可能,是因為編譯器知道 Bar.new 的確切類型。如果編譯器不知道這一點,會發生什麼事?讓我們從一個簡單的聯合開始,其中的類型不是同一個階層中的類別:

class Foo
  def foo
    1
  end
end

class Bar
  def foo
    1
  end
end

if 1 == 2
  obj = Foo.new
else
  obj = Bar.new
end
obj.foo

這次編譯器將會產生大致執行以下動作的程式碼:在 obj 上呼叫 foo 之前,檢查 obj 的類型。這可以透過載入代表物件的指標的第一個欄位(類型 ID)來得知。然後根據這個來呼叫一個方法或另一個方法。這個決策只需要一個記憶體載入和一個比較:非常有效率。對於較大的聯合,仍然會是一個記憶體載入,或只是讀取聯合的類型 ID 欄位,然後進行多次比較。但是...查閱表不是更快嗎?

嗯,事實證明 LLVM 非常聰明,當它偵測到多次比較時,有時可以為我們建立查閱表。為了讓這個效果更好,查閱表中的數字必須彼此接近(想像一下,一個值為 1 和 1000000 的查閱表,它會佔用大量的空間,因此 LLVM 在這種情況下會決定進行比較)。幸運的是,我們以一種有助於 LLVM 實現這一目標的方式來分配類型 ID。

當我們說 大型 聯合 時,這個聯合很可能包含相同階層的類別:您通常會建立一個類別階層,以使所有類型都遵循一定的規則,並回應類似的一組方法。儘管您可以在沒有類別階層的情況下執行此操作,但它們是組織程式碼的非常常見的方法。

考慮這個類別階層:

class Foo; end
class Bar < Foo; end
class Baz < Bar; end
class Qux < Bar; end

僅考慮這些類型,編譯器會以後序方式分配類型 ID:首先 Baz 被分配 1,然後 Qux 被分配 2,然後 Bar 被分配 3,最後 Foo 被分配 4。此外,編譯器會追蹤類型的子類型(包括它自身)的類型 ID 範圍,因此對於 Bar,它還會分配 1-3 的範圍,對於 Foo,它會分配 1-4 的範圍。

現在,考慮這個:

class Foo
  def foo
    1
  end
end

class Bar < Foo
  def foo
    2
  end
end

class Baz < Bar; end
class Qux < Bar; end

obj = # ... a union of the above types
obj.foo

首先,編譯器會將 obj 的類型設為 Foo+,這表示它可以是 Foo 或其子類別之一(在這裡閱讀更多關於此的資訊:這裡)。在這種情況下,只會有兩種不同的方法實例化:一種用於 Foo+,另一種用於 Bar+,因為 Baz 和 Qux 沒有重新定義該方法。為了知道我們需要呼叫哪一個,我們載入類型 ID。然後,不必說「如果類型 ID 是 Bar、Baz 或 Qux 的 ID,則呼叫 Bar#foo,否則呼叫 Foo#foo」,我們可以簡單地檢查類型 ID 是否在先前分配給 Bar 的範圍(1-3)內:只需要兩次比較。

這種範圍檢查也適用於 is_a?。當您執行 obj.is_a?(Foo),而且 obj 可能是 Int32、Foo 或其子類別之一時,我們可以透過最多兩次比較來解決此問題。

最後,Crystal 的一個有趣之處是,方法分派是根據可能的多種類型發生的:

def foo(x : Int32, y : Char)
end

def foo(x : Char, y : Int32)
end

def foo(x, y)
end

foo 1, 'a'

如果所有引數的類型在編譯時未知,這也適用。但是...這篇部落格文章現在變得有點長而且複雜了:我們對您的程式碼應用了更多的微最佳化,以使其盡可能有效率。因此,請不要害怕充分利用 Crystal 的潛力 :-)