跳到内容
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

分配

分配原语为你提供了未初始化的对象实例。

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 代码时,编译器将用一个可以是 Int32 或 String 的联合类型表示 a。在 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 模仿了这种行为。因此,变量只是我们分配给它的最后一个表达式的名称。

如果

让我们看一下一段 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 中一样)。我们认为将其作为编译时错误更好。在某些情况下,你可能比编译器更了解情况,并且你确定变量具有你认为的类型。但在某些情况下,编译器会让你知道你忽略了一个情况或一些逻辑,你将感谢它。

The if 有更多情况需要考虑。例如,变量 a 可能在 if 之前不存在。在这种情况下,如果它没有在其中一个分支中被分配,在 if 结束时,如果它被读取,它也将包含 Nil 类型。

if some_condition
  a = 1
end
a # here a is Int32 or Nil

这再次模仿了 Ruby 的行为。

最后,if 的类型是两个分支中最后一个表达式的联合。如果一个分支丢失,则认为它具有 Nil 类型。

A while 在某种程度上类似于 if

a = 1
while some_condition
  a = "hello"
end
a # here a is Int32 or String

这是因为 some_condition 第一次可能为假。

然而,由于 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 内部需要考虑的其他一些事情是 breaknext。A 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

A 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,则会执行它,但正常路径不会被执行)。

编译器知道 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 的类型是什么(以及 a 变量的类型)?

a = if some_condition
      raise "Boom!"
    else
      1
    end
a # a is...?

那么,then 分支肯定是 NoReturn。而 else 分支肯定是 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。还有一点我们还没有提到,当你对一个联合类型调用方法时,如果所有类型都响应该方法,那么结果类型将是每个方法类型结果的联合类型。

在这种情况下,a.not_nil! 的类型将是 Int32,如果 aInt32,或者 NoReturn,如果它是 Nil(由于 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 中始终能正常工作(在运行时不会引发“undefined method”错误)一样,它在 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 中也是如此。

我们还需要讨论方法和实例变量,但这篇文章已经足够长了,这些内容将在下一篇文章中解释。敬请期待!