內部構造
讓我們來談談 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。第三,我們可以在頂部看到一些常數:hello
、bye
和 symbol_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
的值將會是 3
或 false
,這使其類型為 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 的潛力 :-)