跳至内容
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 告诉编译器不要使用 默认 prelude 文件,例如,初始化 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 位表示的整数,这同样非常有效率,也是我们期望 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 的调用(将使用常规的 prelude 从 GC 请求内存)和 memset 来清除内存,然后是一些指令来在该内存地址中分配 1。这对于这篇博文的主题并不重要,所以我们跳过它,但重要的是要知道生成的代码与在 C 中生成的代码非常相似。

元组

元组是一个大小固定、不可变的值序列,每个位置的类型在编译时已知。

x = {1, true}

上面代码的 LLVM IR 代码片段是

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

正如我们所见,元组被表示为一个 LLVM 结构体,它只是按顺序打包值。这种元组表示允许我们例如将一个 Int32 分解为它的字节,像这样

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

静态数组

静态数组是一个大小固定、可变的相同类型的值序列,在堆栈上分配,并按值传递。prelude 包含创建它们的安全的几种方法,但由于我们使用的是一个精简的 prelude,所以创建它们的非安全方式(将初始化为包含垃圾的数据)是这样的

x = uninitialized Int32[8]

它的 LLVM 表示

%x = alloca [8 x i32]

我们不会过多讨论这种类型,因为它没有被广泛使用,主要用于 IO 缓冲区等等:对于所有其他操作,建议使用 Array 类型。

枚举

这里有一个枚举

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 方法只有在需要时才会生成。但枚举的优点是,它在内存和速度方面都非常高效,而且使用和调试都很方便(例如,在打印时你会得到名称而不是数字)。

过程

过程是一个函数指针,带有可选的闭包数据信息。例如

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 分配给第二个位置。如果我们的过程捕获了一个局部变量

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 的值,并且过程接收它并使用它。在这种情况下,内存是通过 malloc 请求的,但使用常规的 prelude,内存将由 GC 分配,并在不再需要时释放。

类也是对象

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;
  };
}

第一个字段,类型 ID,将在您在 x 上调用方法时被编译器使用。

所以,似乎关于联合类型如何表示的故事就到此为止了。但是,有一些联合非常常见:可空类型。

我们之前没有谈论 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。

当我们说 big unions 时,很可能该联合包含同一层次结构中的类:您通常构建一个类层次结构以使所有类型都遵循特定规则,对一组类似的方法做出响应。虽然您可以在没有类层次结构的情况下执行此操作,但它们是构建代码的一种非常常见的方式。

考虑以下类层次结构

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 的范围内(1-3):只需两个比较,而不是必须说“如果类型 ID 是 Bar、Baz 或 Qux,则调用 Bar#foo,否则调用 Foo#foo”。

此范围检查也适用于 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 的潜力 :-)