Crystal 指南

标签: crystal ;


关于本指南

这是 Crystal 语言的正式规范。

你可以从头到尾阅读本文档,但建议跳读各个部分,因为某些概念是相互关联的,无法孤立地解释。

语言入门教程为初学者提供了更集中的学习体验。

在代码示例中,以 # => 开头的注释显示表达式的值。

1 + 2 # => 3

# : 开头的注释显示表达式的类型。

"hello" # : String

程序(Program)

程序是编译器处理的全部源代码。源代码被解析并编译为程序的可执行版本。

程序的源代码必须使用 UTF-8 编码。

顶层作用域(Top-level Scope)

在任何其他命名空间之外定义的类型、常量、宏和方法等特性都位于顶层作用域。

# 在顶层作用域中定义一个方法
def add(x, y)
  x + y
end

# 在顶层作用域中调用 add 方法
add(1, 2) # => 3

顶层作用域中的局部变量是文件局部的,在方法体内不可见。

x = 1

def add(y)
  x + y # 错误:未定义的局部变量或方法 'x'
end

add(2)

私有特性也仅在当前文件中可见。

双冒号前缀(::)明确引用顶层作用域中的命名空间、常量、方法或宏:

def baz
  puts "::baz"
end

CONST = "::CONST"

module A
  def self.baz
    puts "A.baz"
  end

  # 没有前缀,解析为本地作用域中的方法
  baz

  # 使用 :: 前缀,解析为顶层作用域中的方法
  ::baz

  CONST = "A::Const"

  p! CONST   # => "A::CONST"
  p! ::CONST # => "::CONST"
end

主代码(Main Code)

任何不是方法、宏、常量或类型定义,也不在方法或宏体中的表达式,都是主代码的一部分。主代码在程序启动时按照源文件的包含顺序执行。

主代码不需要使用特殊的入口点(例如 main 方法)。

# 这是一个打印 "Hello Crystal!" 的程序
puts "Hello Crystal!"

主代码也可以位于命名空间内:

# 这是一个打印 "Hello" 的程序
class Hello
  # 这里的 'self' 是 Hello 类
  puts self
end

注释

注释以 # 字符开头。从 # 开始到行末的所有内容都是注释的一部分。注释可以单独成行,也可以跟在 Crystal 表达式之后(尾随注释)。

# 这是一条注释
puts "hello" # 这是一条尾随注释

注释的目的是为代码提供文档。公共文档(包括自动生成的 API 文档)是一种基于注释的特殊功能,其详细说明在文档化代码部分中描述。

代码文档化

API 功能的文档可以直接写在相应功能定义之前的代码注释中。

默认情况下,所有公共方法、宏、类型和常量都被视为 API 文档的一部分。

提示: 编译器命令 crystal docs 会自动提取 API 文档并生成一个网站来展示这些文档。

关联

文档注释必须直接位于所记录功能定义的上方。连续的注释行会被合并为一个注释块。任何空行都会中断与所记录功能的关联。

# 这行注释与下面的类无关

# Unicorn 类的第一行注释
# Unicorn 类的第二行注释
class Unicorn
end

格式

文档注释支持 Markdown 格式。

文档注释的第一段被视为其摘要。它应简洁地定义功能的目的和作用。

补充细节和使用说明应在后续段落中提供。

例如:

# Returns the number of horns this unicorn has.
#
# Always returns `1`.
def horns
  1
end

标记

链接

对其他 API 功能的引用可以用单反引号括起来。它们会自动解析并转换为指向相应功能的链接。

class Unicorn
  # Creates a new `Unicorn` instance.
  def initialize
  end
end

与 Crystal 代码中的规则相同,当前文档命名空间中的功能可以使用相对名称访问:

  • 实例方法通过#前缀进行引用:#horns.

  • 类方法通过.前缀进行引用:.new.

  • 常量和类型通过它们自己的名字进行引用:Unicorn.

其他命名空间中的功能需要使用完全限定的路径来引用,例如:Unicorn#hornsUnicorn.newUnicorn::CONST

参数

当引用参数时,建议将其名称用斜体表示(斜体):

# Creates a unicorn with the specified number of *horns*.
def initialize(@horns = 1)
  raise "Not a unicorn" if @horns != 1
end

代码示例

代码示例可以放在 Markdown 代码块中。如果没有指定语言标签,代码块默认被视为 Crystal 代码。

# Example:
# ```
# unicorn = Unicorn.new
# unicorn.horns # => 1
# ```
class Unicorn
end

要将代码块指定为纯文本,必须显式地添加标签。

# Output:
# ```plain
# "I'm a unicorn"
# ```
def say
  puts "I'm a unicorn"
end

也可以使用其他语言标签。

要在代码块中显示表达式的值,可以使用 # =>

1 + 2             # => 3
Unicorn.new.speak # => "I'm a unicorn"

提示

支持多种提示关键字,用于在视觉上突出显示问题、注意事项和/或潜在问题。

  • BUG

  • DEPRECATED

  • EXPERIMENTAL

  • FIXME

  • NOTE

  • OPTIMIZE

  • TODO

  • WARNING

提示关键字必须是其所在行的第一个单词,并且必须全部大写。为了可读性,建议在关键字后添加一个可选的冒号。

# Makes the unicorn speak to STDOUT
#
# NOTE: Although unicorns don't normally talk, this one is special
# TODO: Check if unicorn is asleep and raise exception if not able to speak
# TODO: Create another `speak` method that takes and prints a string
def speak
  puts "I'm a unicorn"
end

# Makes the unicorn talk to STDOUT
#
# DEPRECATED: Use `speak`
def talk
  puts "I'm a unicorn"
end

编译器会隐式地向文档注释中添加一些提示:

  • @[Deprecated] 注解会添加一个 DEPRECATED 提示。

  • @[Experimental] 注解会添加一个 EXPERIMENTAL 提示。

指令

指令(Directives)用于告诉文档生成器如何处理特定功能的文档。

ditto

如果两个连续定义的功能具有相同的文档,可以使用 :ditto: 来复制前一个定义的文档注释。

# Returns the number of horns.
def horns
  horns
end

# :ditto:
def number_of_horns
  horns
end

指令需要单独占一行,但可以在其他行中添加进一步的文档。:ditto: 指令会被前一个文档注释的内容直接替换。

nodoc

可以使用 :nodoc: 指令将公共功能从 API 文档中隐藏。私有和受保护的功能始终会被隐藏。

# :nodoc:
class InternalHelper
end

该指令必须是文档注释的第一行。开头的空格是可选的。后续的注释行可以用于内部文档。

inherit

参考继承文档.

继承文档

当一个实例方法没有文档注释,但在父类型中存在具有相同签名的方法时,文档会从父方法继承。例如:

abstract class Animal
  # Returns the name of `self`.
  abstract def name : String
end

class Unicorn < Animal
  def name : String
    "unicorn"
  end
end

Unicorn#name的文档将会是:

Description copied from class `Animal`

Returns the name of `self`.

子方法可以使用 :inherit: 显式复制父方法的文档,而不会显示“Description copied from ...”的文本。:inherit: 还可以用于将父方法的文档注入到子方法的附加文档中。

abstract class Parent
  # Some documentation common to every *id*.
  abstract def id : Int32
end

class Child < Parent
  # Some documentation specific to *id*'s usage within `Child`.
  #
  # :inherit:
  def id : Int32
    -1
  end
end

Child#id的文档将会是:

Some documentation specific to *id*'s usage within `Child`.

Some documentation common to every *id*.

注意 继承文档仅适用于实例、非构造函数方法。

一个完整的示例

# A unicorn is a **legendary animal** (see the `Legendary` module) that has been
# described since antiquity as a beast with a large, spiraling horn projecting
# from its forehead.
#
# To create a unicorn:
#
# ```
# unicorn = Unicorn.new
# unicorn.speak
# ```
#
# The above produces:
#
# ```text
# "I'm a unicorn"
# ```
#
# Check the number of horns with `#horns`.
class Unicorn
  include Legendary

  # Creates a unicorn with the specified number of *horns*.
  def initialize(@horns = 1)
    raise "Not a unicorn" if @horns != 1
  end

  # Returns the number of horns this unicorn has
  #
  # ```
  # Unicorn.new.horns # => 1
  # ```
  def horns
    @horns
  end

  # :ditto:
  def number_of_horns
    horns
  end

  # Makes the unicorn speak to STDOUT
  def speak
    puts "I'm a unicorn"
  end

  # :nodoc:
  class Helper
  end
end

字面量

Crystal提供了几个字面值来创建一些基本类型的值。

Literal Sample values
Nil nil
Bool true, false
Integers 18, -12, 19_i64, 14_u32, 64_u8
Floats 1.0, 1.0_f32, 1e10, -0.5
Char 'a', '\n', 'あ'
String "foo\tbar", %("あ"), %q(foo #{foo})
Symbol :symbol, :"foo bar"
Array [1, 2, 3], [1, 2, 3] of Int32, %w(one two three)
Array-like Set{1, 2, 3}
Hash {"foo" => 2}, {} of String => Int32
Hash-like MyType{"foo" => "bar"}
Range 1..9, 1...10, 0..var
Regex /(foo)?bar/, /foo #{foo}/imx, %r(foo/)
Tuple {1, "hello", 'x'}
NamedTuple {name: "Crystal", year: 2011}, {"this is a key": 1}
Proc ->(x : Int32, y : Int32) { x + y }
Command echo foo, %x(echo foo)

Nil

Nil 类型用于表示值的缺失,类似于其他语言中的 null。它只有一个值:

nil

Bool

Bool类只有两个值:truefalse。它们是使用下面的字面量构造的:

true  # A Bool that is true
false # A Bool that is false

整数

有五种带符号整数类型和五种无符号整数类型:

Type Length Minimum Value Maximum Value
Int8 8 -128 127
Int16 16 −32,768 32,767
Int32 32 −2,147,483,648 2,147,483,647
Int64 64 −263 263 - 1
Int128 128 −2127 2127 - 1
UInt8 8 0 255
UInt16 16 0 65,535
UInt32 32 0 4,294,967,295
UInt64 64 0 264 - 1
UInt128 128 0 2128 - 1

整数字面量由一个可选的 +- 符号,后跟一串数字和下划线组成,可选地后跟一个后缀。如果没有后缀,且值在 Int32 的范围内,则字面量的类型为 Int32,否则为 Int64。超出 Int64 范围的整数必须带有后缀:

1 # Int32

1_i8   # Int8
1_i16  # Int16
1_i32  # Int32
1_i64  # Int64
1_i128 # Int128

1_u8   # UInt8
1_u16  # UInt16
1_u32  # UInt32
1_u64  # UInt64
1_u128 # UInt128

+10 # Int32
-20 # Int32

2147483647  # Int32
2147483648  # Int64
-2147483648 # Int32
-2147483649 # Int64

9223372036854775807     # Int64
9223372036854775808_u64 # UInt64

不带后缀的整数字面量,如果其值大于 Int64 的最大值但仍在 UInt64 的范围内,已被弃用,例如 9223372036854775808

后缀前的下划线 _ 是可选的。

下划线可用于使某些数字更具可读性:

1_000_000 # better than 1000000

二进制数字以0b开头

0b1101 # == 13

八进制数字以0o开头

0o123 # == 83

十六进制数字以0x开头

0xFE012D # == 16646445
0xfe012d # == 16646445

浮点数

有两种浮点类型:Float32Float64,它们分别对应 IEEE 定义的 binary32binary64 类型。

浮点数字面量由一个可选的 +- 符号,后跟一串数字或下划线,接着是一个点,然后是数字或下划线,后跟一个可选的指数后缀,最后是一个可选的类型后缀。如果没有后缀,字面量的类型为 Float64

1.0     # Float64
1.0_f32 # Float32
1_f32   # Float32

1e10   # Float64
1.5e10 # Float64
1.5e-7 # Float64

+1.3 # Float64
-0.5 # Float64

后缀前的下划线 _ 是可选的。

下划线可用于使某些数字更具可读性:

1_000_000.111_111 # 可读性比 1000000.111111 好很多

字符

Char代表一个32位的Unicode码点

它通常是通过将UTF-8字符括在单引号中,用char字面量创建的。

'a'
'z'
'0'
'_'
'あ'

反斜杠表示一个特殊字符,它可以是一个命名的转义序列,也可以是unicode代码点的数字表示。

可用的转义序列

'\''         # single quote
'\\'         # backslash
'\a'         # alert
'\b'         # backspace
'\e'         # escape
'\f'         # form feed
'\n'         # newline
'\r'         # carriage return
'\t'         # tab
'\v'         # vertical tab
'\0'         # null character
'\uFFFF'     # hexadecimal unicode character
'\u{10FFFF}' # hexadecimal unicode character

反斜杠后跟一个 u 表示一个 Unicode 码点。它可以后跟恰好四个十六进制字符表示 Unicode 字节(\u0000\uFFFF),或者用花括号包裹的一到六个十六进制字符(\u{0}\u{10FFFF})。

'\u0041'    # => 'A'
'\u{41}'    # => 'A'
'\u{1F52E}' # => '&#x1F52E;'

字符串

String 表示一个不可变的 UTF-8 字符序列。

String 通常通过用双引号(")包裹 UTF-8 字符的字符串字面量来创建:

"hello world"

转义

反斜杠表示字符串中的特殊字符,它可以是命名转义序列或unicode代码点的数字表示。

可用的转义序列

"\""                  # double quote
"\\"                  # backslash
"\#"                  # hash character (to escape interpolation)
"\a"                  # alert
"\b"                  # backspace
"\e"                  # escape
"\f"                  # form feed
"\n"                  # newline
"\r"                  # carriage return
"\t"                  # tab
"\v"                  # vertical tab
"\377"                # octal ASCII character
"\xFF"                # hexadecimal ASCII character
"\uFFFF"              # hexadecimal unicode character
"\u{0}".."\u{10FFFF}" # hexadecimal unicode character

反斜杠后面的任何其他字符都被解释为字符本身。

反斜杠后跟最多三个 0 到 7 的数字表示一个以八进制编写的码点:

"\101" # => "A"
"\123" # => "S"
"\12"  # => "\n"
"\1"   # string with one character with code point 1

反斜杠后跟一个 u 表示一个 Unicode 码点。它可以后跟恰好四个十六进制字符表示 Unicode 字节(\u0000\uFFFF),或者用花括号包裹的一到六个十六进制字符(\u{0}\u{10FFFF})。

"\u0041"    # => "A"
"\u{41}"    # => "A"
"\u{1F52E}" # => "&#x1F52E;"

一个花括号可以包含多个unicode字符,每个字符由空格分隔。

"\u{48 45 4C 4C 4F}" # => "HELLO"

插值

带有插值的字符串字面量允许将表达式嵌入字符串中,这些表达式将在运行时被展开。

a = 1
b = 2
"sum: #{a} + #{b} = #{a + b}" # => "sum: 1 + 2 = 3"

字符串插值也可以通过 String#% 实现。

任何表达式都可以放在插值部分中,但为了可读性,最好保持表达式简洁。

可以通过用反斜杠转义 # 字符或使用非插值字符串字面量(如 %q())来禁用插值。

"\#{a + b}"  # => "#{a + b}"
%q(#{a + b}) # => "#{a + b}"

插值是通过使用 String::Builder 并对 #{...} 包裹的每个表达式调用 Object#to_s(IO) 来实现的。表达式 "sum: #{a} + #{b} = #{a + b}" 等价于:

String.build do |io|
  io << "sum: "
  io << a
  io << " + "
  io << b
  io << " = "
  io << a + b
end

百分比字符串字面量

除了双引号字符串外,Crystal 还支持以百分号(%)和一对分隔符表示的字符串字面量。有效的分隔符包括圆括号 ()、方括号 []、花括号 {}、尖括号 <> 和竖线 ||。除了竖线外,所有分隔符都可以嵌套,这意味着字符串中的起始分隔符会转义下一个结束分隔符。

这些字符串字面量非常适合编写包含双引号的字符串,而在双引号字符串中,这些双引号必须被转义。

%(hello ("world")) # => "hello (\"world\")"
%[hello ["world"]] # => "hello [\"world\"]"
%{hello {"world"}} # => "hello {\"world\"}"
%<hello <"world">> # => "hello <\"world\">"
%|hello "world"|   # => "hello \"world\""

%q 表示的字符串字面量既不应用插值也不处理转义,而 %Q% 的含义相同。

name = "world"
%q(hello \n #{name}) # => "hello \\n \#{name}"
%Q(hello \n #{name}) # => "hello \n world"

百分比字符串数组字面量

除了单个字符串字面量外,还有一种百分号字面量用于创建字符串数组。它由 %w 和一对分隔符表示。有效的分隔符与百分号字符串字面量相同。

%w(foo bar baz)  # => ["foo", "bar", "baz"]
%w(foo\nbar baz) # => ["foo\\nbar", "baz"]
%w(foo(bar) baz) # => ["foo(bar)", "baz"]

请注意,以 %w 表示的字符串字面量不应用插值,也不处理转义(空格除外)。由于字符串由单个空格字符( )分隔,因此必须将其转义才能用作字符串的一部分。

%w(foo\ bar baz) # => ["foo bar", "baz"]

多行字符串

所有字符串字面量都可以跨越多行

"hello
      world" # => "hello\n      world"

请注意,在上面的示例中,尾随和前导空格以及换行符会包含在结果字符串中。为了避免这种情况,可以通过使用反斜杠连接多个字面量来将字符串拆分为多行:

"hello " \
"world, " \
"no newlines" # same as "hello world, no newlines"

或者,可以在字符串字面量中插入反斜杠后跟换行符:

"hello \
     world, \
     no newlines" # same as "hello world, no newlines"

在这种情况下,结果字符串中不包括前导空格。

Heredoc

Here 文档(heredoc)对于编写跨越多行的字符串非常有用。Here 文档由 <<- 后跟一个 heredoc 标识符表示,该标识符是以字母开头的字母数字序列(可以包含下划线)。Here 文档从下一行开始,并在仅包含 heredoc 标识符的行结束,该行前面可以有空格。

<<-XML
<parent>
  <child />
</parent>
XML

根据 heredoc 标识符前最后一行的空格数量,会从 heredoc 内容中移除前导空格。

<<-STRING # => "Hello\n  world"
  Hello
    world
  STRING

<<-STRING # => "  Hello\n    world"
    Hello
      world
  STRING

在 heredoc 标识符之后,且在同一行中,后续内容会继续 heredoc 之前的原始表达式。就好像起始 heredoc 标识符的末尾是字符串的末尾。然而,字符串内容在后续行中,直到结束 heredoc 标识符,该标识符必须独占一行。

<<-STRING.upcase # => "HELLO"
hello
STRING

def upcase(string)
  string.upcase
end

upcase(<<-STRING) # => "HELLO WORLD"
  Hello World
  STRING

如果多个 heredoc 在同一行中开始,它们的内容会按顺序读取:

print(<<-FIRST, <<-SECOND) # prints "HelloWorld"
  Hello
  FIRST
  World
  SECOND

Here 文档通常允许插值和转义。

要表示没有插值或转义的 heredoc,起始 heredoc 标识符需要用单引号括起来:

<<-'HERE' # => "hello \\n \#{world}"
  hello \n #{world}
  HERE

符号

Symbol 表示整个源代码中的唯一名称。

Symbol 在编译时被解释,不能动态创建。创建 Symbol 的唯一方法是使用符号字面量,符号字面量由冒号(:)后跟一个标识符表示。标识符可以选择用双引号(")括起来。

:unquoted_symbol
:"quoted symbol"
:"a" # identical to :a
:あ

双引号标识符可以包含任何 Unicode 字符,包括空格,并接受与字符串字面量相同的转义序列,但不支持插值。

对于未加引号的标识符,其命名规则与方法相同。它可以包含字母数字字符、下划线(_)或码点大于 159(0x9F)的字符。它不能以数字开头,并且可以以感叹号(!)或问号(?)结尾。

:question?
:exclamation!

所有 Crystal 运算符都可以不加引号地用作符号名称:

:+
:-
:*
:/
:%
:&
:|
:^
:**
:>>
:<<
:==
:!=
:<
:<=
:>
:>=
:<=>
:===
:[]
:[]?
:[]=
:!
:~
:!~
:=~

在内部,符号被实现为具有 Int32 类型数值的常量。

百分比符号数组字面量

除了单个符号字面量外,还有一种百分号字面量用于创建符号数组。它由 %i 和一对分隔符表示。有效的分隔符包括圆括号 ()、方括号 []、花括号 {}、尖括号 <> 和竖线 ||。除了竖线外,所有分隔符都可以嵌套;这意味着字符串中的起始分隔符会转义下一个结束分隔符。

%i(foo bar baz)  # => [:foo, :bar, :baz]
%i(foo\nbar baz) # => [:"foo\nbar", :baz]
%i(foo(bar) baz) # => [:"foo(bar)", :baz]

标识符可以包含任何 Unicode 字符。各个符号由单个空格字符分隔,必须将其转义才能用作标识符的一部分。

%i(foo\ bar baz) # => [:"foo bar", :baz]

数组

Array 是一种有序且通过整数索引的泛型集合,包含特定类型 T 的元素。

数组通常通过数组字面量创建,数组字面量由方括号([])表示,各个元素用逗号(,)分隔。

[1, 2, 3]

泛型类型参数(Generic Type Argument)

数组的泛型类型参数 T 是从字面量中元素的类型推断出来的。当数组的所有元素具有相同类型时,T 等于该类型。否则,T 将是所有元素类型的联合类型。

[1, 2, 3]         # => Array(Int32)
[1, "hello", 'x'] # => Array(Int32 | String | Char)

可以通过在右括号后紧跟 of 和一个类型来显式指定类型。这会覆盖推断的类型,例如可以用于创建一个最初只包含某些类型但稍后可以接受其他类型的数组。

array_of_numbers = [1, 2, 3] of Float64 | Int32 # => Array(Float64 | Int32)
array_of_numbers << 0.5                         # => [1, 2, 3, 0.5]

array_of_int_or_string = [1, 2, 3] of Int32 | String # => Array(Int32 | String)
array_of_int_or_string << "foo"                      # => [1, 2, 3, "foo"]

空数组字面量始终需要指定类型:

[] of Int32 # => Array(Int32).new

百分号数组字面量(Percent Array Literals)

字符串数组和符号数组可以使用百分号数组字面量创建:

%w(one two three) # => ["one", "two", "three"]
%i(one two three) # => [:one, :two, :three]

类数组类型字面量(Array-like Type Literal)

Crystal 支持一种额外的字面量用于数组和类数组类型。它由类型名称后跟用花括号({})括起来的元素列表组成,各个元素用逗号(,)分隔。

Array{1, 2, 3}

这种字面量可以用于任何具有无参构造函数并响应 << 方法的类型。

IO::Memory{1, 2, 3}
Set{1, 2, 3}

对于像 IO::Memory 这样的非泛型类型,这等价于:

array_like = IO::Memory.new
array_like << 1
array_like << 2
array_like << 3

对于像 Set 这样的泛型类型,泛型类型 T 会以与数组字面量相同的方式从元素的类型中推断出来。上述代码等价于:

array_like = Set(typeof(1, 2, 3)).new
array_like << 1
array_like << 2
array_like << 3

类型参数可以显式指定为类型名称的一部分:

Set(Int32){1, 2, 3}

展开操作符(Splat Expansion)

展开操作符可以在数组和类数组字面量中使用,以一次性插入多个值。

[1, *coll, 2, 3]
Set{1, *coll, 2, 3}

唯一的要求是 coll 的类型必须包含 Enumerable。上述代码等价于:

array = Array(typeof(...)).new
array << 1
array.concat(coll)
array << 2
array << 3

array_like = Set(typeof(...)).new
array_like << 1
coll.each do |elem|
  array_like << elem
end
array_like << 2
array_like << 3

在这些情况下,泛型类型参数还会使用 coll 的元素进行推断。

哈希(Hash)

Hash 是一种泛型集合,包含键值对,将类型为 K 的键映射到类型为 V 的值。

哈希通常通过哈希字面量创建,哈希字面量由花括号({ })括起来,内部是用 => 分隔的键值对列表,键值对之间用逗号 , 分隔。

{"one" => 1, "two" => 2}

泛型类型参数(Generic Type Argument)

K 和值 V 的泛型类型参数分别从字面量中键或值的类型推断出来。当所有键或值具有相同类型时,K/V 等于该类型。否则,K/V 将是所有键类型或值类型的联合类型。

{1 => 2, 3 => 4}   # Hash(Int32, Int32)
{1 => 2, 'a' => 3} # Hash(Int32 | Char, Int32)

可以通过在右括号后紧跟 of(用空格分隔)、键类型(K)、=> 分隔符和值类型(V)来显式指定类型。这会覆盖推断的类型,例如可以用于创建一个最初只包含某些类型但稍后可以接受其他类型的哈希。

空哈希字面量始终需要指定类型:

{} of Int32 => Int32 # => Hash(Int32, Int32).new

类哈希类型字面量(Hash-like Type Literal)

Crystal 支持一种额外的字面量用于哈希和类哈希类型。它由类型名称后跟用花括号({})括起来的逗号分隔的键值对列表组成。

Hash{"one" => 1, "two" => 2}

这种字面量可以用于任何具有无参构造函数并响应 []= 方法的类型。

HTTP::Headers{"foo" => "bar"}

对于像 HTTP::Headers 这样的非泛型类型,这等价于:

headers = HTTP::Headers.new
headers["foo"] = "bar"

对于泛型类型,泛型类型会以与哈希字面量相同的方式从键和值的类型中推断出来。

MyHash{"foo" => 1, "bar" => "baz"}

如果 MyHash 是泛型类型,上述代码等价于:

my_hash = MyHash(typeof("foo", "bar"), typeof(1, "baz")).new
my_hash["foo"] = 1
my_hash["bar"] = "baz"

类型参数可以显式指定为类型名称的一部分:

MyHash(String, String | Int32){"foo" => "bar"}

范围(Range)

Range 表示两个值之间的区间。它通常通过范围字面量构造,范围字面量由两个或三个点组成:

  • x..y:两个点表示包含范围,包括 xy 以及它们之间的所有值(数学上表示为 [x, y])。
  • x...y:三个点表示排除范围,包括 x 和所有小于 y 的值,但不包括 y(数学上表示为 [x, y))。
(0..5).to_a  # => [0, 1, 2, 3, 4, 5]
(0...5).to_a # => [0, 1, 2, 3, 4]

注意:范围字面量通常用括号括起来,例如,如果它用作方法调用的接收者。没有括号的 0..5.to_a 在语义上等价于 0..(5.to_a),因为方法调用和其他运算符的优先级高于范围字面量。

一种简单的记忆方法是:额外的点将 y 推得更远,从而将其排除在范围之外。

字面量 x..y 在语义上等价于显式构造函数 Range.new(x, y),而 x...y 等价于 Range.new(x, y, true)

起始值和结束值不一定需要是相同类型:true..1 是一个有效的范围,尽管由于 Enumerable 方法无法处理不兼容的类型,它几乎无用。它们至少需要是可比较的。

nil 开头的范围称为无起始范围,而以 nil 结尾的范围称为无结束范围。在字面量表示法中,nil 可以省略:x.. 是一个从 x 开始的无结束范围,而 ..x 是一个以 x 结尾的无起始范围。

numbers = [1, 10, 3, 4, 5, 8]
numbers.select(6..) # => [10, 8]
numbers.select(..6) # => [1, 3, 4, 5]

numbers[2..] = [3, 4, 5, 8]
numbers[..2] = [1, 10, 3]

一个既无起始也无结束的范围是有效的,可以表示为 .....,但它通常不太有用。

正则表达式(Regular Expressions)

正则表达式由 Regex 类表示。

正则表达式通常使用 PCRE2 语法的正则表达式字面量创建。它由用正斜杠(/)括起来的 UTF-8 字符组成:

/foo|bar/
/h(e+)llo/
/\d+/
/あ/

注意:在 Crystal 1.8 之前,编译器期望正则表达式字面量遵循原始的 PCRE 模式语法。PCRE2 模式语法在 1.8 版本中引入。

转义(Escaping)

正则表达式支持与字符串字面量相同的转义序列。

/\//         # 正斜杠
/\\/         # 反斜杠
/\b/         # 退格
/\e/         # 转义
/\f/         # 换页
/\n/         # 换行
/\r/         # 回车
/\t/         # 制表符
/\v/         # 垂直制表符
/\NNN/       # 八进制 ASCII 字符
/\xNN/       # 十六进制 ASCII 字符
/\x{FFFF}/   # 十六进制 Unicode 字符
/\x{10FFFF}/ # 十六进制 Unicode 字符

在正斜杠分隔的正则表达式字面量中,分隔符 / 必须被转义。请注意,如果 PCRE 语法中的特殊字符用作字面字符,则需要对其进行转义。

插值(Interpolation)

正则表达式字面量中的插值功能与字符串字面量中的插值功能相同。请注意,如果生成的字符串导致无效的正则表达式,使用此功能将在运行时引发异常。

修饰符(Modifiers)

结束分隔符后可以跟一些可选的修饰符,以调整正则表达式的匹配行为。

  • i:不区分大小写匹配(PCRE_CASELESS):模式中的 Unicode 字母匹配目标字符串中的大写和小写字母。
  • m:多行匹配(PCRE_MULTILINE):行首(^)和行尾($)元字符分别匹配目标字符串中内部换行符之后或之前的位置,以及整个字符串的开头和结尾。
  • x:扩展空白匹配(PCRE_EXTENDED):模式中的大多数空白字符被完全忽略,除非被转义或在字符类中。未转义的井号 # 表示注释的开始,直到行尾。
/foo/i.match("FOO")         # => #<Regex::MatchData "FOO">
/foo/m.match("bar\nfoo")    # => #<Regex::MatchData "foo">
/foo /x.match("foo")        # => #<Regex::MatchData "foo">
/foo /imx.match("bar\nFOO") # => #<Regex::MatchData "FOO">

百分号正则表达式字面量(Percent Regex Literals)

除了正斜杠分隔的字面量外,正则表达式还可以表示为百分号字面量,由 %r 和一对分隔符组成。有效的分隔符包括圆括号 ()、方括号 []、花括号 {}、尖括号 <> 和竖线 ||。除了竖线外,所有分隔符都可以嵌套;这意味着字面量中的起始分隔符会转义下一个结束分隔符。

这些字面量非常适合编写包含正斜杠的正则表达式,而在正斜杠分隔的字面量中,这些正斜杠必须被转义。

%r((/)) # => /(\/)/
%r[[/]] # => /[\/]/
%r{{/}} # => /{\/}/
%r<</>> # => /<\/>/
%r|/|   # => /\//

元组(Tuple)

元组通常通过元组字面量创建:

tuple = {1, "hello", 'x'} # Tuple(Int32, String, Char)
tuple[0]                  # => 1       (Int32)
tuple[1]                  # => "hello" (String)
tuple[2]                  # => 'x'     (Char)

要创建一个空元组,可以使用 Tuple.new

要表示元组类型,可以写成:

# 表示一个包含 Int32、String 和 Char 的元组类型
Tuple(Int32, String, Char)

在类型限制、泛型类型参数和其他需要类型的地方,可以使用更短的语法,如类型语法中所述:

# 一个包含 Int32、String 和 Char 的元组数组
Array({Int32, String, Char})

展开操作符(Splat Expansion)

展开操作符可以在元组字面量中使用,以一次性解包多个值。被展开的值必须是另一个元组。

tuple = {1, *{"hello", 'x'}, 2} # => {1, "hello", 'x', 2}
typeof(tuple)                   # => Tuple(Int32, String, Char, Int32)

tuple = {3.5, true}
tuple = {*tuple, *tuple} # => {3.5, true, 3.5, true}
typeof(tuple)            # => Tuple(Float64, Bool, Float64, Bool)

过程(Proc)

Proc 表示一个带有可选上下文(闭包数据)的函数指针。它通常通过过程字面量创建:

# 无参数的过程
->{ 1 } # Proc(Int32)

# 带一个参数的过程
->(x : Int32) { x.to_s } # Proc(Int32, String)

# 带两个参数的过程
->(x : Int32, y : Int32) { x + y } # Proc(Int32, Int32, Int32)

参数的类型是必需的,除非直接将过程字面量传递给 C 绑定中的库函数。

返回类型从过程的主体中推断,但也可以显式提供:

# 返回 Int32 或 String 的过程
-> : Int32 | String { 1 } # Proc(Int32 | String)

# 带一个参数并返回 Nil 的过程
->(x : Array(String)) : Nil { x.delete("foo") } # Proc(Array(String), Nil)

# 返回类型必须与过程的主体匹配
->(x : Int32) : Bool { x.to_s } # 错误:预期 Proc 返回 Bool,而不是 String

还提供了 new 方法,用于从捕获的块创建 Proc。这种形式主要用于别名:

Proc(Int32, String).new { |x| x.to_s } # Proc(Int32, String)

alias Foo = Int32 -> String
Foo.new { |x| x.to_s } # 与上述相同的过程

过程类型(The Proc Type)

要表示 Proc 类型,可以写成:

# 接受一个 Int32 参数并返回 String 的 Proc
Proc(Int32, String)

# 不接受参数并返回 Nil 的 Proc
Proc(Nil)

# 接受两个参数(一个 Int32 和一个 String)并返回 Char 的 Proc
Proc(Int32, String, Char)

在类型限制、泛型类型参数和其他需要类型的地方,可以使用更短的语法,如类型语法中所述:

# 一个包含 Proc(Int32, String, Char) 的数组
Array(Int32, String -> Char)

调用(Invoking)

要调用 Proc,可以在其上调用 call 方法。参数的数量必须与过程的类型匹配:

proc = ->(x : Int32, y : Int32) { x + y }
proc.call(1, 2) # => 3

从方法创建(From methods)

可以从现有方法创建 Proc

def one
  1
end

proc = ->one
proc.call # => 1

如果方法有参数,则必须指定它们的类型:

def plus_one(x)
  x + 1
end

proc = ->plus_one(Int32)
proc.call(41) # => 42

过程可以可选地指定接收者:

str = "hello"
proc = ->str.count(Char)
proc.call('e') # => 1
proc.call('l') # => 2

命令字面量(Command Literal)

命令字面量是由反引号或 %x 百分号字面量分隔的字符串。它将在运行时替换为在子 shell 中执行该字符串后捕获的输出。

与普通字符串相同的转义和插值规则也适用于命令字面量。

与百分号字符串字面量类似,%x 的有效分隔符包括圆括号 ()、方括号 []、花括号 {}、尖括号 <> 和竖线 ||。除了竖线外,所有分隔符都可以嵌套;这意味着字符串中的起始分隔符会转义下一个结束分隔符。

特殊变量 $? 保存命令的退出状态,类型为 Process::Status。它仅在命令字面量的同一作用域内可用。

`echo foo`  # => "foo"
$?.success? # => true

在内部,编译器将命令字面量重写为对顶层方法 `() 的调用,并将包含命令的字符串字面量作为参数:`echo #{argument}`%x(echo #{argument}) 会被重写为 `("echo #{argument}")

安全问题(Security Concerns)

虽然命令字面量对于简单的脚本工具可能很有用,但在插值用户输入时需要特别小心,因为它很容易导致命令注入。

user_input = "hello; rm -rf *"
`echo #{user_input}`

此命令将输出 hello,然后删除当前工作目录中的所有文件和文件夹。

为了避免这种情况,通常不应将命令字面量与插值的用户输入一起使用。标准库中的 Process 提供了一种安全的方式来将用户输入作为命令参数:

user_input = "hello; rm -rf *"
process = Process.new("echo", [user_input], output: Process::Redirect::Pipe)
process.output.gets_to_end # => "hello; rm -rf *"
process.wait.success?      # => true

赋值(Assignment)

赋值表达式将一个值赋给一个命名的标识符(通常是一个变量)。赋值运算符是等号(=)。

赋值的目标可以是:

  • 局部变量
  • 实例变量
  • 类变量
  • 常量
  • 赋值方法
# 赋值给局部变量
local = 1

# 赋值给实例变量
@instance = 2

# 赋值给类变量
@@class = 3

# 赋值给常量
CONST = 4

# 赋值给 setter 方法
foo.method = 5
foo[0] = 6

方法作为赋值目标(Method as Assignment Target)

以等号(=)结尾的方法称为 setter 方法。它可以作为赋值的目标。赋值运算符的语义作为语法糖应用于方法调用。

调用 setter 方法需要显式的接收者。无接收者的语法 x = y 总是被解析为对局部变量的赋值,而不是对方法 x= 的调用。即使添加括号也不会强制方法调用,就像读取局部变量时那样。

以下示例展示了以典型方法符号和赋值运算符调用 setter 方法的两种方式。两种赋值表达式是等价的。

class Thing
  def name=(value); end
end

thing = Thing.new

thing.name=("John")
thing.name = "John"

以下示例展示了以典型方法符号和索引赋值运算符调用索引赋值方法的两种方式。两种赋值表达式是等价的。

class List
  def []=(key, value); end
end

list = List.new

list.[]=(2, 3)
list[2] = 3

组合赋值(Combined Assignments)

组合赋值是赋值运算符和另一个运算符的组合。这适用于除常量之外的任何目标类型。

一些包含 = 字符的语法糖是可用的:

local += 1  # 等同于:local = local + 1

这假设相应的目标 local 是可赋值的,无论是作为变量还是通过相应的 getter 和 setter 方法。

= 运算符的语法糖也适用于 setter 和索引赋值方法。注意 ||&& 使用 []? 方法来检查键是否存在。

person.age += 1 # 等同于:person.age = person.age + 1

person.name ||= "John" # 等同于:person.name || (person.name = "John")
person.name &&= "John" # 等同于:person.name && (person.name = "John")

objects[1] += 2 # 等同于:objects[1] = objects[1] + 2

objects[1] ||= 2 # 等同于:objects[1]? || (objects[1] = 2)
objects[1] &&= 2 # 等同于:objects[1]? && (objects[1] = 2)

链式赋值(Chained Assignment)

可以使用链式赋值将相同的值赋给多个目标。这适用于除常量之外的任何目标类型。

a = b = c = 123

# 现在 a、b 和 c 具有相同的值:
a # => 123
b # => 123
c # => 123

多重赋值(Multiple Assignment)

可以通过用逗号(,)分隔表达式来同时声明/赋值多个变量。这适用于除常量之外的任何目标类型。

name, age = "Crystal", 1

# 上述代码等同于:
temp1 = "Crystal"
temp2 = 1
name = temp1
age = temp2

注意,由于表达式被赋值给临时变量,因此可以在单行中交换变量的内容:

a = 1
b = 2
a, b = b, a
a # => 2
b # => 1

多重赋值也适用于以 = 结尾的方法:

person.name, person.age = "John", 32

# 等同于:
temp1 = "John"
temp2 = 32
person.name = temp1
person.age = temp2

它也适用于索引赋值([]=):

objects[1], objects[2] = 3, 4

# 等同于:
temp1 = 3
temp2 = 4
objects[1] = temp1
objects[2] = temp2

一对多赋值(One-to-Many Assignment)

如果右侧只包含一个表达式,则类型会为左侧的每个变量进行索引,如下所示:

name, age, source = "Crystal, 123, GitHub".split(", ")

# 上述代码等同于:
temp = "Crystal, 123, GitHub".split(", ")
name = temp[0]
age = temp[1]
source = temp[2]

此外,如果提供了 strict_multi_assign 标志,则元素的数量必须与目标的数量匹配,并且右侧必须是 Indexable

name, age, source = "Crystal, 123, GitHub".split(", ")

# 上述代码等同于:
temp = "Crystal, 123, GitHub".split(", ")
if temp.size != 3 # 目标数量
  raise IndexError.new("Multiple assignment count mismatch")
end
name = temp[0]
age = temp[1]
source = temp[2]

a, b = {0 => "x", 1 => "y"} # 错误:一对多赋值的右侧必须是 Indexable,而不是 Hash(Int32, String)

展开赋值(Splat Assignment)

赋值的左侧可以包含一个展开符(*),它会收集未分配给其他目标的任何值。如果右侧只有一个表达式,则使用范围索引:

head, *rest = [1, 2, 3, 4, 5]

# 等同于:
temp = [1, 2, 3, 4, 5]
head = temp[0]
rest = temp[1..]

对于展开符之后的目标,使用负索引:

*rest, tail1, tail2 = [1, 2, 3, 4, 5]

# 等同于:
temp = [1, 2, 3, 4, 5]
rest = temp[..-3]
tail1 = temp[-2]
tail2 = temp[-1]

如果表达式没有足够的元素,并且展开符出现在目标的中间,则会引发 IndexError

a, b, *c, d, e, f = [1, 2, 3, 4]

# 等同于:
temp = [1, 2, 3, 4]
if temp.size < 5 # 非展开赋值目标的数量
  raise IndexError.new("Multiple assignment count mismatch")
end
# 注意,如果上述检查不存在,以下赋值将错误地不会引发异常
a = temp[0]
b = temp[1]
c = temp[2..-4]
d = temp[-3]
e = temp[-2]
f = temp[-1]

右侧表达式必须是 Indexable。即使没有 strict_multi_assign 标志,也会进行大小检查和 Indexable 检查(参见上面的一对多赋值)。

如果有多个值,则会形成一个元组:

*a, b, c = 3, 4, 5, 6, 7

# 等同于:
temp1 = {3, 4, 5}
temp2 = 6
temp3 = 7
a = temp1
b = temp2
c = temp3

下划线(Underscore)

下划线可以出现在任何赋值的左侧。给它赋值没有效果,并且不能从中读取:

_ = 1     # 无效果
_ = "123" # 无效果
puts _    # 错误:无法从 _ 读取

在多重赋值中,当右侧返回的某些值不重要时,它很有用:

before, _, after = "main.cr".partition(".")

# 上述代码等同于:
temp = "main.cr".partition(".")
before = temp[0]
_ = temp[1] # 这行没有效果
after = temp[2]

*_ 的赋值会被完全丢弃,因此可以使用多重赋值高效地提取值中的第一个和最后一个元素,而无需为中间的元素创建中间对象:

first, *_, last = "127.0.0.1".split(".")

# 等同于:
temp = "127.0.0.1".split(".")
if temp.size < 2
  raise IndexError.new("Multiple assignment count mismatch")
end
first = temp[0]
last = temp[-1]

局部变量(Local Variables)

局部变量以小写字母开头。它们在首次赋值时被声明。

name = "Crystal"
age = 1

它们的类型是从其使用中推断出来的,而不仅仅是从初始化器中推断。通常,它们只是与程序员根据其在程序中的位置和用法期望的类型相关联的值持有者。

例如,用不同的表达式重新赋值变量会使其具有该表达式的类型:

flower = "Tulip"
# 此时 'flower' 是 String 类型

flower = 1
# 此时 'flower' 是 Int32 类型

下划线允许出现在变量名的开头,但这些名称是为编译器保留的,因此不建议使用它们(而且它还会使代码更难阅读)。

控制表达式(Control Expressions)

在讨论控制表达式之前,我们需要了解什么是 真值(truthy) 和 假值(falsey)。

真值与假值(Truthy and Falsey Values)

真值是指在 ifunlesswhileuntil 条件中被视为 的值。假值则是在这些条件下被视为 的值。

唯一的假值是 nilfalse 和空指针(内存地址为零的指针)。其他所有值都是真值。

if 语句

if 语句会在其条件为 真值 时执行对应的分支。否则,如果存在 else 分支,则会执行 else 分支。

a = 1
if a > 0
  a = 10
end
a # => 10

b = 1
if b > 2
  b = 10
else
  b = 20
end
b # => 20

要编写 if-else-if 链,可以使用 elsif

if some_condition
  do_something
elsif some_other_condition
  do_something_else
else
  do_that
end

if 语句之后,变量的类型取决于两个分支中表达式的类型。

a = 1
if some_condition
  a = "hello"
else
  a = true
end
# a : String | Bool

b = 1
if some_condition
  b = "hello"
end
# b : Int32 | String

if some_condition
  c = 1
else
  c = "hello"
end
# c : Int32 | String

if some_condition
  d = 1
end
# d : Int32 | Nil

注意,如果变量在一个分支中声明,但在另一个分支中没有声明,那么在 if 语句结束时,该变量也会包含 Nil 类型。

if 分支内部,变量的类型是它在该分支中被赋予的类型,或者如果它没有被重新赋值,则是它在该分支之前的类型:

a = 1
if some_condition
  a = "hello"
  # a : String
  a.size
end
# a : String | Int32

也就是说,变量的类型是最后赋值给它的表达式的类型。

如果某个分支永远不会执行到 if 的末尾(例如在 returnnextbreakraise 的情况下),则该类型在 if 结束时不会被考虑:

if some_condition
  e = 1
else
  e = "hello"
  # e : String
  return
end
# e : Int32

作为后缀(As a Suffix)

if 可以作为表达式的后缀来书写:

a = 2 if some_condition

# 上述代码等同于:
if some_condition
  a = 2
end

这种写法有时会让代码更自然易读。

作为表达式(As an Expression)

if 语句的值是其每个分支中最后一个表达式的值:

a = if 2 > 1
      3
    else
      4
    end
a # => 3

如果 if 分支为空或缺失,则视为该分支包含 nil

if 1 > 2
  3
end

# 上述代码等同于:
if 1 > 2
  3
else
  nil
end

# 另一个例子:
if 1 > 2
else
  3
end

# 上述代码等同于:
if 1 > 2
  nil
else
  3
end

三元 if(Ternary If)

三元 if 允许以更简洁的方式书写 if 语句:

a = 1 > 2 ? 3 : 4

# 上述代码等同于:
a = if 1 > 2
      3
    else
      4
    end

if var

如果一个变量是 if 的条件,那么在 then 分支中,该变量将被视为不包含 Nil 类型:

a = some_condition ? nil : 3
# a 是 Int32 或 Nil

if a
  # 因为只有 a 为真值时才会进入这里,
  # 所以 a 不能是 nil。因此这里 a 是 Int32。
  a.abs
end

这种逻辑也适用于在 if 条件中赋值变量的情况:

if a = some_expression
  # 这里 a 不是 nil
end

如果条件中包含 &&,这种逻辑同样适用:

if a && b
  # 这里 a 和 b 都保证不是 Nil
end

在这里,&& 表达式的右侧也保证 a 不是 Nil

当然,如果在 then 分支中重新赋值变量,则该变量将基于赋值的表达式具有新的类型。

限制(Limitations)

上述逻辑仅适用于局部变量。它不适用于实例变量、类变量或在闭包中绑定的变量。这些类型的变量的值可能会在条件检查后被另一个纤程修改,从而使其变为 nil。它也不适用于常量。

if @a
  # 这里 `@a` 可能是 nil
end

if @@a
  # 这里 `@@a` 可能是 nil
end

a = nil
closure = ->{ a = "foo" }

if a
  # 这里 `a` 可能是 nil
end

可以通过将值赋给一个新的局部变量来规避这个问题:

if a = @a
  # 这里 `a` 不能是 nil
end

另一种选择是使用标准库中的 Object#try,它只在值不是 nil 时执行块:

@a.try do |a|
  # 这里 `a` 不能是 nil
end
方法调用(Method Calls)

这种逻辑也不适用于过程和方法调用,包括 getter 和属性,因为可为 nil 的(或更一般地说,联合类型的)过程和方法不能保证在两次连续调用中返回相同的更具体的类型。

if method # 第一次调用可能返回 Int32 或 Nil 的方法
  # 这里我们知道第一次调用没有返回 Nil
  method # 第二次调用仍然可能返回 Int32 或 Nil
end

上述针对实例变量的技术也适用于过程和方法调用。

if var.is_a?(...)

如果 if 的条件是 is_a? 测试,那么在 then 分支中,变量的类型保证会被限制为该类型。

if a.is_a?(String)
  # 这里 a 是 String
end

if b.is_a?(Number)
  # 这里 b 是 Number
end

此外,在 else 分支中,变量的类型保证不会被限制为该类型:

a = some_condition ? 1 : "hello"
# a : Int32 | String

if a.is_a?(Number)
  # a : Int32
else
  # a : String
end

注意,你可以使用任何类型作为 is_a? 测试,例如抽象类和模块。

如果条件中包含 &&,上述逻辑同样适用:

if a.is_a?(String) && b.is_a?(Number)
  # 这里 a 是 String,b 是 Number
end

上述逻辑不适用于实例变量或类变量。要处理这些变量,首先将它们赋值给一个局部变量:

if @a.is_a?(String)
  # 这里 @a 不保证是 String
end

a = @a
if a.is_a?(String)
  # 这里 a 保证是 String
end

# 更简洁的写法:
if (a = @a).is_a?(String)
  # 这里 a 保证是 String
end

if var.responds_to?(...)

如果 if 的条件是 responds_to? 测试,那么在 then 分支中,变量的类型保证会被限制为响应该方法的类型:

if a.responds_to?(:abs)
  # 这里 a 的类型将被限制为响应 `abs` 方法的类型
end

此外,在 else 分支中,变量的类型保证会被限制为不响应该方法的类型:

a = some_condition ? 1 : "hello"
# a : Int32 | String

if a.responds_to?(:abs)
  # 这里 a 将是 Int32,因为 Int32#abs 存在,但 String#abs 不存在
else
  # 这里 a 将是 String
end

上述逻辑不适用于实例变量或类变量。要处理这些变量,首先将它们赋值给一个局部变量:

if @a.responds_to?(:abs)
  # 这里 @a 不保证响应 `abs` 方法
end

a = @a
if a.responds_to?(:abs)
  # 这里 a 保证响应 `abs` 方法
end

# 更简洁的写法:
if (a = @a).responds_to?(:abs)
  # 这里 a 保证响应 `abs` 方法
end

if var.nil?

如果 if 的条件是 var.nil?,那么编译器在 then 分支中知道 var 的类型是 Nil,而在 else 分支中知道 var 的类型是非 Nil

a = some_condition ? nil : 3
if a.nil?
  # 这里 a 是 Nil
else
  # 这里 a 是 Int32
end
实例变量(Instance Variables)

通过 if var.nil? 进行的类型限制仅适用于局部变量。在类似的代码示例中,实例变量的类型仍然可能是可为 nil 的,并且会抛出编译错误,因为 greetunless 分支中期望一个 String

class Person
  property name : String?

  def greet
    unless @name.nil?
      puts "Hello, #{@name.upcase}" # 错误:Nil 未定义方法 'upcase'(编译时类型是 (String | Nil))
    else
      puts "Hello"
    end
  end
end

Person.new.greet

你可以通过先将值存储在局部变量中来解决这个问题:

def greet
  name = @name
  unless name.nil?
    puts "Hello, #{name.upcase}" # name 将是 String - 无编译错误
  else
    puts "Hello"
  end
end

这是 Crystal 中多线程的副产品。由于 Fiber 的存在,Crystal 在编译时无法知道实例变量在到达 if 分支时是否仍然是非 Nil

if !

! 运算符返回一个布尔值,该值是对一个值的真值取反的结果。

当在 if 语句中与变量、is_a?responds_to?nil? 一起使用时,编译器会相应地限制类型:

a = some_condition ? nil : 3
if !a
  # 这里 a 是 Nil,因为 a 在这个分支中是假值
else
  # 这里 a 是 Int32,因为 a 在这个分支中是真值
end

b = some_condition ? 1 : "x"
if !b.is_a?(Int32)
  # 这里 b 是 String,因为它不是 Int32
end

unless

unless 会在其条件为 假值 时执行 then 分支,否则会执行 else 分支(如果有的话)。也就是说,它的行为与 if 相反:

unless some_condition
  expression_when_falsey
else
  expression_when_truthy
end

# 上述代码等同于:
if some_condition
  expression_when_truthy
else
  expression_when_falsey
end

# 也可以作为后缀使用
close_door unless door_closed?

case

case 是一种控制表达式,功能类似于模式匹配。它允许编写 if-else-if 链,但在语义上有一些变化,并且提供了更强大的功能。

基本形式

在基本形式中,它允许将一个值与多个值进行匹配:

case exp
when value1, value2
  do_something
when value3
  do_something_else
else
  do_another_thing
end

# 上述代码等同于:
tmp = exp
if value1 === tmp || value2 === tmp
  do_something
elsif value3 === tmp
  do_something_else
else
  do_another_thing
end

为了将表达式与 case 的主题进行比较,编译器使用 case 包含运算符 ===。它被定义为 Object 上的方法,并可以由子类重写以在 case 语句中提供有意义的语义。例如,Class=== 定义为对象是否是该类的实例,Regex 定义为值是否匹配正则表达式,Range 定义为值是否包含在该范围内。

如果 when 的表达式是一个类型,则使用 is_a?。此外,如果 case 表达式是一个变量或变量赋值,则变量的类型会被限制:

case var
when String
  # var : String
  do_something
when Int32
  # var : Int32
  do_something_else
else
  # 这里 var 既不是 String 也不是 Int32
  do_another_thing
end

# 上述代码等同于:
if var.is_a?(String)
  do_something
elsif var.is_a?(Int32)
  do_something_else
else
  do_another_thing
end

隐式对象语法

你可以使用隐式对象语法在 when 中调用 case 表达式的方法:

case num
when .even?
  do_something
when .odd?
  do_something_else
end

# 上述代码等同于:
tmp = num
if tmp.even?
  do_something
elsif tmp.odd?
  do_something_else
end

then 关键字

你可以在 when 条件后使用 then 将主体放在一行:

case exp
when value1, value2 then do_something
when value3         then do_something_else
else                     do_another_thing
end

省略 case 的值

你可以省略 case 的值:

case
when cond1, cond2
  do_something
when cond3
  do_something_else
end

# 上述代码等同于:
if cond1 || cond2
  do_something
elsif cond3
  do_something_else
end

这种写法有时会让代码更自然易读。

元组字面量

case 表达式是元组字面量时,如果 when 条件也是元组字面量,则会有一些语义上的差异。

  • 元组大小必须匹配
case {value1, value2}
when {0, 0} # 正确,2 个元素
  # ...
when {1, 2, 3} # 语法错误:元组元素数量错误(给定 3,预期 2)
  # ...
end
  • 允许使用下划线
case {value1, value2}
when {0, _}
  # 如果 0 === value1 则匹配,不对 value2 进行测试
when {_, 0}
  # 如果 0 === value2 则匹配,不对 value1 进行测试
end
  • 允许使用隐式对象语法
case {value1, value2}
when {.even?, .odd?}
  # 如果 value1.even? && value2.odd? 则匹配
end
  • 与类型比较时会执行 is_a? 检查
case {value1, value2}
when {String, Int32}
  # 如果 value1.is_a?(String) && value2.is_a?(Int32) 则匹配
  # 编译器知道 value1 是 String,value2 是 Int32
end

穷尽性 case

使用 in 而不是 when 会产生一个穷尽性 case 表达式;在穷尽性 case 中,省略任何必需的 in 条件都会导致编译时错误。穷尽性 case 不能包含任何 whenelse 子句。

编译器支持以下 in 条件:

  • 联合类型检查
# var : (Bool | Char | String)?
case var
in String
  # var : String
in Char
  # var : Char
in Bool
  # var : Bool
in nil # 或 Nil,但不允许 .nil?
  # var : Nil
end
  • 布尔值
# var : Bool
case var
in true
  do_something
in false
  do_something_else
end
  • 枚举值
enum Foo
  X
  Y
  Z
end

# var : Foo
case var
in Foo::X
  # var == Foo::X
in .y?
  # var == Foo::Y
in .z? # :z 不允许
  # var == Foo::Z
end
  • 元组字面量

条件必须穷尽 case 表达式元素的所有可能组合:

# value1, value2 : Bool
case {value1, value2}
in {true, _}
  # value1 是 true,value2 可以是 true 或 false
  do_something
in {_, false}
  # 这里 value1 是 false,value2 也是 false
  do_something_else
end

# 错误:case 不穷尽。
#
# 缺失的情况:
#  - {false, true}

select

select 表达式从一组阻塞操作中选择,并继续执行最先可用的分支。

语法

该表达式以关键字 select 开头,后跟一个或多个 when 分支的列表。每个分支都有一个条件和主体,由语句分隔符或关键字 then 分隔。可选地,最后一个分支可以是 else(没有条件),这表示 select 操作是非阻塞的。表达式以 end 关键字结束。

注意select 类似于 case 表达式,所有分支都引用可能阻塞的操作。

每个条件要么是对 select 操作的调用,要么是右侧为 select 操作调用的赋值。

select
when foo = foo_channel.receive
  puts foo
when bar = bar_channel.receive?
  puts bar
when baz_channel.send
  exit
when timeout(5.seconds)
  puts "Timeout"
end

select 操作

select 操作调用带有隐式后缀 _select_action 的方法,或者对于带有 ? 后缀的调用,使用 _select_action?。该方法返回一个 select 操作的实例。

select 表达式启动与每个分支关联的 select 操作。如果其中任何一个立即返回,则继续执行该分支。否则,它会等待完成。一旦一个分支完成,所有其他分支都会被取消。else 分支会立即完成,因此不会等待。

执行在完成的分支中继续。如果分支条件是赋值,则 select 调用的结果将赋值给目标变量。

标准库中的 select 操作

标准库提供了以下 select 操作:

  • Channel#send_select_action
  • Channel#receive_select_action
  • Channel#receive_select_action?
  • ::timeout_select_action

while

while 会在其条件为 真值 时执行其主体。

while some_condition
  do_this
end

首先测试条件,如果为真值,则执行主体。也就是说,主体可能永远不会被执行。

if 类似,如果 while 的条件是一个变量,则保证该变量在主体内不是 nil。如果条件是 var.is_a?(Type) 测试,则保证 var 在主体内是 Type 类型。如果条件是 var.responds_to?(:method),则保证 var 是响应该方法的类型。

while 之后变量的类型取决于它在 while 之前的类型以及它在离开 while 主体之前的类型:

a = 1
while some_condition
  # a : Int32 | String
  a = "hello"
  # a : String
  a.size
end
# a : Int32 | String

在循环结束时检查条件

如果需要至少执行一次主体,然后检查退出条件,可以这样做:

while true
  do_something
  break if some_condition
end

或者使用标准库中的 loop

loop do
  do_something
  break if some_condition
end

作为表达式

while 的值是退出 while 主体的 break 表达式的值:

a = 0
x = while a < 5
  a += 1
  break "four" if a == 4
  break "three" if a == 3
end
x # => "three"

如果 while 循环正常结束(因为其条件变为假),则值为 nil

x = while 1 > 2
  break 3
end
x # => nil

没有参数的 break 表达式也返回 nil

x = while 2 > 1
  break
end
x # => nil

带有多个参数的 break 表达式会被打包成 Tuple 实例:

x = while 2 > 1
  break 3, 4
end
x         # => {3, 4}
typeof(x) # => Tuple(Int32, Int32)

while 的类型是主体中所有 break 表达式的类型的联合,加上 Nil,因为条件可能失败:

x = while 1 > 2
  if rand < 0.5
    break 3
  else
    break '4'
  end
end
typeof(x) # => (Char | Int32 | Nil)

然而,如果条件恰好是 true 字面量,则其效果会从返回值和返回类型中排除:

x = while true
  break 1
end
x         # => 1
typeof(x) # => Int32

特别是,没有 breakwhile true 表达式具有 NoReturn 类型,因为循环无法在同一作用域内退出:

x = while true
  puts "yes"
end
x         # 无法到达
typeof(x) # => NoReturn

break

你可以使用 break 来跳出 while 循环:

a = 2
while (a += 1) < 20
  if a == 10
    break # 跳转到 'puts a'
  end
end
puts a # => 10

break 还可以接受一个参数,该参数将成为返回的值:

def foo
  loop do
    break "bar"
  end
end

puts foo # => "bar"

如果 break 在多个嵌套的 while 循环中使用,则只会跳出最内层的循环:

while true
  pp "start1"
  while true
    pp "start2"
    break
    pp "end2"
  end
  pp "end1"
  break
end

# 输出:
# "start1"
# "start2"
# "end1"

next

你可以使用 next 来尝试执行 while 循环的下一次迭代。执行 next 后,会检查 while 的条件,如果为真值,则执行主体。

a = 1
while a < 5
  a += 1
  if a == 3
    next
  end
  puts a
end

# 上述代码会打印数字 2、4 和 5

next 也可以用于从块中退出,例如:

def block(&)
  yield
end

block do
  puts "hello"
  next
  puts "world"
end

# 上述代码会打印 "hello"

break 类似,next 也可以接受一个参数,该参数将由 yield 返回。

def block(&)
  puts yield
end

block do
  next "hello"
end

# 上述代码会打印 "hello"

until

until 会执行其主体,直到其条件为 真值until 只是 while 的语法糖,条件被取反:

until some_condition
  do_this
end

# 上述代码等同于:
while !some_condition
  do_this
end

breaknext 也可以在 until 中使用,与 while 表达式一样,break 可以用于从 until 返回值。

&& - 逻辑与运算符

&&(逻辑与)会先计算其左侧表达式。如果左侧为 真值,则计算右侧表达式并将其值作为结果。否则,结果为左侧表达式的值。其类型是两侧类型的联合。

你可以将 && 视为 if 的语法糖:

some_exp1 && some_exp2

上述代码等同于:

tmp = some_exp1
if tmp
  some_exp2
else
  tmp
end

|| - 逻辑或运算符

||(逻辑或)会先计算其左侧表达式。如果左侧为 假值,则计算右侧表达式并将其值作为结果。否则,结果为左侧表达式的值。其类型是两侧类型的联合。

你可以将 || 视为 if 的语法糖:

some_exp1 || some_exp2

上述代码等同于:

tmp = some_exp1
if tmp
  tmp
else
  some_exp2
end

文件引入(Requiring Files)

在单个文件中编写程序适用于小片段和小型基准测试代码。大型程序在拆分为多个文件时更易于维护和理解。

为了让编译器处理其他文件,你可以使用 require "..."。它接受一个参数,即字符串字面量,并且有多种形式。

一旦文件被引入,编译器会记住其绝对路径,后续对同一文件的引入将被忽略。

require "filename"

这种方式会在 require 路径中查找 "filename"

默认情况下,require 路径包括两个位置:

  1. 相对于当前工作目录的 lib 目录(这是查找依赖项的地方)。
  2. 编译器附带的标准库位置。

这些是唯一被查找的位置。

编译器使用的确切路径可以通过 crystal env CRYSTAL_PATH 查询:

$ crystal env CRYSTAL_PATH
lib:/usr/bin/../share/crystal/src

可以通过定义 CRYSTAL_PATH 环境变量来覆盖这些查找路径。

查找过程如下:

  1. 如果在 require 路径中找到名为 "filename.cr" 的文件,则引入它。
  2. 如果找到名为 "filename" 的目录,并且它直接包含名为 "filename.cr" 的文件,则引入它。
  3. 如果找到名为 "filename" 的目录,并且它包含一个 "src" 目录,且该目录直接包含名为 "filename.cr" 的文件,则引入它。
  4. 否则,会引发编译时错误。

第二条规则意味着,除了以下结构:

- project
  - src
    - file
      - sub1.cr
      - sub2.cr
    - file.cr (requires "./file/*")

你也可以这样组织:

- project
  - src
    - file
      - file.cr (requires "./*")
      - sub1.cr
      - sub2.cr

这可能会根据你的喜好更清晰一些。

第三条规则非常方便,因为它适用于典型的项目目录结构:

- project
  - lib
    - foo
      - src
        - foo.cr
    - bar
      - src
        - bar.cr
  - src
    - project.cr
  - spec
    - project_spec.cr

也就是说,在 lib/{project} 中,每个项目的目录都存在(srcspecREADME.md 等)。

例如,如果你在 project.cr 中放入 require "foo",并在项目的根目录中运行 crystal src/project.cr,它将在 lib/foo/foo.cr 中找到 foo

第四条规则是第三条规则应用于第二条规则的结果。

如果你从其他地方运行编译器,例如 src 文件夹,lib 将不在路径中,require "foo" 将无法解析。

require "./filename"

这种方式会相对于包含 require 表达式的文件查找 "filename"

查找过程如下:

  1. 如果相对于当前文件找到名为 "filename.cr" 的文件,则引入它。
  2. 如果找到名为 "filename" 的目录,并且它直接包含名为 "filename.cr" 的文件,则引入它。
  3. 否则,会引发编译时错误。

这种相对路径通常用于项目内部引用其他文件。它也用于从测试中引用代码:

# spec/spec_helper.cr
require "../src/project"

其他形式

在两种情况下,你都可以使用嵌套名称,它们将在嵌套目录中查找:

  • require "foo/bar/baz" 将在 require 路径中查找 "foo/bar/baz.cr""foo/bar/baz/baz.cr""foo/src/bar/baz.cr""foo/src/bar/baz/baz.cr"
  • require "./foo/bar/baz" 将相对于当前文件查找 "foo/bar/baz.cr""foo/bar/baz/baz.cr"

你还可以使用 "../" 访问相对于当前文件的父目录,因此 require "../../foo/bar" 也可以工作。

在所有情况下,你都可以使用特殊的 *** 后缀:

  • require "foo/*" 将引入 "foo" 目录下的所有 .cr 文件,但不包括 "foo" 目录中的子目录。
  • require "foo/**" 将递归地引入 "foo" 目录及其子目录下的所有 .cr 文件。

类型与方法(Types and Methods)

接下来的部分将假设你已经了解面向对象编程的概念,以及类和方法的基本知识。

一切都是对象(Everything is an Object)

在 Crystal 中,一切都是对象。对象的定义可以归结为以下几点:

  1. 它有一个类型。
  2. 它可以响应某些方法。

这就是你可以了解一个对象的全部:它的类型以及它是否响应某些方法。

对象的内部状态(如果有的话)只能通过调用方法来查询。

类与方法(Classes and Methods)

类是创建单个对象的蓝图。例如,考虑一个 Person 类。你可以这样声明一个类:

class Person
end

在 Crystal 中,类名以及所有类型名称都以大写字母开头。

newinitializeallocate

你可以通过在类上调用 new 来创建该类的实例:

person = Person.new

在这里,personPerson 的一个实例。

我们还不能对 person 做太多操作,因此让我们为它添加一些概念。一个 Person 有名字和年龄。在“一切都是对象”部分中,我们说过对象有一个类型并响应某些方法,这是与对象交互的唯一方式,因此我们需要 nameage 方法。我们将这些信息存储在实例变量中,实例变量总是以 @ 字符为前缀。我们还希望 Person 在创建时具有我们选择的名字和零岁。我们用特殊的 initialize 方法编写“创建”部分,这通常称为构造函数:

class Person
  def initialize(name : String)
    @name = name
    @age = 0
  end

  def name
    @name
  end

  def age
    @age
  end
end

现在我们可以像这样创建人:

john = Person.new "John"
peter = Person.new "Peter"

john.name # => "John"
john.age  # => 0

peter.name # => "Peter"

(如果你想知道为什么我们需要指定 nameString 类型,但没有为 age 这样做,请查看全局类型推断算法。)

注意,我们使用 new 创建 Person,但我们在 initialize 方法中定义了初始化,而不是在 new 方法中。为什么会这样?

答案是当我们定义 initialize 方法时,Crystal 为我们定义了一个 new 方法,如下所示:

class Person
  def self.new(name : String)
    instance = Person.allocate
    instance.initialize(name)
    instance
  end
end

首先,注意 self.new 符号。这是一个属于 Person 类的类方法,而不是该类的特定实例。这就是为什么我们可以执行 Person.new

其次,allocate 是一个低级别的类方法,它创建给定类型的未初始化对象。它基本上为对象分配必要的内存,然后在它上面调用 initialize,最后返回实例。你通常不会调用 allocate,因为它是不安全的,但这就是 newinitialize 相关的原因。

方法与实例变量

我们可以通过使用更简洁的语法将方法参数赋值给实例变量来简化构造函数:

class Person
  def initialize(@name : String)
    @age = 0
  end

  def age
    @age
  end
end

目前,我们除了用名字创建一个人之外,还不能对它做太多操作。它的年龄将始终为零。因此,让我们添加一个方法,使一个人变老:

class Person
  def initialize(@name : String)
    @age = 0
  end

  def age
    @age
  end

  def become_older
    @age += 1
  end
end

john = Person.new "John"
peter = Person.new "Peter"

john.age # => 0

john.become_older
john.age # => 1

peter.age # => 0

方法名以小写字母开头,按照惯例,只使用小写字母、下划线和数字。

Getter 和 Setter

Crystal 标准库提供了宏,简化了 getter 和 setter 方法的定义:

class Person
  property age
  getter name : String

  def initialize(@name)
    @age = 0
  end
end

john = Person.new "John"
john.age = 32
john.age # => 32

有关 getter 和 setter 宏的更多信息,请参阅标准库文档中的 Object#getterObject#setterObject#property

顺便说一下,我们可以在原始的 Person 定义中定义 become_older,或者在单独的定义中定义:Crystal 会将所有定义合并为一个类。以下代码完全有效:

class Person
  def initialize(@name : String)
    @age = 0
  end
end

class Person
  def become_older
    @age += 1
  end
end
重新定义方法和 previous_def

如果你重新定义了一个方法,最后一个定义将优先。

class Person
  def become_older
    @age += 1
  end
end

class Person
  def become_older
    @age += 2
  end
end

person = Person.new "John"
person.become_older
person.age # => 2

你可以使用 previous_def 调用之前重新定义的方法:

class Person
  def become_older
    @age += 1
  end
end

class Person
  def become_older
    previous_def
    @age += 2
  end
end

person = Person.new "John"
person.become_older
person.age # => 3

如果没有参数或括号,previous_def 会接收方法的所有参数作为参数。否则,它会接收你传递给它的参数。

全局初始化

实例变量也可以在 initialize 方法之外初始化:

class Person
  @age = 0

  def initialize(@name : String)
  end
end

这将在每个构造函数中将 @age 初始化为零。这对于避免重复很有用,也可以在重新打开类并向其添加实例变量时避免 Nil 类型。

类型推断(Type Inference)

Crystal 的哲学是尽可能少地要求类型限制。然而,某些限制是必要的。

考虑如下类定义:

class Person
  def initialize(@name)
    @age = 0
  end
end

我们可以很快看出 @age 是一个整数,但我们不知道 @name 的类型。编译器可以从 Person 类的所有使用中推断其类型。然而,这样做有几个问题:

  1. 对于阅读代码的人来说,类型并不明显:他们还需要检查 Person 的所有使用情况才能找到这一点。
  2. 一些编译器优化,比如只需分析一次方法,以及增量编译,几乎不可能实现。

随着代码库的增长,这些问题变得更加重要:理解项目变得更加困难,编译时间变得难以忍受。

因此,Crystal 需要以一种明显的方式(对人类来说也是明显的)知道实例变量和类变量的类型。

有几种方法可以让 Crystal 知道这一点。

使用类型限制

最简单但可能最繁琐的方法是使用显式类型限制。

class Person
  @name : String
  @age : Int32

  def initialize(@name)
    @age = 0
  end
end
不使用类型限制

如果省略显式类型限制,编译器将尝试使用一系列语法规则推断实例变量和类变量的类型。

对于给定的实例/类变量,当可以应用规则并猜测类型时,该类型将被添加到集合中。当没有更多规则可以应用时,推断的类型将是这些类型的联合。此外,如果编译器推断出实例变量并不总是被初始化,它还将包括 Nil 类型。

规则很多,但通常前三个是最常用的。不需要记住所有规则。如果编译器给出错误,说明无法推断实例变量的类型,你总是可以添加显式类型限制。

以下规则仅提到实例变量,但它们也适用于类变量。它们是:

  1. 分配字面值

    当将字面值分配给实例变量时,字面值的类型将添加到集合中。所有字面值都有相关的类型。

    在以下示例中,@name 被推断为 String@age 被推断为 Int32

    class Person
      def initialize
        @name = "John Doe"
        @age = 0
      end
    end
    

    此规则以及以下所有规则也将应用于 initialize 之外的方法。例如:

    class SomeObject
      def lucky_number
        @lucky_number = 42
      end
    end
    

    在上述情况下,@lucky_number 将被推断为 Int32 | NilInt32 是因为 42 被分配给它,而 Nil 是因为它并未在类的所有 initialize 方法中被分配。

  2. 分配调用类方法 new 的结果

    当将类似 Type.new(...) 的表达式分配给实例变量时,类型 Type 将被添加到集合中。

    在以下示例中,@address 被推断为 Address

    class Person
      def initialize
        @address = Address.new("somewhere")
      end
    end
    

    这也适用于泛型类型。在这里,@values 被推断为 Array(Int32)

    class Something
      def initialize
        @values = Array(Int32).new
      end
    end
    

    注意new 方法可能会被类型重新定义。在这种情况下,如果可以使用以下某些规则推断出返回的类型,则推断的类型将是 new 返回的类型。

  3. 分配具有类型限制的方法参数变量

    在以下示例中,@name 被推断为 String,因为方法参数 name 具有 String 类型的限制,并且该参数被分配给 @name

    class Person
      def initialize(name : String)
        @name = name
      end
    end
    

    请注意,方法参数的名称并不重要;这也同样有效:

    class Person
      def initialize(obj : String)
        @name = obj
      end
    end
    

    使用更简洁的语法从方法参数分配实例变量具有相同的效果:

    class Person
      def initialize(@name : String)
      end
    end
    

    还要注意,编译器不会检查方法参数是否被重新分配了不同的值:

    class Person
      def initialize(name : String)
        name = 1
        @name = name
      end
    end
    

    在上述情况下,编译器仍将推断 @nameString,然后在完全类型化该方法时会给出编译时错误,说明 Int32 不能分配给 String 类型的变量。如果 @name 不应该是 String,请使用显式类型限制。

  4. 分配具有返回类型限制的类方法的结果

    在以下示例中,@address 被推断为 Address,因为类方法 Address.unknown 具有 Address 的返回类型限制。

    class Person
      def initialize
        @address = Address.unknown
      end
    end
    
    class Address
      def self.unknown : Address
        new("unknown")
      end
    
      def initialize(@name : String)
      end
    end
    

    实际上,上述代码不需要在 self.unknown 中指定返回类型限制。原因是编译器还会查看类方法的主体,如果它可以应用先前的规则(它是 new 方法,或者它是字面值等),它将从该表达式中推断类型。因此,上述代码可以简单地写成这样:

    class Person
      def initialize
        @address = Address.unknown
      end
    end
    
    class Address
      # 这里不需要返回类型限制
      def self.unknown
        new("unknown")
      end
    
      def initialize(@name : String)
      end
    end
    

    这个额外的规则非常方便,因为除了 new 之外,拥有“类似构造函数”的类方法非常常见。

  5. 分配具有默认值的方法参数变量

    在以下示例中,因为 name 的默认值是字符串字面值,并且它稍后被分配给 @name,所以 String 将被添加到推断类型的集合中。

    class Person
      def initialize(name = "John Doe")
        @name = name
      end
    end
    

    这当然也适用于更简洁的语法:

    class Person
      def initialize(@name = "John Doe")
      end
    end
    

    默认参数值也可以是 Type.new(...) 方法或具有返回类型限制的类方法。

  6. 分配调用库函数的结果

    因为库函数必须具有显式类型,所以编译器可以在将其分配给实例变量时使用返回类型。

    在以下示例中,@age 被推断为 Int32

    class Person
      def initialize
        @age = LibPerson.compute_default_age
      end
    end
    
    lib LibPerson
      fun compute_default_age : Int32
    end
    
  7. 使用 out 库表达式

    因为库函数必须具有显式类型,所以编译器可以使用 out 参数的类型(应该是指针类型),并使用解引用的类型作为猜测。

    在以下示例中,@age 被推断为 Int32

    class Person
      def initialize
        LibPerson.compute_default_age(out @age)
      end
    end
    
    lib LibPerson
      fun compute_default_age(age_ptr : Int32*)
    end
    
其他规则

编译器将尽可能智能,以减少显式类型限制的需求。例如,如果分配 if 表达式,类型将从 thenelse 分支中推断:

class Person
  def initialize
    @age = some_condition ? 1 : 2
  end
end

因为上面的 if(技术上是一个三元运算符,但它类似于 if)有整数字面值,@age 成功推断为 Int32,而无需冗余的类型限制。

另一个例子是 ||||=

class SomeObject
  def lucky_number
    @lucky_number ||= 42
  end
end

在上面的示例中,@lucky_number 将被推断为 Int32 | Nil。这对于延迟初始化的变量非常有用。

常量也会被跟踪,因为这对编译器(和人类)来说非常简单。

class SomeObject
  DEFAULT_LUCKY_NUMBER = 42

  def initialize(@lucky_number = DEFAULT_LUCKY_NUMBER)
  end
end

这里使用了规则 5(默认参数值),因为常量解析为整数字面值,@lucky_number 被推断为 Int32

联合类型(Union Types)

变量或表达式的类型可以由多个类型组成。这称为联合类型。例如,当在不同的 if 分支中分配给同一个变量时:

if 1 + 2 == 3
  a = 1
else
  a = "hello"
end

a # : Int32 | String

if 结束时,a 将具有 Int32 | String 类型,读作“Int32String 的联合”。这种联合类型是由编译器自动创建的。在运行时,a 当然只会是其中一种类型。可以通过调用 class 方法来查看:

# 运行时类型
a.class # => Int32

编译时类型可以通过 typeof 查看:

# 编译时类型
typeof(a) # => Int32 | String

联合类型可以由任意数量的类型组成。当对类型为联合类型的表达式调用方法时,联合中的所有类型都必须响应该方法,否则会给出编译时错误。方法调用的类型是这些方法返回类型的联合类型。

# to_s 是为 Int32 和 String 定义的,它返回 String
a.to_s # => String

a + 1 # 错误,因为 String#+(Int32) 未定义

如果需要,可以在编译时将变量定义为联合类型:

# 设置编译时类型
a = 0.as(Int32 | Nil | String)
typeof(a) # => Int32 | Nil | String
联合类型规则

在一般情况下,当两种类型 T1T2 组合时,结果是联合类型 T1 | T2。然而,有一些情况下结果类型是不同的类型。

  1. 同一层次结构下的类和结构的联合

    如果 T1T2 位于同一层次结构下,并且它们最近的共同祖先 Parent 不是 ReferenceStructIntFloatValue,则结果类型是 Parent+。这称为虚拟类型,基本上意味着编译器现在将该类型视为 Parent 或其任何子类型。

例如:

class Foo
end

class Bar < Foo
end

class Baz < Foo
end

bar = Bar.new
baz = Baz.new

# 这里 foo 的类型将是 Bar | Baz,
# 但因为 Bar 和 Baz 都继承自 Foo,
# 所以结果类型是 Foo+
foo = rand < 0.5 ? bar : baz
typeof(foo) # => Foo+
  1. 相同大小的元组的联合

两个相同大小的元组的联合结果是一个元组类型,其中每个位置的类型是联合类型。

例如:

t1 = {1, "hi"}   # Tuple(Int32, String)
t2 = {true, nil} # Tuple(Bool, Nil)

t3 = rand < 0.5 ? t1 : t2
typeof(t3) # Tuple(Int32 | Bool, String | Nil)
  1. 具有相同键的命名元组的联合

两个具有相同键的命名元组(无论顺序如何)的联合结果是一个命名元组类型,其中每个键的类型是联合类型。键的顺序将是左侧元组的顺序。

例如:

t1 = {x: 1, y: "hi"}   # Tuple(x: Int32, y: String)
t2 = {y: true, x: nil} # Tuple(y: Bool, x: Nil)

t3 = rand < 0.5 ? t1 : t2
typeof(t3) # NamedTuple(x: Int32 | Nil, y: String | Bool)

方法重载(Overloading)

我们可以定义一个 become_older 方法,该方法接受一个数字表示增长的年龄:

class Person
  getter :age

  def initialize(@name : String, @age : Int = 0)
  end

  def become_older
    @age += 1
  end

  def become_older(years)
    @age += years
  end
end

john = Person.new "John"
john.age # => 0

john.become_older
john.age # => 1

john.become_older 5
john.age # => 6

也就是说,你可以有多个同名但参数数量不同的方法,它们将被视为不同的方法。这称为方法重载。

方法重载的依据包括:

  1. 参数的数量
  2. 应用于参数的类型限制
  3. 必需命名参数的名称
  4. 方法是否接受块

例如,我们可以定义四个不同的 become_older 方法:

class Person
  @age = 0

  # 将年龄增加一岁
  def become_older
    @age += 1
  end

  # 将年龄增加给定的年数
  def become_older(years : Int32)
    @age += years
  end

  # 将年龄增加给定的年数,作为字符串
  def become_older(years : String)
    @age += years.to_i
  end

  # 生成当前年龄,并通过块返回的值增加年龄
  def become_older(&)
    @age += yield @age
  end
end

person = Person.new "John"

person.become_older
person.age # => 1

person.become_older 5
person.age # => 6

person.become_older "12"
person.age # => 18

person.become_older do |current_age|
  current_age < 20 ? 10 : 30
end
person.age # => 28

请注意,在生成块的方法中,编译器通过 yield 表达式推断出这一点。为了更明确,你可以在末尾添加一个虚拟的 &block 参数:

class Person
  @age = 0

  def become_older(&block)
    @age += yield @age
  end
end

在生成的文档中,虚拟的 &block 方法将始终出现,无论你是否编写它。

在参数数量相同的情况下,编译器会尝试通过将限制较少的参数放在后面来排序:

class Person
  @age = 0

  # 首先定义这个方法
  def become_older(age)
    @age += age
  end

  # 由于 "String" 比没有任何限制更严格,
  # 编译器在考虑哪个重载匹配时将此方法放在前一个方法之前。
  def become_older(age : String)
    @age += age.to_i
  end
end

person = Person.new "John"

# 调用第一个定义
person.become_older 20

# 调用第二个定义
person.become_older "12"

然而,编译器并不总是能够确定顺序,因为并不总是存在完全排序,因此最好将限制较少的方法放在最后。

默认参数值和命名参数

默认参数值

方法可以为最后的参数指定默认值:

class Person
  def become_older(by = 1)
    @age += by
  end
end

john = Person.new "John"
john.age # => 0

john.become_older
john.age # => 1

john.become_older 2
john.age # => 3
命名参数

所有参数除了按位置指定外,还可以通过名称指定。例如:

john.become_older by: 5

当有许多参数时,调用中的名称顺序无关紧要,只要所有必需的参数都被覆盖:

def some_method(x, y = 1, z = 2, w = 3)
  # 做一些事情...
end

some_method 10                   # x: 10, y: 1, z: 2, w: 3
some_method 10, z: 10            # x: 10, y: 1, z: 10, w: 3
some_method 10, w: 1, y: 2, z: 3 # x: 10, y: 2, z: 3, w: 1
some_method y: 10, x: 20         # x: 20, y: 10, z: 2, w: 3

some_method y: 10 # 错误,缺少参数:x

当方法指定了 splat 参数(在下一节中解释)时,命名参数不能用于位置参数。原因是理解参数如何匹配变得非常困难;在这种情况下,位置参数更容易推理。

Splats 和元组

方法可以通过使用 splat 参数(*)接收可变数量的参数,splat 参数只能出现一次,并且可以出现在任何位置:

def sum(*elements)
  total = 0
  elements.each do |value|
    total += value
  end
  total
end

sum 1, 2, 3      # => 6
sum 1, 2, 3, 4.5 # => 10.5

传递的参数在方法体内成为一个 Tuple

# elements 是 Tuple(Int32, Int32, Int32)
sum 1, 2, 3

# elements 是 Tuple(Int32, Int32, Int32, Float64)
sum 1, 2, 3, 4.5

splat 参数之后的参数只能作为命名参数传递:

def sum(*elements, initial = 0)
  total = initial
  elements.each do |value|
    total += value
  end
  total
end

sum 1, 2, 3              # => 6
sum 1, 2, 3, initial: 10 # => 16

splat 参数之后没有默认值的参数是必需的命名参数:

def sum(*elements, initial)
  total = initial
  elements.each do |value|
    total += value
  end
  total
end

sum 1, 2, 3              # 错误,缺少参数:initial
sum 1, 2, 3, initial: 10 # => 16

具有不同必需命名参数的两个方法会相互重载:

def foo(*elements, x)
  1
end

def foo(*elements, y)
  2
end

foo x: "something" # => 1
foo y: "something" # => 2

splat 参数也可以不命名,表示“在此之后是命名参数”:

def foo(x, y, *, z)
end

foo 1, 2, 3    # 错误,参数数量错误(给定 3,预期 2)
foo 1, 2       # 错误,缺少参数:z
foo 1, 2, z: 3 # 正确
展开元组

可以通过使用 *Tuple 展开为方法调用:

def foo(x, y)
  x + y
end

tuple = {1, 2}
foo *tuple # => 3

双 splats 和命名元组

双 splat(**)捕获未被其他参数匹配的命名参数。参数的类型是 NamedTuple

def foo(x, **other)
  # 将捕获的命名参数作为 NamedTuple 返回
  other
end

foo 1, y: 2, z: 3    # => {y: 2, z: 3}
foo y: 2, x: 1, z: 3 # => {y: 2, z: 3}
展开命名元组

可以通过使用 **NamedTuple 展开为方法调用:

def foo(x, y)
  x - y
end

tuple = {y: 3, x: 10}
foo **tuple # => 7
类型限制(Type Restrictions)

类型限制应用于方法参数,以限制该方法接受的类型。

def add(x : Number, y : Number)
  x + y
end

# 正确
add 1, 2

# 错误:没有匹配 'add' 的重载,类型为 Bool, Bool
add true, false

请注意,如果我们定义 add 时没有类型限制,我们也会得到一个编译时错误:

def add(x, y)
  x + y
end

add true, false

上述代码会给出以下编译错误:

Error in foo.cr:6: instantiating 'add(Bool, Bool)'

add true, false
^~~

in foo.cr:2: undefined method '+' for Bool

  x + y
    ^

这是因为当你调用 add 时,它会根据参数的类型进行实例化:每次使用不同的类型组合调用方法时,都会生成不同的方法实例。

唯一的区别是第一个错误消息更清晰一些,但两种定义都是安全的,因为无论如何你都会得到编译时错误。因此,通常最好不要指定类型限制,几乎只在定义不同的方法重载时使用它们。这会导致代码更通用、更可重用。例如,如果我们定义一个具有 + 方法但不是 Number 的类,我们可以使用没有类型限制的 add 方法,但不能使用有类型限制的 add 方法。

# 一个具有 + 方法但不是 Number 的类
class Six
  def +(other)
    6 + other
  end
end

# 没有类型限制的 add 方法
def add(x, y)
  x + y
end

# 正确
add Six.new, 10

# 有类型限制的 add 方法
def restricted_add(x : Number, y : Number)
  x + y
end

# 错误:没有匹配 'restricted_add' 的重载,类型为 Six, Int32
restricted_add Six.new, 10

有关类型限制中使用的符号,请参阅类型语法。

请注意,类型限制不适用于实际方法中的变量。

def handle_path(path : String)
  path = Path.new(path) # *path* 现在是 Path 类型
  # 对 *path* 做一些事情
end
实例变量的限制

在某些情况下,可以根据使用情况限制方法参数的类型。例如,考虑以下示例:

class Foo
  @x : Int64

  def initialize(x)
    @x = x
  end
end

在这种情况下,我们知道初始化函数中的参数 x 必须是 Int64,因此没有必要将其保持为无限制。

当编译器发现从方法参数到实例变量的赋值时,它会插入这样的限制。在上面的示例中,调用 Foo.new "hi" 会失败(注意类型限制):

Error: no overload matches 'Foo.new' with type String

Overloads are:
 - Foo.new(x : ::Int64)
self 限制

一种特殊的类型限制是 self

class Person
  def ==(other : self)
    other.name == name
  end

  def ==(other)
    false
  end
end

john = Person.new "John"
another_john = Person.new "John"
peter = Person.new "Peter"

john == another_john # => true
john == peter        # => false (名字不同)
john == 1            # => false (因为 1 不是 Person)

在前面的示例中,self 与写 Person 相同。但是,通常 self 与最终拥有该方法的类型相同,当涉及模块时,这变得更有用。

顺便说一下,由于 Person 继承自 Reference,因此 == 的第二个定义不是必需的,因为它已经在 Reference 中定义了。

请注意,self 始终表示对实例类型的匹配,即使在类方法中也是如此:

class Person
  getter name : String

  def initialize(@name)
  end

  def self.compare(p1 : self, p2 : self)
    p1.name == p2.name
  end
end

john = Person.new "John"
peter = Person.new "Peter"

Person.compare(john, peter) # 正确

你可以使用 self.class 来限制为 Person 类型。下一节将讨论类型限制中的 .class 后缀。

类作为限制

例如,使用 Int32 作为类型限制会使该方法仅接受 Int32 的实例:

def foo(x : Int32)
end

foo 1       # 正确
foo "hello" # 错误

如果你希望一个方法仅接受 Int32 类型(而不是它的实例),你可以使用 .class

def foo(x : Int32.class)
end

foo Int32  # 正确
foo String # 错误

上述方法在基于类型(而不是实例)提供重载时非常有用:

def foo(x : Int32.class)
  puts "Got Int32"
end

def foo(x : String.class)
  puts "Got String"
end

foo Int32  # 打印 "Got Int32"
foo String # 打印 "Got String"
Splats 中的类型限制

你可以在 splats 中指定类型限制:

def foo(*args : Int32)
end

def foo(*args : String)
end

foo 1, 2, 3       # 正确,调用第一个重载
foo "a", "b", "c" # 正确,调用第二个重载
foo 1, 2, "hello" # 错误
foo()             # 错误

当指定类型时,元组中的所有元素都必须匹配该类型。此外,空元组不匹配上述任何情况。如果你想支持空元组的情况,可以添加另一个重载:

def foo
  # 这是空元组的情况
end

匹配一个或多个任意类型元素的简单方法是使用 _ 作为限制:

def foo(*args : _)
end

foo()       # 错误
foo(1)      # 正确
foo(1, "x") # 正确
自由变量

你可以使用 forall 使类型限制接受参数的类型或参数类型的一部分:

def foo(x : T) forall T
  T
end

foo(1)       # => Int32
foo("hello") # => String

也就是说,T 成为实际用于实例化方法的类型。

自由变量可以用于在类型限制中提取泛型类型的类型参数:

def foo(x : Array(T)) forall T
  T
end

foo([1, 2])   # => Int32
foo([1, "a"]) # => (Int32 | String)

要创建一个接受类型名称而不是类型实例的方法,可以在类型限制中的自由变量后附加 .class

def foo(x : T.class) forall T
  Array(T)
end

foo(Int32)  # => Array(Int32)
foo(String) # => Array(String)

也可以指定多个自由变量,以匹配多个参数的类型:

def push(element : T, array : Array(T)) forall T
  array << element
end

push(4, [1, 2, 3])      # 正确
push("oops", [1, 2, 3]) # 错误
Splat 类型限制

如果 splat 参数的限制也有 splat,则限制必须命名一个 Tuple 类型,并且与该参数对应的参数必须匹配 splat 限制的元素:

def foo(*x : *{Int32, String})
end

foo(1, "") # 正确
foo("", 1) # 错误
foo(1)     # 错误

直接在 splat 限制中指定元组类型非常罕见,因为上述情况可以通过不使用 splat 来表达(即 def foo(x : Int32, y : String))。然而,如果限制是自由变量,则它会被推断为包含所有对应参数类型的 Tuple

def foo(*x : *T) forall T
  T
end

foo(1, 2)  # => Tuple(Int32, Int32)
foo(1, "") # => Tuple(Int32, String)
foo(1)     # => Tuple(Int32)
foo()      # => Tuple()

在最后一行,T 被推断为空元组,这对于具有非 splat 限制的 splat 参数是不可能的。

双 splat 参数同样支持双 splat 类型限制:

def foo(**x : **T) forall T
  T
end

foo(x: 1, y: 2)  # => NamedTuple(x: Int32, y: Int32)
foo(x: 1, y: "") # => NamedTuple(x: Int32, y: String)
foo(x: 1)        # => NamedTuple(x: Int32)
foo()            # => NamedTuple()

此外,单 splat 限制也可以在泛型类型内部使用,以一次提取多个类型参数:

def foo(x : Proc(*T, Int32)) forall T
  T
end

foo(->(x : Int32, y : Int32) { x + y }) # => Tuple(Int32, Int32)
foo(->(x : Bool) { x ? 1 : 0 })         # => Tuple(Bool)
foo(->{ 1 })                            # => Tuple()
返回类型(Return Types)

方法的返回类型总是由编译器推断。然而,你可能希望指定它,原因有两个:

  1. 确保方法返回你想要的类型
  2. 使其出现在文档注释中

例如:

def some_method : String
  "hello"
end

返回类型遵循类型语法。

Nil 返回类型

将方法标记为返回 Nil 将使其无论实际返回什么,都返回 nil

def some_method : Nil
  1 + 2
end

some_method # => nil

这有两个原因很有用:

  1. 确保方法返回 nil,而无需在末尾添加额外的 nil,或在每个返回点添加 nil
  2. 记录该方法的返回值无关紧要

这些方法通常意味着有副作用。

使用 Void 是相同的,但 Nil 更符合习惯:Void 在 C 绑定中更常用。

NoReturn 返回类型

某些表达式不会返回到当前作用域,因此没有返回类型。这表示为特殊的返回类型 NoReturn

不返回的方法和关键字的典型例子是 returnexitraisenextbreak

这对于解构联合类型非常有用:

string = STDIN.gets
typeof(string)                        # => String?
typeof(raise "Empty input")           # => NoReturn
typeof(string || raise "Empty input") # => String

编译器认识到,如果 stringNil,则表达式 string || raise 的右侧将被评估。由于 typeof(raise "Empty input")NoReturn,在这种情况下,执行不会返回到当前作用域。这使得表达式的唯一可能类型是 String

所有代码路径都导致 NoReturn 的表达式也将是 NoReturnNoReturn 不会出现在联合类型中,因为它实际上包含在每个表达式的类型中。它仅在表达式永远不会返回到当前作用域时使用。

NoReturn 可以显式设置为方法或函数定义的返回类型,但通常由编译器推断。

方法参数

这是方法参数和调用参数的形式化规范。

方法定义的组成部分

方法定义包括:

  1. 必需和可选的位置参数
  2. 一个可选的 splat 参数,其名称可以为空
  3. 必需和可选的命名参数
  4. 一个可选的 double splat 参数

例如:

def foo(
  # 这些是位置参数:
  x, y, z = 1,
  # 这是 splat 参数:
  *args,
  # 这些是命名参数:
  a, b, c = 2,
  # 这是 double splat 参数:
  **options
)
end

其中每一个都是可选的,因此方法可以没有 double splat、没有 splat、没有命名参数和没有位置参数。

方法调用的组成部分

方法调用也有一些部分:

foo(
  # 这些是位置参数
  1, 2,
  # 这些是命名参数
  a: 1, b: 2
)

此外,调用参数可以有一个 splat(*)或 double splat(**)。splat 将 Tuple 展开为位置参数,而 double splat 将 NamedTuple 展开为命名参数。允许多个参数的 splat 和 double splat。

调用参数如何与方法参数匹配

当调用方法时,将调用参数与方法参数匹配的算法是:

  1. 首先,位置调用参数与位置方法参数匹配。这些参数的数量必须至少是没有默认值的位置参数的数量。如果有一个带名称的 splat 参数(没有名称的情况在下面解释),则允许更多的位置参数,并将它们捕获为元组。位置参数永远不会匹配到 splat 参数之后。
  2. 然后,命名参数通过名称与方法中的任何参数匹配(它可以在 splat 参数之前或之后)。如果参数已经被位置参数填充,则这是一个错误。
  3. 额外的命名参数被放置在 double splat 方法参数中,作为 NamedTuple,如果它存在,否则这是一个错误。

当 splat 参数没有名称时,意味着不能再传递更多的位置参数,并且任何后续参数必须作为命名参数传递。例如:

# 只允许一个位置参数,y 必须作为命名参数传递
def foo(x, *, y)
end

foo 1        # 错误,缺少参数:y
foo 1, 2     # 错误:参数数量错误(给定 2,预期 1)
foo 1, y: 10 # 正确

但即使 splat 参数有名称,其后的参数也必须作为命名参数传递:

# 允许一个或多个位置参数,y 必须作为命名参数传递
def foo(x, *args, y)
end

foo 1             # 错误,缺少参数:y
foo 1, 2          # 错误:缺少参数;y
foo 1, 2, 3       # 错误:缺少参数:y
foo 1, y: 10      # 正确
foo 1, 2, 3, y: 4 # 正确

还可以通过将星号放在开头,使方法仅接收命名参数(并列出它们):

# 一个具有两个必需命名参数的方法:x 和 y
def foo(*, x, y)
end

foo            # 错误:缺少参数:x, y
foo x: 1       # 错误:缺少参数:y
foo x: 1, y: 2 # 正确

星号之后的参数也可以有默认值。这意味着:它们必须作为命名参数传递,但它们不是必需的(因此:可选的命名参数):

# x 是必需的命名参数,y 是可选的命名参数
def foo(*, x, y = 2)
end

foo            # 错误:缺少参数:x
foo x: 1       # 正确,y 是 2
foo x: 1, y: 3 # 正确,y 是 3

由于 splat 参数之后的参数(没有默认值)必须通过名称传递,因此具有不同必需命名参数的两个方法会重载:

def foo(*, x)
  puts "Passed with x: #{x}"
end

def foo(*, y)
  puts "Passed with y: #{y}"
end

foo x: 1 # => Passed with x: 1
foo y: 2 # => Passed with y: 2

位置参数总是可以通过名称匹配:

def foo(x, *, y)
end

foo 1, y: 2    # 正确
foo y: 2, x: 3 # 正确
外部名称

可以为方法参数指定外部名称。外部名称是在将参数作为命名参数传递时使用的名称,而内部名称是在方法定义中引用参数时使用的名称:

def foo(external_name internal_name)
  # 这里我们使用 internal_name
end

foo external_name: 1

这涵盖了两个用例。

第一个用例是使用关键字作为命名参数:

def plan(begin begin_time, end end_time)
  puts "Planning between #{begin_time} and #{end_time}"
end

plan begin: Time.local, end: 2.days.from_now

第二个用例是使方法参数在方法体内更具可读性:

def increment(value, by)
  # 正确,但读起来很奇怪
  value + by
end

def increment(value, by amount)
  # 更好
  value + amount
end
运算符(Operators)

Crystal 支持多种运算符,包括一元、二元和三元运算符。

运算符表达式实际上被解析为方法调用。例如,a + b 在语义上等同于 a.+(b),即对 a 调用方法 +,参数为 b

然而,关于运算符语法有一些特殊规则:

  1. 通常放在接收者和方法名之间的点(.)可以省略。
  2. 编译器会重新构造链式运算符调用序列,以实现运算符优先级。强制执行运算符优先级确保诸如 1 * 2 + 3 * 4 的表达式被解析为 (1 * 2) + (2 * 3),以遵循常规的数学规则。
  3. 常规方法名称必须以字母或下划线开头,但运算符仅由特殊字符组成。任何不以字母或下划线开头的方法都是运算符方法。
  4. 可用的运算符在编译器中是白名单(参见下面的运算符列表),它允许仅由符号组成的方法名称,并将它们视为运算符,包括它们的优先级规则。

运算符像任何常规方法一样实现,标准库提供了许多实现,例如用于数学表达式。

定义运算符方法

大多数运算符可以作为常规方法实现。

可以为运算符赋予任何含义,但建议保持与通用运算符含义相似的语义,以避免编写晦涩难懂且行为出乎意料的代码。

一些运算符由编译器直接定义,不能在用户代码中重新定义。例如,取反运算符 !、赋值运算符 =、组合赋值运算符如 ||= 和范围运算符。方法是否可以重新定义由下面运算符表中的 Overloadable 列指示。

一元运算符

一元运算符以前缀表示法书写,并且只有一个操作数。因此,方法实现不接收任何参数,仅对 self 进行操作。

以下示例演示了 Vector2 类型作为二维向量,并带有一元运算符方法 - 用于向量取反。

struct Vector2
  getter x, y

  def initialize(@x : Int32, @y : Int32)
  end

  # 一元运算符。返回 `self` 的反向量。
  def - : self
    Vector2.new(-x, -y)
  end
end

v1 = Vector2.new(1, 2)
-v1 # => Vector2(@x=-1, @y=-2)
二元运算符

二元运算符有两个操作数。因此,方法实现恰好接收一个参数,表示第二个操作数。第一个操作数是接收者 self

以下示例演示了 Vector2 类型作为二维向量,并带有二元运算符方法 + 用于向量加法。

struct Vector2
  getter x, y

  def initialize(@x : Int32, @y : Int32)
  end

  # 二元运算符。返回 *other* 加到 `self` 上。
  def +(other : self) : self
    Vector2.new(x + other.x, y + other.y)
  end
end

v1 = Vector2.new(1, 2)
v2 = Vector2.new(3, 4)
v1 + v2 # => Vector2(@x=4, @y=6)

按照惯例,二元运算符的返回类型应该是第一个操作数(接收者)的类型,以便 typeof(a <op> b) == typeof(a)。否则,赋值运算符(a <op>= b)会意外地改变 a 的类型。不过,也有一些合理的例外。例如,在标准库中,整数类型的浮点除法运算符 / 总是返回 Float64,因为商不能限制在整数的值范围内。

三元运算符

条件运算符(? :)是唯一的三元运算符。它不被解析为方法,并且其含义不能更改。编译器将其转换为 if 表达式。

运算符优先级

此列表按优先级排序,因此上方的条目比下方的条目绑定更强。

类别 运算符
索引访问器 [], []?
一元 +, &+, -, &-, !, ~
指数 **, &**
乘法 *, &*, /, //, %
加法 +, &+, -, &-
移位 <<, >>
二进制 AND &
二进制 OR/XOR \|, ^
相等性和包含性 ==, !=, =~, !~, ===
比较 <, <=, >, >=, <=>
逻辑 AND &&
逻辑 OR \|\|
范围 .., ...
条件 ? :
赋值 =, []=, +=, &+=, -=, &-=, *=, &*=, /=, //=, %=, \|=, &=, ^=, **=, <<=, >>=, \|\|=, &&=
Splat *, **
运算符列表
算术运算符
一元
运算符 描述 示例 可重载 结合性
+ +1
&+ 包装正 &+1
- -1
&- 包装负 &-1
乘法
运算符 描述 示例 可重载 结合性
** 指数 1 ** 2
&** 包装指数 1 &** 2
* 乘法 1 * 2
&* 包装乘法 1 &* 2
/ 除法 1 / 2
// 地板除法 1 // 2
% 1 % 2
加法
运算符 描述 示例 可重载 结合性
+ 加法 1 + 2
&+ 包装加法 1 &+ 2
- 减法 1 - 2
&- 包装减法 1 &- 2
其他一元运算符
运算符 描述 示例 可重载 结合性
! 取反 !true
~ 二进制补码 ~1
移位
运算符 描述 示例 可重载 结合性
<< 左移,追加 1 << 2, STDOUT << "foo"
>> 右移 1 >> 2
二进制
运算符 描述 示例 可重载 结合性
& 二进制 AND 1 & 2
\| 二进制 OR 1 \| 2
^ 二进制 XOR 1 ^ 2
关系运算符

关系运算符测试两个值之间的关系。它们包括相等性、不等式和包含性。

相等性

相等运算符 == 检查操作数的值是否被视为相等。

不等运算符 != 是表达取反的快捷方式:a != b 应该等同于 !(a == b)

实现不等运算符的类型必须确保遵守这一点。特殊实现可能对性能有用,因为不等式通常比等式更快地证明。

这两个运算符都应该是可交换的,即 a == b 当且仅当 b == a。编译器不强制执行这一点,实现类型必须自己注意。

运算符 描述 示例 可重载 结合性
== 相等 1 == 2
!= 不等 1 != 2

注意

标准库将 Reference#same? 定义为另一个非运算符的相等性测试。它检查引用标识,以确定两个值是否引用内存中的相同位置。

不等式

不等式运算符描述值之间的顺序。

三向比较运算符 <=>(也称为太空船运算符)通过其返回值的符号表示两个元素之间的顺序。

运算符 描述 示例 可重载 结合性
< 小于 1 < 2
<= 小于或等于 1 <= 2
> 大于 1 > 2
>= 大于或等于 1 >= 2
<=> 三向比较 1 <=> 2

注意

标准库定义了 Comparable 模块,它从三向比较运算符派生所有其他不等式运算符以及相等运算符。

包含性

模式匹配运算符 =~ 检查第一个操作数的值是否与第二个操作数的值匹配。

无模式匹配运算符 !~ 表示相反。

案例包含运算符 ===(也称为不精确地称为案例相等运算符或三等号)检查右操作数是否是左操作数描述的集合的成员。确切的解释取决于所涉及的数据类型。

编译器在 case ... when 条件中插入此运算符。

没有相反的运算符。

运算符 描述 示例 可重载 结合性
=~ 模式匹配 "foo" =~ /fo/
!~ 无模式匹配 "foo" !~ /fo/
=== 案例包含 /foo/ === "foo"
链式关系运算符

关系运算符 ==!====<><=>= 可以链接在一起,并被视为复合表达式。例如,a <= b <= c 被视为 a <= b && b <= c。可以混合使用不同的运算符:a >= b <= c > d 等同于 a >= b && b <= c && c > d

建议仅组合相同优先级类的运算符,以避免令人惊讶的绑定行为。例如,a == b <= c 等同于 a == b && b <= c,而 a <= b == c 等同于 a <= (b == c)

逻辑
运算符 描述 示例 可重载 结合性
&& 逻辑 AND true && false
\|\| 逻辑 OR true \|\| false
范围

范围运算符用于范围字面量。

运算符 描述 示例 可重载
.. 包含范围 1..10
... 排除范围 1...10
Splats

Splat 运算符只能用于在方法参数中解构元组。有关详细信息,请参阅 Splats 和 Tuples。

运算符 描述 示例 可重载
* splat *foo
** double splat **foo
条件

条件运算符(? :)在内部被编译器重写为 if 表达式。

运算符 描述 示例 可重载 结合性
? : 条件 a == b ? c : d
赋值

赋值运算符 = 将第二个操作数的值赋给第一个操作数。第一个操作数要么是变量(在这种情况下运算符不能重定义),要么是调用(在这种情况下运算符可以重定义)。有关详细信息,请参阅赋值。

运算符 描述 示例 可重载 结合性
= 变量赋值 a = 1
= 调用赋值 a.b = 1
[]= 索引赋值 a[0] = 1
组合赋值

赋值运算符 = 是所有将运算符与赋值组合的运算符的基础。一般形式是 a <op>= b,编译器将其转换为 a = a <op> b

逻辑运算符的例外情况:

  • a ||= b 转换为 a || (a = b)
  • a &&= b 转换为 a && (a = b)

a 是索引访问器([])时,还有另一种特殊情况,它被更改为右侧的可空变体([]?):

  • a[i] ||= b 转换为 a[i] = (a[i]? || b)
  • a[i] &&= b 转换为 a[i] = (a[i]? && b)

所有转换都假设接收者(a)是变量。如果它是调用,则替换在语义上是等价的,但实现稍微复杂一些(引入一个匿名临时变量),并期望 a= 是可调用的。

接收者不能是变量或调用以外的任何东西。

运算符 描述 示例 可重载 结合性
+= 加法和赋值 i += 1
&+= 包装加法和赋值 i &+= 1
-= 减法和赋值 i -= 1
&-= 包装减法和赋值 i &-= 1
*= 乘法和赋值 i *= 1
&*= 包装乘法和赋值 i &*= 1
/= 除法和赋值 i /= 1
//= 地板除法和赋值 i //= 1
%= 模和赋值 i %= 1
\|= 二进制或和赋值 i \|= 1
&= 二进制与和赋值 i &= 1
^= 二进制异或和赋值 i ^= 1
**= 指数和赋值 i **= 1
<<= 左移和赋值 i <<= 1
>>= 右移和赋值 i >>= 1
\|\|= 逻辑或和赋值 i \|\|= true
&&= 逻辑与和赋值 i &&= true
索引访问器

索引访问器用于通过索引或键查询值,例如数组项或映射条目。可空变体 []? 应该在找不到索引时返回 nil,而不可空变体在这种情况下会引发异常。标准库中的实现通常引发 KeyErrorIndexError

运算符 描述 示例 可重载
[] 索引访问器 ary[i]
[]? 可空索引访问器 ary[i]?
可见性(Visibility)

方法默认是公开的:编译器总是允许你调用它们。因此没有 public 关键字。

方法可以标记为 privateprotected

私有方法(Private Methods)

私有方法只能在没有接收者的情况下调用,即没有点号之前的内容。唯一的例外是 self 作为接收者:

class Person
  private def say(message)
    puts message
  end

  def say_hello
    say "hello"      # 正确,没有接收者
    self.say "hello" # 正确,self 是接收者,但这是允许的。

    other = Person.new
    other.say "hello" # 错误,other 是接收者
  end
end

请注意,私有方法对子类可见:

class Employee < Person
  def say_bye
    say "bye" # 正确
  end
end
私有类型(Private Types)

私有类型只能在其定义的命名空间内引用,并且永远不能完全限定。

class Foo
  private class Bar
  end

  Bar      # 正确
  Foo::Bar # 错误
end

Foo::Bar # 错误

private 可以与 classmodulelibenumalias 和常量一起使用:

class Foo
  private ONE = 1

  ONE # => 1
end

Foo::ONE # 错误
受保护方法(Protected Methods)

受保护方法只能在以下情况下调用:

  1. 与当前类型相同的类型的实例
  2. 与当前类型相同的命名空间(类、结构体、模块等)中的实例
# 示例 1

class Person
  protected def say(message)
    puts message
  end

  def say_hello
    say "hello"      # 正确,隐式的 self 是 Person
    self.say "hello" # 正确,self 是 Person

    other = Person.new "Other"
    other.say "hello" # 正确,other 是 Person
  end
end

class Animal
  def make_a_person_talk
    person = Person.new
    person.say "hello" # 错误:person 是 Person,但当前类型是 Animal
  end
end

one_more = Person.new "One more"
one_more.say "hello" # 错误:one_more 是 Person,但当前类型是 Program

# 示例 2

module Namespace
  class Foo
    protected def foo
      puts "Hello"
    end
  end

  class Bar
    def bar
      # 正确,因为 Foo 和 Bar 在 Namespace 下
      Foo.new.foo
    end
  end
end

Namespace::Bar.new.bar

受保护方法只能从其类或其子类的作用域中调用。这包括类作用域和类方法和实例方法的主体,以及包含或继承该类型的所有类型和该命名空间中的所有类型。

class Parent
  protected def self.protected_method
  end

  Parent.protected_method # 正确

  def instance_method
    Parent.protected_method # 正确
  end

  def self.class_method
    Parent.protected_method # 正确
  end
end

class Child < Parent
  Parent.protected_method # 正确

  def instance_method
    Parent.protected_method # 正确
  end

  def self.class_method
    Parent.protected_method # 正确
  end
end

class Parent::Sub
  Parent.protected_method # 正确

  def instance_method
    Parent.protected_method # 正确
  end

  def self.class_method
    Parent.protected_method # 正确
  end
end
私有顶层方法(Private Top-Level Methods)

私有顶层方法仅在当前文件中可见。

# one.cr

private def greet
  puts "Hello"
end

greet # => "Hello"

# two.cr

require "./one"

greet # 未定义的局部变量或方法 'greet'

这允许你在文件中定义仅在文件中可见的辅助方法。

私有顶层类型(Private Top-Level Types)

私有顶层类型仅在当前文件中可见。

# one.cr

private class Greeter
  def self.greet
    "Hello"
  end
end

Greeter.greet # => "Hello"

# two.cr

require "./one"

Greeter.greet # 未定义的常量 'Greeter'
继承(Inheritance)

除了层次结构的根 Object 之外,每个类都继承自另一个类(其超类)。如果没有指定超类,则类默认为 Reference,结构体默认为 Struct

类继承超类的所有实例变量、所有实例和类方法,包括其构造函数(newinitialize)。

class Person
  def initialize(@name : String)
  end

  def greet
    puts "Hi, I'm #{@name}"
  end
end

class Employee < Person
end

employee = Employee.new "John"
employee.greet # "Hi, I'm John"

如果类定义了 newinitialize,则其超类的构造函数不会被继承:

class Person
  def initialize(@name : String)
  end
end

class Employee < Person
  def initialize(@name : String, @company_name : String)
  end
end

Employee.new "John", "Acme" # 正确
Employee.new "Peter"        # 错误:'Employee:Class#new' 的参数数量错误(1 对 2)

你可以在派生类中覆盖方法:

class Person
  def greet(msg)
    puts "Hi, #{msg}"
  end
end

class Employee < Person
  def greet(msg)
    puts "Hello, #{msg}"
  end
end

p = Person.new
p.greet "everyone" # "Hi, everyone"

e = Employee.new
e.greet "everyone" # "Hello, everyone"

除了覆盖方法,你还可以通过使用类型限制来定义专门的方法:

class Person
  def greet(msg)
    puts "Hi, #{msg}"
  end
end

class Employee < Person
  def greet(msg : Int32)
    puts "Hi, this is a number: #{msg}"
  end
end

e = Employee.new
e.greet "everyone" # "Hi, everyone"

e.greet 1 # "Hi, this is a number: 1"
super

你可以使用 super 调用超类的方法:

class Person
  def greet(msg)
    puts "Hello, #{msg}"
  end
end

class Employee < Person
  def greet(msg)
    super # 等同于:super(msg)
    super("another message")
  end
end

如果没有参数或括号,super 会接收方法的所有参数作为参数。否则,它会接收你传递给它的参数。

协变与逆变(Covariance and Contravariance)

继承在处理数组时可能会有些棘手。在使用继承时,我们必须小心声明对象数组。例如,考虑以下代码:

class Foo
end

class Bar < Foo
end

foo_arr = [Bar.new] of Foo  # => [#<Bar:0x10215bfe0>] : Array(Foo)
bar_arr = [Bar.new]         # => [#<Bar:0x10215bfd0>] : Array(Bar)
bar_arr2 = [Foo.new] of Bar # 编译器错误

Foo 数组可以容纳 FooBar,但 Bar 数组只能容纳 Bar 及其子类。

一个可能会让你困惑的地方是自动类型转换。例如,以下代码不会工作:

class Foo
end

class Bar < Foo
end

class Test
  @arr : Array(Foo)

  def initialize
    @arr = [Bar.new]
  end
end

我们已经将 @arr 声明为 Array(Foo) 类型,因此我们可能会认为可以开始将 Bar 放入其中。不完全正确。在 initialize 中,[Bar.new] 表达式的类型是 Array(Bar),仅此而已。而 Array(Bar) 不能赋值给 Array(Foo) 实例变量。

正确的做法是什么?更改表达式,使其具有正确的类型:Array(Foo)(参见上面的示例)。

class Foo
end

class Bar < Foo
end

class Test
  @arr : Array(Foo)

  def initialize
    @arr = [Bar.new] of Foo
  end
end

这只是针对一种类型(Array)和一种操作(赋值),上述逻辑在其他类型和赋值中会有所不同,通常协变与逆变并不完全支持。

虚拟与抽象类型

当一个变量的类型在同一个类层次结构下结合了不同的类型时,它的类型就变成了一个虚拟类型。这适用于除Reference、Value、Int和Float之外的每个类和结构体。例如:

class Animal
end

class Dog < Animal
  def talk
    "Woof!"
  end
end

class Cat < Animal
  def talk
    "Miau"
  end
end

class Person
  getter pet

  def initialize(@name : String, @pet : Animal)
  end
end

john = Person.new "John", Dog.new
peter = Person.new "Peter", Cat.new

如果你使用工具层次结构命令编译上述程序,你会看到Person类的如下信息:

- class Object
  |
  +- class Reference
     |
     +- class Person
            @name : String
            @pet : Animal+

你可以看到@pet是Animal+。这里的+表示它是一个虚拟类型,意思是“任何继承自Animal的类,包括Animal本身”。

编译器总是会将同一层次结构下的类型联合解析为虚拟类型:

if some_condition
  pet = Dog.new
else
  pet = Cat.new
end

# pet : Animal+

编译器总是会对同一层次结构下的类和结构体执行此操作:它会找到所有类型继承的第一个超类(不包括Reference、Value、Int和Float)。如果找不到,类型联合将保持不变。

编译器这样做的真正原因是能够通过不创建各种不同的相似联合来加快程序的编译速度,同时也使生成的代码体积更小。但另一方面,这也是合理的:同一层次结构下的类应该以相似的方式行为。

让我们让John的宠物说话:

john.pet.talk # 错误:未定义的方法'talk'用于Animal

我们得到一个错误,因为编译器现在将@pet视为Animal+,其中包括Animal。由于它找不到talk方法,所以报错。

编译器不知道的是,对我们来说,Animal永远不会被实例化,因为实例化一个Animal没有意义。我们可以通过将类标记为abstract来告诉编译器这一点:

abstract class Animal
end

现在代码可以编译了:

john.pet.talk # => "Woof!"

将一个类标记为abstract也会阻止我们创建它的实例:

Animal.new # 错误:无法实例化抽象类Animal

为了更明确地表示Animal必须定义一个talk方法,我们可以将其添加到Animal作为一个抽象方法:

abstract class Animal
  # 让这个动物说话
  abstract def talk
end

通过将一个方法标记为abstract,编译器将检查所有子类是否实现了这个方法(匹配参数类型和名称),即使程序没有使用它们。

抽象方法也可以在模块中定义,编译器将检查包含类型是否实现了它们。

类方法¶

类方法是与类或模块相关联的方法,而不是与特定实例相关联的方法。

module CaesarCipher
  def self.encrypt(string : String)
    string.chars.map { |char| ((char.upcase.ord - 52) % 26 + 65).chr }.join
  end
end

CaesarCipher.encrypt("HELLO") # => "URYYB"

类方法通过在方法名前加上类型名称和一个点来定义。

def CaesarCipher.decrypt(string : String)
  encrypt(string)
end

当它们在类或模块的作用域内定义时,使用self比使用类名更方便。

类方法也可以通过扩展一个模块来定义。

类方法可以以其定义时的名称调用(例如CaesarCipher.decrypt("HELLO"))。当从同一类或模块作用域内调用时,接收者可以是self或隐式的(如encrypt(string))。

类方法不在类的实例作用域内;相反,通过类作用域访问它。

class Foo
  def self.shout(str : String)
    puts str.upcase
  end

  def baz
    self.class.shout("baz")
  end
end

Foo.new.baz # => BAZ

构造函数¶

构造函数是创建类的新实例的普通类方法。默认情况下,Crystal中的所有类至少有一个名为new的构造函数,但它们也可以定义具有不同名称的其他构造函数。

类变量¶

类变量是与类相关联的变量,而不是与实例相关联的变量。它们以两个“at”符号(@@)作为前缀。例如:

class Counter
  @@instances = 0

  def initialize
    @@instances += 1
  end

  def self.instances
    @@instances
  end
end

Counter.instances # => 0
Counter.new
Counter.new
Counter.new
Counter.instances # => 3

类变量可以从类方法或实例方法中读取和写入。

它们的类型是通过全局类型推断算法推断的。

类变量由子类继承,其含义是:它们的类型相同,但每个类在运行时具有不同的值。例如:

class Parent
  @@numbers = [] of Int32

  def self.numbers
    @@numbers
  end
end

class Child < Parent
end

Parent.numbers # => []
Child.numbers  # => []

Parent.numbers << 1
Parent.numbers # => [1]
Child.numbers  # => []

类变量也可以与模块和结构体相关联。如上所述,它们通过包含/子类化类型继承。

finalize 方法

如果一个类定义了 finalize 方法,那么当该类的实例被垃圾回收时,该方法将被调用:

class Foo
  def finalize
    # 当 Foo 被垃圾回收时调用
    # 用于释放非托管资源(例如 C 库、结构体)
  end
end

使用此方法可以释放由外部库分配的资源,这些资源不受 Crystal 垃圾回收器直接管理。

可以在 IO::FileDescriptor#finalizeOpenSSL::Digest#finalize 中找到此类示例。

注意事项
  1. 初始化完成finalize 方法只有在对象通过 initialize 方法完全初始化后才会被调用。如果在 initialize 方法中抛出异常,finalize 将不会被调用。如果你的类定义了 finalize 方法,请确保捕获 initialize 方法中可能抛出的任何异常并释放资源。

  2. 避免在垃圾回收期间分配新对象:在垃圾回收期间分配任何新的对象实例可能会导致未定义的行为,并很可能使程序崩溃。因此,在 finalize 方法中应避免创建新对象。

示例

以下是一个简单的示例,展示了如何使用 finalize 方法释放资源:

class ResourceHandler
  def initialize
    # 模拟分配外部资源
    puts "Resource allocated"
  end

  def finalize
    # 模拟释放外部资源
    puts "Resource released"
  end
end

# 创建对象
handler = ResourceHandler.new

# 手动触发垃圾回收(仅用于演示,通常不需要手动调用)
GC.collect

# 输出:
# Resource allocated
# Resource released

在这个示例中,当 ResourceHandler 的实例被垃圾回收时,finalize 方法会被调用,从而释放资源。

模块(Modules)

模块在 Crystal 中有两个主要用途:

  1. 作为命名空间:用于定义其他类型、方法和常量。
  2. 作为部分类型:可以被混入(mixed in)其他类型中。

模块作为命名空间

模块可以用作命名空间,以避免命名冲突。例如:

module Curses
  class Window
  end
end

Curses::Window.new

库的作者通常会将定义放在模块中,以避免命名冲突。标准库通常没有命名空间,因为它的类型和方法非常常见,这样可以避免写长名称。

模块作为部分类型

模块可以通过 includeextend 被混入其他类型中。

include:将模块方法作为实例方法

include 会将模块中定义的方法作为实例方法混入类中:

module ItemsSize
  def size
    items.size
  end
end

class Items
  include ItemsSize

  def items
    [1, 2, 3]
  end
end

items = Items.new
items.size # => 3

在上面的例子中,size 方法从模块中被“粘贴”到了 Items 类中。实际上,每个类型都有一个祖先列表(或父类列表),默认情况下这个列表从超类开始。当模块被 include 时,它们会被添加到这个列表的前面。当在类型中找不到方法时,会在这个列表中查找。当调用 super 时,会使用祖先列表中的第一个类型。

模块也可以包含其他模块,因此当在模块中找不到方法时,会在被包含的模块中查找。

extend:将模块方法作为类方法

extend 会将模块中定义的方法作为类方法混入类中:

module SomeSize
  def size
    3
  end
end

class Items
  extend SomeSize
end

Items.size # => 3

includeextend 都会使模块中定义的常量对包含/扩展的类型可用。

在顶层使用 includeextend

includeextend 可以在顶层使用,以避免重复写命名空间(尽管命名冲突的可能性会增加):

module SomeModule
  class SomeType
  end

  def some_method
    1
  end
end

include SomeModule

SomeType.new # OK,等同于 SomeModule::SomeType
some_method  # OK,1

extend self 模式

模块中常见的模式是 extend self

module Base64
  extend self

  def encode64(string)
    # ...
  end

  def decode64(string)
    # ...
  end
end

这样,模块既可以作为命名空间使用:

Base64.encode64 "hello" # => "aGVsbG8="

也可以被包含到程序中,直接调用其方法而无需命名空间:

include Base64

encode64 "hello" # => "aGVsbG8="

为了使这种模式有用,方法名称应该与模块相关,否则命名冲突的可能性很高。

模块不能被实例化

模块不能被实例化:

module Moo
end

Moo.new # 未定义方法 'new' for Moo:Module

模块的类型检查

模块也可以用于类型检查。

如果定义了两个模块 AB

module A; end

module B; end

可以将它们混入类中:

class One
  include A
end

class Two
  include B
end

class Three < One
  include B
end

然后,我们可以根据类及其包含的模块对实例进行类型检查:

one = One.new
typeof(one)  # => One
one.is_a?(A) # => true
one.is_a?(B) # => false

three = Three.new
typeof(three)  # => Three
three.is_a?(A) # => true
three.is_a?(B) # => true

这允许你基于模块类型而不是类来定义数组和方法:

one = One.new
two = Two.new
three = Three.new

new_array = Array(A).new
new_array << one   # Ok,One 包含模块 A
new_array << three # Ok,Three 继承模块 A

new_array << two # 错误,因为 Two 既不继承也不包含模块 A

泛型(Generics)

泛型允许你根据另一个类型参数化一个类型。泛型提供了类型多态性。考虑一个 Box 类型:

class MyBox(T)
  def initialize(@value : T)
  end

  def value
    @value
  end
end

int_box = MyBox(Int32).new(1)
int_box.value # => 1 (Int32)

string_box = MyBox(String).new("hello")
string_box.value # => "hello" (String)

another_box = MyBox(String).new(1) # 错误,Int32 不匹配 String

泛型在实现集合类型时特别有用。ArrayHashSet 都是泛型类型,Pointer 也是。

允许多个类型参数:

class MyDictionary(K, V)
end

类型参数可以使用任何名称:

class MyDictionary(KeyType, ValueType)
end

泛型类方法

在泛型类型的类方法中,当接收者的类型参数未指定时,类型限制会成为自由变量。这些自由变量随后会从调用的参数中推断出来。例如,也可以这样写:

int_box = MyBox.new(1)          # : MyBox(Int32)
string_box = MyBox.new("hello") # : MyBox(String)

在上面的代码中,我们不必指定 MyBox 的类型参数,编译器会通过以下过程推断它们:

  1. 编译器从 MyBox#initialize(@value : T) 生成一个 MyBox.new(value : T) 方法,该方法没有显式定义的自由变量。
  2. MyBox.new(value : T) 中的 T 尚未绑定到类型,而 TMyBox 的类型参数,因此编译器将其绑定到给定参数的类型。
  3. 编译器生成的 MyBox.new(value : T) 调用 MyBox(T)#initialize(@value : T),此时 T 已被绑定。

通过这种方式,泛型类型的使用变得更加简便。注意,#initialize 方法本身不需要指定任何自由变量即可实现这一点。

同样的类型推断也适用于 .new 以外的类方法:

class MyBox(T)
  def self.nilable(x : T)
    MyBox(T?).new(x)
  end
end

MyBox.nilable(1)     # : MyBox(Int32 | Nil)
MyBox.nilable("foo") # : MyBox(String | Nil)

在这些例子中,T 仅作为自由变量被推断,因此接收者本身的 T 仍然未绑定。因此,在无法推断 T 的情况下调用其他类方法会出错:

module Foo(T)
  def self.foo
    T
  end

  def self.foo(x : T)
    foo
  end
end

Foo.foo(1)        # 错误:无法推断泛型模块 Foo(T) 的类型参数 T。请显式提供
Foo(Int32).foo(1) # OK

泛型结构体和模块

结构体和模块也可以是泛型的。当模块是泛型时,你可以这样包含它:

module Moo(T)
  def t
    T
  end
end

class Foo(U)
  include Moo(U)

  def initialize(@value : U)
  end
end

foo = Foo.new(1)
foo.t # Int32

注意,在上面的例子中,T 变成了 Int32,因为 Foo.new(1) 使得 U 成为 Int32,进而通过包含泛型模块使得 T 成为 Int32

泛型类型继承

泛型类和结构体可以被继承。在继承时,你可以指定泛型类型的实例,或者委托类型变量:

class Parent(T)
end

class Int32Child < Parent(Int32)
end

class GenericChild(T) < Parent(T)
end

可变数量参数的泛型

我们可以使用 splat 操作符定义一个具有可变数量参数的泛型类。

让我们看一个例子,定义一个名为 Foo 的泛型类,然后使用不同数量的类型变量:

class Foo(*T)
  getter content

  def initialize(*@content : *T)
  end
end

# 2 个类型变量:
# (显式指定类型变量)
foo = Foo(Int32, String).new(42, "Life, the Universe, and Everything")

p typeof(foo) # => Foo(Int32, String)
p foo.content # => {42, "Life, the Universe, and Everything"}

# 3 个类型变量:
# (编译器推断类型变量)
bar = Foo.new("Hello", ["Crystal", "!"], 140)
p typeof(bar) # => Foo(String, Array(String), Int32)

在以下示例中,我们通过继承定义类,并为泛型类型指定实例:

class Parent(*T)
end

# 我们定义 `StringChild` 继承自 `Parent` 类,
# 并使用 `String` 作为泛型类型参数:
class StringChild < Parent(String)
end

# 我们定义 `Int32StringChild` 继承自 `Parent` 类,
# 并使用 `Int32` 和 `String` 作为泛型类型参数:
class Int32StringChild < Parent(Int32, String)
end

如果我们需要实例化一个没有参数的类,可以这样做:

class Parent(*T)
end

foo = Parent().new
p typeof(foo) # => Parent()

但我们不应将 0 个参数与未指定泛型类型变量混淆。以下示例将引发错误:

class Parent(*T)
end

foo = Parent.new # 错误:无法推断泛型类 Parent(*T) 的类型参数 T。请显式提供

class Foo < Parent # 错误:继承 Parent(*T) 时必须指定泛型类型参数
end

结构体(Structs)

你可以使用 struct 来定义类型,而不是使用 class

struct Point
  property x, y

  def initialize(@x : Int32, @y : Int32)
  end
end

结构体继承自 Value,因此它们分配在栈上并通过值传递:当传递给方法、从方法返回或赋值给变量时,实际上传递的是值的副本(而类继承自 Reference,分配在堆上并通过引用传递)。

因此,结构体主要用于不可变数据类型和/或其他类型的无状态包装器,通常是为了性能考虑,以避免在传递小副本时可能更高效的大量小内存分配(更多细节请参阅性能指南)。

可变结构体仍然是允许的,但在编写涉及可变性的代码时应小心,以避免下面描述的意外情况。

按值传递

结构体总是按值传递,即使你从该结构体的方法中返回 self

struct Counter
  def initialize(@count : Int32)
  end

  def plus
    @count += 1
    self
  end
end

counter = Counter.new(0)
counter.plus.plus # => Counter(@count=2)
puts counter      # => Counter(@count=1)

注意,链式调用的 plus 返回了预期的结果,但只有第一次调用修改了变量 counter,因为第二次调用操作的是从第一次调用传递过来的结构体的副本,而这个副本在表达式执行后被丢弃。

在处理结构体内部的可变类型时也应小心:

class Klass
  property array = ["str"]
end

struct Strukt
  property array = ["str"]
end

def modify(object)
  object.array << "foo"
  object.array = ["new"]
  object.array << "bar"
end

klass = Klass.new
puts modify(klass) # => ["new", "bar"]
puts klass.array   # => ["new", "bar"]

strukt = Strukt.new
puts modify(strukt) # => ["new", "bar"]
puts strukt.array   # => ["str", "foo"]

这里 strukt 发生了什么:

  1. 数组通过引用传递,因此 ["str"] 的引用存储在 strukt 的属性中。
  2. strukt 传递给 modify 时,传递的是 strukt 的副本,其中包含对数组的引用。
  3. object.array << "foo" 修改了 array 引用的数组(向其中添加了元素)。
  4. 这也反映在原始的 strukt 中,因为它持有对同一数组的引用。
  5. object.array = ["new"]strukt 副本中的引用替换为对新数组的引用。
  6. object.array << "bar" 向这个新创建的数组追加内容。
  7. modify 返回对这个新数组的引用,并打印其内容。
  8. 对这个新数组的引用仅保存在 strukt 的副本中,而不在原始 strukt 中,因此原始 strukt 只保留了第一条语句的结果,而不是其他两条语句的结果。

Klass 是一个类,因此它通过引用传递给 modify,而 object.array = ["new"] 将对新创建的数组的引用保存在原始的 klass 对象中,而不是像 strukt 那样保存在副本中。

继承

  1. 结构体隐式继承自 Struct,而 Struct 继承自 Value。类隐式继承自 Reference
  2. 结构体不能继承自非抽象结构体。

第二点有其原因:结构体具有非常明确的内存布局。例如,上面的 Point 结构体占用 8 个字节。如果你有一个 Point 数组,这些点会嵌入到数组的缓冲区中:

# 数组的缓冲区将为每个 Point 分配 8 个字节
ary = [] of Point

如果 Point 被继承,那么这种类型的数组也应考虑到其他类型可能存在于其中,因此每个元素的大小应增加以适应这一点。这显然是出乎意料的。因此,非抽象结构体不能被继承。另一方面,抽象结构体将有子类,因此可以预期它们的数组会考虑到其中可能包含多种类型的情况。

结构体也可以包含模块,并且可以像类一样是泛型的。

记录(Records)

Crystal 标准库提供了 record 宏。它简化了具有初始化器和一些辅助方法的基本结构体类型的定义。

record Point, x : Int32, y : Int32

Point.new 1, 2 # => #<Point(@x=1, @y=2)>

record 宏展开为以下结构体定义:

struct Point
  getter x : Int32

  getter y : Int32

  def initialize(@x : Int32, @y : Int32)
  end

  def copy_with(x _x = @x, y _y = @y)
    self.class.new(_x, _y)
  end

  def clone
    self.class.new(@x.clone, @y.clone)
  end
end

常量(Constants)

常量可以在顶层或其他类型内部声明。它们必须以大写字母开头:

PI = 3.14

module Earth
  RADIUS = 6_371_000
end

PI            # => 3.14
Earth::RADIUS # => 6_371_000

尽管编译器没有强制要求,但常量通常以全大写字母命名,并用下划线分隔单词。

常量的定义可以调用方法并包含复杂的逻辑:

TEN = begin
  a = 0
  while a < 10
    a += 1
  end
  a
end

TEN # => 10

伪常量(Pseudo Constants)

Crystal 提供了一些伪常量,它们提供了关于正在执行的源代码的反射数据。

  • __LINE__ 是当前正在执行的 Crystal 文件中的行号。当 __LINE__ 用作默认参数值时,它表示方法调用位置的行号。
  • __END_LINE__ 是调用块的结束行号。只能用作默认参数值。
  • __FILE__ 引用当前正在执行的 Crystal 文件的完整路径。
  • __DIR__ 引用当前正在执行的 Crystal 文件所在目录的完整路径。
# 假设此示例代码保存在:/crystal_code/pseudo_constants.cr
#
def pseudo_constants(caller_line = __LINE__, end_of_caller = __END_LINE__)
  puts "Called from line number: #{caller_line}"
  puts "Currently at line number: #{__LINE__}"
  puts "End of caller block is at: #{end_of_caller}"
  puts "File path is: #{__FILE__}"
  puts "Directory file is in: #{__DIR__}"
end

begin
  pseudo_constants
end

# 程序输出:
# Called from line number: 13
# Currently at line number: 5
# End of caller block is at: 14
# File path is: /crystal_code/pseudo_constants.cr
# Directory file is in: /crystal_code

动态赋值

不支持使用链式赋值或多重赋值动态为常量赋值,这会导致语法错误。

ONE, TWO, THREE = 1, 2, 3 # 语法错误:常量不允许多重赋值

枚举(Enums)

注意:本页介绍的是 Crystal 的枚举。关于 C 的枚举,请参阅 C 绑定枚举

枚举是一组整数值,每个值都有一个关联的名称。例如:

enum Color
  Red
  Green
  Blue
end

枚举通过 enum 关键字定义,后跟其名称。枚举的主体包含值。值从 0 开始,并依次递增。默认值可以被覆盖:

enum Color
  Red        # 0
  Green      # 1
  Blue   = 5 # 覆盖为 5
  Yellow     # 6 (5 + 1)
end

枚举中的每个常量都具有该枚举的类型:

Color::Red # :: Color

要获取其基础值,可以调用 value 方法:

Color::Green.value # => 1

默认情况下,值的类型是 Int32,但可以更改:

enum Color : UInt8
  Red
  Green
  Blue
end

Color::Red.value # :: UInt8

只有整数类型可以作为基础类型。

所有枚举都继承自 Enum


标志枚举(Flags Enums)

枚举可以用 @[Flags] 注解标记。这会改变默认值:

@[Flags]
enum IOMode
  Read  # 1
  Write # 2
  Async # 4
end

@[Flags] 注解使第一个常量的值为 1,后续常量的值依次乘以 2

隐式常量 NoneAll 会自动添加到这些枚举中,其中 None 的值为 0All 的值为所有常量的“或”运算结果。

IOMode::None.value # => 0
IOMode::All.value  # => 7

此外,某些 Enum 方法会检查 @[Flags] 注解。例如:

puts(Color::Red)                    # 输出 "Red"
puts(IOMode::Write | IOMode::Async) # 输出 "Write, Async"

从整数创建枚举

可以从整数创建枚举:

puts Color.new(1) # => 输出 "Green"

如果值不对应于枚举的任何常量,也是允许的:该值仍为 Color 类型,但在打印时会显示其基础值:

puts Color.new(10) # => 输出 "10"

此方法主要用于将 C 中的整数转换为 Crystal 中的枚举。


方法

与类或结构体类似,可以为枚举定义方法:

enum Color
  Red
  Green
  Blue

  def red?
    self == Color::Red
  end
end

Color::Red.red?  # => true
Color::Blue.red? # => false

允许使用类变量,但不允许使用实例变量。


使用

当方法参数具有枚举类型限制时,它接受枚举常量或符号。符号会自动转换为枚举常量,如果转换失败则会引发编译时错误。

def paint(color : Color)
  puts "Painting using the color #{color}"
end

paint Color::Red

paint :red # 自动转换为 `Color::Red`

paint :yellow # 错误:预期参数 #1 为 `paint` 匹配枚举 Color 的成员

相同的自动转换不适用于 case 语句。要将枚举与 case 语句一起使用,请参阅 case 枚举值

块(Blocks)和过程(Procs)

方法可以接受一个代码块,并通过 yield 关键字执行。例如:

def twice(&)
  yield
  yield
end

twice do
  puts "Hello!"
end

上述程序会打印两次 "Hello!",每次 yield 都会执行一次。

要定义一个接收块的方法,只需在方法内部使用 yield,编译器会自动识别。你可以通过在最后一个参数前加上 & 来显式声明一个块参数。在上面的例子中,我们使用了匿名块参数(仅写 &),但也可以为它命名:

def twice(&block)
  yield
  yield
end

在这个例子中,块参数的名称无关紧要,但在更高级的用法中会变得重要。

要调用方法并传递块,可以使用 do ... end{ ... }。以下写法都是等价的:

twice() do
  puts "Hello!"
end

twice do
  puts "Hello!"
end

twice { puts "Hello!" }

do ... end{ ... } 的区别在于,do ... end 绑定到最左边的调用,而 { ... } 绑定到最右边的调用:

foo bar do
  something
end

# 等同于
foo(bar) do
  something
end

foo bar { something }

# 等同于
foo(bar { something })

这种设计是为了允许使用 do ... end 创建领域特定语言(DSL),使其读起来像普通英语:

open file "foo.cr" do
  something
end

# 等同于:
open(file("foo.cr")) do
  something
end

你不会希望上述代码变成:

open(file("foo.cr") do
  something
end)

重载(Overloads)

两个方法,一个带 yield,另一个不带,会被视为不同的重载,如重载部分所述。


yield 参数

yield 表达式类似于调用,可以接收参数。例如:

def twice(&)
  yield 1
  yield 2
end

twice do |i|
  puts "Got #{i}"
end

上述代码会打印 "Got 1" 和 "Got 2"。

也可以使用花括号语法:

twice { |i| puts "Got #{i}" }

你可以传递多个值:

def many(&)
  yield 1, 2, 3
end

many do |x, y, z|
  puts x + y + z
end

# 输出:6

块可以指定比 yield 传递的参数更少的参数:

def many(&)
  yield 1, 2, 3
end

many do |x, y|
  puts x + y
end

# 输出:3

如果块参数比 yield 传递的参数多,则会报错:

def twice(&)
  yield
  yield
end

twice do |i| # 错误:块参数过多
end

每个块参数的类型是 yield 表达式中该位置的所有可能类型。例如:

def some(&)
  yield 1, 'a'
  yield true, "hello"
  yield 2, nil
end

some do |first, second|
  # first 是 Int32 | Bool
  # second 是 Char | String | Nil
end

下划线 _ 也可以作为块参数:

def pairs(&)
  yield 1, 2
  yield 2, 4
  yield 3, 6
end

pairs do |_, second|
  print second
end

# 输出:246

单参数简写语法

如果块只有一个参数并对其调用方法,则可以用简写语法替换块。

以下写法:

method do |param|
  param.some_method
end

method { |param| param.some_method }

都可以简写为:

method &.some_method

或者:

method(&.some_method)

在这两种情况下,&.some_method 都是传递给 method 的参数。它在语法上等同于块形式,仅仅是语法糖,不会影响性能。

如果方法有其他必需参数,简写语法参数也应放在方法的参数列表中:

["a", "b"].join(",", &.upcase)

等同于:

["a", "b"].join(",") { |s| s.upcase }

简写语法也支持参数:

["i", "o"].join(",", &.upcase(Unicode::CaseOptions::Turkic))

运算符也可以调用:

method &.+(2)
method(&.[index])

yield

yield 表达式本身有一个值:块的最后一个表达式的值。例如:

def twice(&)
  v1 = yield 1
  puts v1

  v2 = yield 2
  puts v2
end

twice do |i|
  i + 1
end

上述代码会打印 "2" 和 "3"。

yield 表达式的值主要用于转换和过滤值。最好的例子是 Enumerable#mapEnumerable#select

ary = [1, 2, 3]
ary.map { |x| x + 1 }         # => [2, 3, 4]
ary.select { |x| x % 2 == 1 } # => [1, 3]

一个简单的转换方法:

def transform(value, &)
  yield value
end

transform(1) { |x| x + 1 } # => 2

最后一个表达式的结果是 2,因为 transform 方法的最后一个表达式是 yield,它的值是块的最后一个表达式。


类型限制

可以使用 &block 语法限制块的类型。例如:

def transform_int(start : Int32, &block : Int32 -> Int32)
  result = yield start
  result * 2
end

transform_int(3) { |x| x + 2 } # => 10
transform_int(3) { |x| "foo" } # 错误:预期块返回 Int32,而不是 String

break

块中的 break 表达式会提前退出方法:

def thrice(&)
  puts "Before 1"
  yield 1
  puts "Before 2"
  yield 2
  puts "Before 3"
  yield 3
  puts "After 3"
end

thrice do |i|
  if i == 2
    break
  end
end

上述代码会打印 "Before 1" 和 "Before 2"。由于 breakthrice 方法没有执行 puts "Before 3"

break 也可以接受参数:这些参数会成为方法的返回值。例如:

def twice(&)
  yield 1
  yield 2
end

twice { |i| i + 1 }         # => 3
twice { |i| break "hello" } # => "hello"

第一次调用的值是 3,因为 twice 方法的最后一个表达式是 yield,它获取了块的值。第二次调用的值是 "hello",因为执行了 break

如果有条件 break,调用的返回值类型将是块的值类型和多个 break 类型的联合:

value = twice do |i|
  if i == 1
    break "hello"
  end
  i + 1
end
value # :: Int32 | String

如果 break 接收多个参数,它们会自动转换为元组:

values = twice { break 1, 2 }
values # => {1, 2}

如果 break 没有参数,则等同于接收一个 nil 参数:

value = twice { break }
value # => nil

如果 break 在多个嵌套块中使用,只会跳出最内层的块:

def foo(&)
  pp "before yield"
  yield
  pp "after yield"
end

foo do
  pp "start foo1"
  foo do
    pp "start foo2"
    break
    pp "end foo2"
  end
  pp "end foo1"
end

# 输出:
# "before yield"
# "start foo1"
# "before yield"
# "start foo2"
# "end foo1"
# "after yield"

注意,你不会得到两个 "after yield",也不会得到 "end foo2"。


next

块中的 next 表达式会提前退出块(而不是方法)。例如:

def twice(&)
  yield 1
  yield 2
end

twice do |i|
  if i == 1
    puts "Skipping 1"
    next
  end

  puts "Got #{i}"
end

# 输出:
# Skipping 1
# Got 2

next 表达式可以接受参数,这些参数会成为 yield 表达式的值:

def twice(&)
  v1 = yield 1
  puts v1

  v2 = yield 2
  puts v2
end

twice do |i|
  if i == 1
    next 10
  end

  i + 1
end

# 输出
# 10
# 3

如果 next 接收多个参数,它们会自动转换为元组。如果没有参数,则等同于接收一个 nil 参数。


with ... yield

yield 表达式可以通过 with 关键字修改,以指定一个对象作为块内方法调用的默认接收者:

class Foo
  def one
    1
  end

  def yield_with_self(&)
    with self yield
  end

  def yield_normally(&)
    yield
  end
end

def one
  "one"
end

Foo.new.yield_with_self { one } # => 1
Foo.new.yield_normally { one }  # => "one"

解构块参数

块参数可以指定用括号括起来的子参数:

array = [{1, "one"}, {2, "two"}]
array.each do |(number, word)|
  puts "#{number}: #{word}"
end

上述代码是以下写法的语法糖:

array = [{1, "one"}, {2, "two"}]
array.each do |arg|
  number = arg[0]
  word = arg[1]
  puts "#{number}: #{word}"
end

这意味着任何响应 [] 方法的类型都可以在块参数中解构。

参数解构可以嵌套:

ary = [
  {1, {2, {3, 4}}},
]

ary.each do |(w, (x, (y, z)))|
  w # => 1
  x # => 2
  y # => 3
  z # => 4
end

支持展开参数:

ary = [
  [1, 2, 3, 4, 5],
]

ary.each do |(x, *y, z)|
  x # => 1
  y # => [2, 3, 4]
  z # => 5
end

对于元组参数,可以利用自动解构,不需要括号:

array = [{1, "one", true}, {2, "two", false}]
array.each do |number, word, bool|
  puts "#{number}: #{word} #{bool}"
end

Hash(K, V)#eachTuple(K, V) 传递给块,因此迭代键值对时可以使用自动解构:

h = {"foo" => "bar"}
h.each do |key, value|
  key   # => "foo"
  value # => "bar"
end

性能

当使用带 yield 的块时,块总是内联的:不涉及闭包、调用或函数指针。这意味着以下代码:

def twice(&)
  yield 1
  yield 2
end

twice do |i|
  puts "Got: #{i}"
end

完全等同于:

i = 1
puts "Got: #{i}"
i = 2
puts "Got: #{i}"

例如,标准库中的整数 times 方法允许你编写:

3.times do |i|
  puts i
end

这看起来很高级,但它是否和 C 的 for 循环一样快?答案是:是的!

这是 Int#times 的定义:

struct Int
  def times(&)
    i = 0
    while i < self
      yield i
      i += 1
    end
  end
end

由于非捕获块总是内联的,上述方法调用完全等同于:

i = 0
while i < 3
  puts i
  i += 1
end

不必担心使用块来增强可读性或代码复用,它不会影响最终的可执行性能。

捕获块(Capturing Blocks)

块可以被捕获并转换为 Proc,它表示一段代码及其关联的上下文:闭包数据。

要捕获块,你必须将其指定为方法的块参数,为其命名并指定输入和输出类型。例如:

def int_to_int(&block : Int32 -> Int32)
  block
end

proc = int_to_int { |x| x + 1 }
proc.call(1) # => 2

上述代码将传递给 int_to_int 的块捕获到 block 变量中,并将其从方法中返回。proc 的类型是 Proc(Int32, Int32),这是一个接受一个 Int32 参数并返回 Int32 的函数。

通过这种方式,块可以被保存为回调:

class Model
  def on_save(&block)
    @on_save_callback = block
  end

  def save
    if callback = @on_save_callback
      callback.call
    end
  end
end

model = Model.new
model.on_save { puts "Saved!" }
model.save # 输出 "Saved!"

在上面的例子中,&block 的类型没有指定:这意味着捕获的块不接受任何参数,也不返回任何值。

注意,如果没有指定返回类型,proc 调用不会返回任何值:

def some_proc(&block : Int32 ->)
  block
end

proc = some_proc { |x| x + 1 }
proc.call(1) # => nil

要返回某些值,可以指定返回类型或使用下划线 _ 来允许任何返回类型:

def some_proc(&block : Int32 -> _)
  block
end

proc = some_proc { |x| x + 1 }
proc.call(1) # 2

proc = some_proc { |x| x.to_s }
proc.call(1) # "1"

breaknext

在捕获的块中不能使用 returnbreak。可以使用 next,它会退出并给出捕获块的值。


with ... yield

在捕获的块中,不能通过 with ... yield 更改默认接收者。

Proc 字面量(Proc Literal)

捕获块与声明一个 Proc 字面量并将其传递给方法是相同的。

def some_proc(&block : Int32 -> Int32)
  block
end

x = 0
proc = ->(i : Int32) { x += i }
proc = some_proc(&proc)
proc.call(1)  # => 1
proc.call(10) # => 11
x             # => 11

Proc 字面量部分 所述,Proc 也可以从现有方法创建:

def add(x, y)
  x + y
end

adder = ->add(Int32, Int32)
adder.call(1, 2) # => 3

块转发(Block Forwarding)

要转发捕获的块,你可以使用块参数,并在表达式前加上 &

def capture(&block)
  block
end

def invoke(&block)
  block.call
end

proc = capture { puts "Hello" }
invoke(&proc) # 输出 "Hello"

在上面的例子中,invoke 接收一个块。我们不能直接将 proc 传递给它,因为 invoke 不接受常规参数,只接受块参数。我们使用 & 来指定我们确实希望将 proc 作为块参数传递。否则:

invoke(proc) # 错误:'invoke' 的参数数量错误(1 个而不是 0 个)

你实际上可以将 proc 传递给一个使用 yield 的方法:

def capture(&block)
  block
end

def twice(&)
  yield
  yield
end

proc = capture { puts "Hello" }
twice &proc

上述代码会被重写为:

proc = capture { puts "Hello" }
twice do
  proc.call
end

或者,结合 &-> 语法:

twice &->{ puts "Hello" }

或者:

def say_hello
  puts "Hello"
end

twice &->say_hello

转发非捕获块

要转发非捕获块,你必须使用 yield

def foo(&)
  yield 1
end

def wrap_foo(&)
  puts "Before foo"
  foo do |x|
    yield x
  end
  puts "After foo"
end

wrap_foo do |i|
  puts i
end

# 输出:
# Before foo
# 1
# After foo

你也可以使用 &block 语法来转发块,但这样你至少需要指定输入类型,并且生成的代码会涉及闭包,性能会更慢:

def foo(&)
  yield 1
end

def wrap_foo(&block : Int32 -> _)
  puts "Before foo"
  foo(&block)
  puts "After foo"
end

wrap_foo do |i|
  puts i
end

# 输出:
# Before foo
# 1
# After foo

如果使用 yield 就足够了,尽量避免以这种方式转发块。此外,breaknext 不允许在捕获的块中使用,因此以下代码在使用 &block 转发时将无法工作:

foo_forward do |i|
  break # 错误
end

简而言之,当涉及 yield 时,尽量避免使用 &block 转发。

闭包(Closures)

捕获的块和 Proc 字面量会捕获局部变量和 self。通过一个例子可以更好地理解这一点:

x = 0
proc = ->{ x += 1; x }
proc.call # => 1
proc.call # => 2
x         # => 2

或者从一个方法返回的 Proc

def counter
  x = 0
  ->{ x += 1; x }
end

proc = counter
proc.call # => 1
proc.call # => 2

在上面的例子中,尽管 x 是一个局部变量,但它被 Proc 字面量捕获了。在这种情况下,编译器会将 x 分配到堆上,并将其作为 Proc 的上下文数据,以确保其正常工作,因为通常局部变量存在于栈中,并在方法返回后消失。


闭包变量的类型

编译器通常对局部变量的类型有一定的智能推断。例如:

def foo(&)
  yield
end

x = 1
foo do
  x = "hello"
end
x # : Int32 | String

编译器知道在块执行后,x 可能是 Int32String(它可能知道 x 将始终是 String,因为方法总是会执行 yield;这一点在未来可能会改进)。

如果在块之后 x 被赋值为其他值,编译器会知道类型发生了变化:

x = 1
foo do
  x = "hello"
end
x # : Int32 | String

x = 'a'
x # : Char

然而,如果 xProc 捕获,其类型始终是所有赋值的混合类型:

def capture(&block)
  block
end

x = 1
capture { x = "hello" }

x = 'a'
x # : Int32 | String | Char

这是因为捕获的块可能被存储在类或实例变量中,并在指令之间在单独的线程中调用。编译器不会对此进行详尽的分析:它只是假设如果变量被 Proc 捕获,那么 Proc 调用的时间是未知的。

这种情况也发生在常规的 Proc 字面量中,即使很明显 Proc 没有被调用或存储:

x = 1
->{ x = "hello" }

x = 'a'
x # : Int32 | String | Char

别名(alias)

使用 alias 可以为类型赋予一个不同的名称:

alias PInt32 = Pointer(Int32)

ptr = PInt32.malloc(1) # : Pointer(Int32)

每次使用别名时,编译器会将其替换为它所指的类型。

别名不仅有助于避免书写冗长的类型名称,还可以用于定义递归类型:

alias RecArray = Array(Int32) | Array(RecArray)

ary = [] of RecArray
ary.push [1, 2, 3]
ary.push ary
ary # => [[1, 2, 3], [...]]

递归类型的一个实际例子是 JSON

module Json
  alias Type = Nil |
               Bool |
               Int64 |
               Float64 |
               String |
               Array(Type) |
               Hash(String, Type)
end

异常处理(Exception Handling)

Crystal 通过抛出(raise)和捕获(rescue)异常来进行错误处理。


抛出异常

你可以通过调用顶层的 raise 方法来抛出异常。与其他关键字不同,raise 是一个普通方法,有两个重载版本:一个接受 String,另一个接受 Exception 实例:

raise "OH NO!"
raise Exception.new("Some error")

String 版本会创建一个带有该消息的 Exception 实例。

只有 Exception 实例或其子类可以被抛出。


定义自定义异常

要定义自定义异常类型,只需从 Exception 继承:

class MyException < Exception
end

class MyOtherException < Exception
end

你可以像往常一样为异常定义构造函数,或者直接使用默认构造函数。


捕获异常

要捕获任何异常,可以使用 begin ... rescue ... end 表达式:

begin
  raise "OH NO!"
rescue
  puts "Rescued!"
end

# 输出:Rescued!

要访问捕获的异常,可以在 rescue 子句中指定一个变量:

begin
  raise "OH NO!"
rescue ex
  puts ex.message
end

# 输出:OH NO!

要捕获特定类型的异常(或其子类):

begin
  raise MyException.new("OH NO!")
rescue MyException
  puts "Rescued MyException"
end

# 输出:Rescued MyException

有效的类型限制可以是 ::Exception 的子类、模块类型或这些类型的联合。

要访问异常,可以使用类似于类型限制的语法:

begin
  raise MyException.new("OH NO!")
rescue ex : MyException
  puts "Rescued MyException: #{ex.message}"
end

# 输出:Rescued MyException: OH NO!

可以指定多个 rescue 子句:

begin
  # ...
rescue ex1 : MyException
  # 仅捕获 MyException...
rescue ex2 : MyOtherException
  # 仅捕获 MyOtherException...
rescue
  # 捕获其他类型的异常
end

你也可以通过指定联合类型来同时捕获多种异常:

begin
  # ...
rescue ex : MyException | MyOtherException
  # 仅捕获 MyException 或 MyOtherException
rescue
  # 捕获其他类型的异常
end

else

else 子句仅在未捕获到任何异常时执行:

begin
  something_dangerous
rescue
  # 如果有异常抛出,执行这里
else
  # 如果没有异常抛出,执行这里
end

只有在至少指定了一个 rescue 子句时,才能使用 else 子句。


ensure

ensure 子句在 begin ... endbegin ... rescue ... end 表达式的末尾执行,无论是否抛出异常:

begin
  something_dangerous
ensure
  puts "Cleanup..."
end

# 无论是否抛出异常,都会在调用 something_dangerous 后打印 "Cleanup..."

或者:

begin
  something_dangerous
rescue
  # ...
else
  # ...
ensure
  # 这里总是会执行
end

ensure 子句通常用于清理、释放资源等操作。


简写语法

异常处理有一种简写语法:假设方法或块定义是一个隐式的 begin ... end 表达式,然后指定 rescueelseensure 子句:

def some_method
  something_dangerous
rescue
  # 如果有异常抛出,执行这里
end

# 上述代码等同于:
def some_method
  begin
    something_dangerous
  rescue
    # 如果有异常抛出,执行这里
  end
end

使用 ensure

def some_method
  something_dangerous
ensure
  # 这里总是会执行
end

# 上述代码等同于:
def some_method
  begin
    something_dangerous
  ensure
    # 这里总是会执行
  end
end

同样,简写语法也适用于块:

(1..10).each do |n|
  # 潜在的危险操作
rescue
  # ..
else
  # ..
ensure
  # ..
end

类型推断

在异常处理器的 begin 部分声明的变量,在 rescueensure 主体中被视为 Nil 类型。例如:

begin
  a = something_dangerous_that_returns_Int32
ensure
  puts a + 1 # 错误,Nil 未定义方法 '+'
end

即使 something_dangerous_that_returns_Int32 从未抛出异常,或者 a 被赋值后执行了可能抛出异常的方法,上述情况仍然会发生:

begin
  a = 1
  something_dangerous
ensure
  puts a + 1 # 错误,Nil 未定义方法 '+'
end

尽管很明显 a 总是会被赋值,编译器仍然会认为 a 可能从未被初始化。即使未来这一逻辑可能会改进,目前它迫使你将异常处理器保持在必要的最小范围内,使代码意图更加清晰:

# 比上述代码更清晰:`a` 不需要放在异常处理代码中。
a = 1
begin
  something_dangerous
ensure
  puts a + 1 # 正常执行
end

错误处理的其他方式

尽管异常是处理错误的机制之一,但它们并不是唯一的选择。抛出异常涉及内存分配,并且执行异常处理程序通常较慢。

标准库通常提供两种方法来完成某些操作:一种会抛出异常,另一种返回 nil。例如:

array = [1, 2, 3]
array[4]  # 抛出 IndexError
array[4]? # 返回 nil,因为索引越界

通常的约定是提供一个带问号的替代方法,表示该方法的变体返回 nil 而不是抛出异常。这让用户可以选择是处理异常还是处理 nil。然而,请注意,并非所有方法都提供这种选择,因为异常仍然是首选方式,因为它们不会用错误处理逻辑污染代码。

类型语法(Type Grammar)

在以下情况下:

  1. 指定类型限制
  2. 指定类型参数
  3. 声明变量
  4. 声明别名
  5. 声明类型定义(typedef)
  6. is_a? 伪调用的参数
  7. as 表达式的参数
  8. sizeofinstance_sizeof 表达式的参数
  9. alignofinstance_alignof 表达式的参数
  10. 方法的返回类型

Crystal 提供了一种方便的语法来表示一些常见类型。这些语法在编写 C 绑定时特别有用,但也可以在上述任何位置使用。


路径和泛型

可以使用常规类型和泛型:

Int32
My::Nested::Type
Array(String)

联合类型(Union)

alias Int32OrString = Int32 | String

类型中的管道符 | 用于创建联合类型。Int32 | String 表示“Int32String”。在常规代码中,Int32 | String 表示在 Int32 上调用 | 方法,并将 String 作为参数。


可空类型(Nilable)

alias Int32OrNil = Int32?

等同于:

alias Int32OrNil = Int32 | ::Nil

在常规代码中,Int32? 本身就是一个 Int32 | ::Nil 的联合类型。


指针(Pointer)

alias Int32Ptr = Int32*

等同于:

alias Int32Ptr = Pointer(Int32)

在常规代码中,Int32* 表示在 Int32 上调用 * 方法。


静态数组(StaticArray)

alias Int32_8 = Int32[8]

等同于:

alias Int32_8 = StaticArray(Int32, 8)

在常规代码中,Int32[8] 表示在 Int32 上调用 [] 方法,并将 8 作为参数。


元组(Tuple)

alias Int32StringTuple = {Int32, String}

等同于:

alias Int32StringTuple = Tuple(Int32, String)

在常规代码中,{Int32, String} 是一个包含 Int32String 的元组实例。这与上述元组类型不同。


命名元组(NamedTuple)

alias Int32StringNamedTuple = {x: Int32, y: String}

等同于:

alias Int32StringNamedTuple = NamedTuple(x: Int32, y: String)

在常规代码中,{x: Int32, y: String} 是一个包含 Int32String 的命名元组实例。这与上述命名元组类型不同。


过程(Proc)

alias Int32ToString = Int32 -> String

等同于:

alias Int32ToString = Proc(Int32, String)

要指定一个无参数的 Proc

alias ProcThatReturnsInt32 = -> Int32

要指定多个参数:

alias Int32AndCharToString = Int32, Char -> String

对于嵌套的 Proc(以及任何类型),可以使用括号:

alias ComplexProc = (Int32 -> Int32) -> String

在常规代码中,Int32 -> String 是一个语法错误。


self

self 可以在类型语法中用于表示自身类型。请参阅类型限制部分。


class

class 用于引用类类型,而不是实例类型。

例如:

def foo(x : Int32)
  "instance"
end

def foo(x : Int32.class)
  "class"
end

foo 1     # "instance"
foo Int32 # "class"

class 在创建类类型的数组和集合时也很有用:

class Parent
end

class Child1 < Parent
end

class Child2 < Parent
end

ary = [] of Parent.class
ary << Child1
ary << Child2

下划线(Underscore)

下划线 _ 在类型限制中是允许的。它匹配任何内容:

# 等同于不指定限制,不太有用
def foo(x : _)
end

# 更有用:任何返回 Int32 的两个参数的 Proc:
def foo(x : _, _ -> Int32)
end

在常规代码中,_ 表示下划线变量。


typeof

typeof 在类型语法中是允许的。它返回传递表达式的类型的联合类型:

typeof(1 + 2)  # => Int32
typeof(1, "a") # => (Int32 | String)

类型反射(Type Reflection)

Crystal 提供了一些基本方法来进行类型反射、类型转换和内省。

is_a?

伪方法 is_a? 用于确定表达式的运行时类型是否继承或包含另一个类型。例如:

a = 1
a.is_a?(Int32)          # => true
a.is_a?(String)         # => false
a.is_a?(Number)         # => true
a.is_a?(Int32 | String) # => true

它被称为伪方法,因为编译器知道它的存在,并且它可能会影响类型信息,如 if var.is_a?(...) 中所述。此外,它接受一个必须在编译时已知的类型作为参数。

nil?

伪方法 nil? 用于确定表达式的运行时类型是否为 Nil。例如:

a = 1
a.nil? # => false

b = nil
b.nil? # => true

它被称为伪方法,因为编译器知道它的存在,并且它可能会影响类型信息,如 if var.nil?(...) 中所述。

它的效果与 is_a?(Nil) 相同,但更简短且更易于阅读和编写。

responds_to?

伪方法 responds_to? 用于确定某个类型是否具有指定名称的方法。例如:

a = 1
a.responds_to?(:abs)  # => true
a.responds_to?(:size) # => false

它被称为伪方法,因为它只接受符号字面量作为参数,并且编译器也会对其进行特殊处理,如 if var.responds_to?(...) 中所述。

as

伪方法 as 用于限制表达式的类型。例如:

if some_condition
  a = 1
else
  a = "hello"
end

# a : Int32 | String

在上面的代码中,aInt32 | String 的联合类型。如果出于某种原因我们确定在 if 之后 aInt32,我们可以强制编译器将其视为 Int32

a_as_int = a.as(Int32)
a_as_int.abs # 可以工作,编译器知道 a_as_int 是 Int32

as 伪方法会执行运行时检查:如果 a 不是 Int32,则会抛出异常。

该方法的参数是一个类型。

如果无法将一个类型限制为另一个类型,则会引发编译时错误:

1.as(String) # 编译时错误

注意
你不能使用 as 将一个类型转换为不相关的类型:as 不像其他语言中的强制类型转换。整数、浮点数和字符的方法提供了这些转换功能。或者,可以使用指针转换,如下所述。


指针类型之间的转换

as 伪方法还允许在指针类型之间进行转换:

ptr = Pointer(Int32).malloc(1)
ptr.as(Int8*) # :: Pointer(Int8)

在这种情况下,不会执行运行时检查:指针是不安全的,这种类型的转换通常只在 C 绑定和低级代码中需要。


指针类型与其他类型之间的转换

指针类型与引用类型之间的转换也是可能的:

array = [1, 2, 3]

# object_id 返回对象在内存中的地址,
# 因此我们使用该地址创建一个指针
ptr = Pointer(Void).new(array.object_id)

# 现在我们将该指针转换为相同的类型,
# 我们应该得到相同的值
array2 = ptr.as(Array(Int32))
array2.same?(array) # => true

在这些情况下不会执行运行时检查,因为同样涉及指针。这种转换的需求比前一种更为罕见,但它允许在 Crystal 本身中实现一些核心类型(如 String),并且还允许通过将其转换为 void 指针将引用类型传递给 C 函数。


用于转换为更大类型的用法

as 伪方法可以用于将表达式转换为“更大”的类型。例如:

a = 1
b = a.as(Int32 | Float64)
b # :: Int32 | Float64

上述代码可能看起来不太有用,但在某些情况下(例如映射元素数组时)很有用:

ary = [1, 2, 3]

# 我们希望创建一个 Int32 | Float64 的数组 1, 2, 3
ary2 = ary.map { |x| x.as(Int32 | Float64) }

ary2        # :: Array(Int32 | Float64)
ary2 << 1.5 # OK

Array#map 方法使用块的类型作为数组的泛型类型。如果没有 as 伪方法,推断的类型将是 Int32,我们就无法将 Float64 添加到其中。


用于编译器无法推断块类型的情况

有时编译器无法推断块的类型。这可能发生在相互依赖的递归调用中。在这些情况下,你可以使用 as 来让编译器知道类型:

some_call { |v| v.method.as(ExpectedType) }

as?

伪方法 as?as 类似,不同之处在于当类型不匹配时,它会返回 nil 而不是抛出异常。它也不能用于在指针类型和其他类型之间进行转换。

示例:

value = rand < 0.5 ? -3 : nil
result = value.as?(Int32) || 10

value.as?(Int32).try &.abs

typeof

typeof 表达式返回表达式的类型:

a = 1
b = typeof(a) # => Int32

它接受多个参数,结果是这些表达式类型的联合:

typeof(1, "a", 'a') # => (Int32 | String | Char)

它通常用于泛型代码中,以利用编译器的类型推断能力:

hash = {} of Int32 => String
another_hash = typeof(hash).new # :: Hash(Int32, String)

由于 typeof 实际上并不对表达式求值,因此它可以在编译时用于方法,例如在以下示例中,它递归地从嵌套的泛型类型中形成一个联合类型:

class Array
  def self.elem_type(typ)
    if typ.is_a?(Array)
      elem_type(typ.first)
    else
      typ
    end
  end
end

nest = [1, ["b", [:c, ['d']]]]
flat = Array(typeof(Array.elem_type(nest))).new
typeof(nest) # => Array(Int32 | Array(String | Array(Symbol | Array(Char))))
typeof(flat) # => Array(String | Int32 | Symbol | Char)

此表达式也可用于类型语法中。

类型自动转换(Type Autocasting)

Crystal 在无歧义的情况下会透明地转换某些类型的元素。

数字自动转换

如果不会丢失精度,数值类型的值会自动转换为更大的类型:

def foo(x : Int32) : Int32
  x
end

def bar(x : Float32) : Float32
  x
end

def bar64(x : Float64) : Float64
  x
end

foo 0xFFFF_u16 # OK,UInt16 总是可以放入 Int32
foo 0xFFFF_u64 # OK,这个特定的 UInt64 可以放入 Int32
bar(foo 1)     # 失败,将 Int32 转换为 Float32 可能会丢失精度
bar64(bar 1)   # OK,Float32 可以自动转换为 Float64

数字字面量在字面值的实际值适合目标类型时总是会被转换,无论其类型如何。

表达式会被转换(如上面的最后一个例子),除非编译器传递了 no_number_autocast 标志(参见编译器特性)。

如果存在歧义,例如因为有多个选项,编译器会抛出错误:

def foo(x : Int64)
  x
end

def foo(x : Int128)
  x
end

foo 1_i32 # 错误:调用歧义,Int32 的隐式转换匹配 Int64 和 Int128

目前,自动转换仅在两种情况下有效:在函数调用中(如上所示)和在类/实例变量初始化时。以下示例展示了实例变量的两种情况:初始化时的转换有效,但赋值时的转换无效:

class Foo
  @x : Int64 = 10 # OK,10 可以放入 Int64

  def set_x(y)
    @x = y
  end
end

Foo.new.set_x 1 # 错误:"在第 5 行:Foo 的实例变量 '@x' 必须是 Int64,而不是 Int32"

符号自动转换

符号会自动转换为枚举成员,从而使代码更简洁:

enum TwoValues
  A
  B
end

def foo(v : TwoValues)
  case v
  in TwoValues::A
    p "A"
  in TwoValues::B
    p "B"
  end
end

foo :a # 自动转换为 TwoValues::A

宏(Macros)

宏是在编译时接收 AST 节点并生成代码的方法,生成的代码会被插入到程序中。例如:

macro define_method(name, content)
  def {{name}}
    {{content}}
  end
end

# 这会生成:
#
#     def foo
#       1
#     end
define_method foo, 1

foo # => 1

宏的定义体看起来像普通的 Crystal 代码,但带有额外的语法来操作 AST 节点。生成的代码必须是有效的 Crystal 代码,这意味着你不能生成一个没有匹配 enddef,或者生成 case 语句的单个 when 表达式,因为这两者都不是完整的有效表达式。更多信息请参阅陷阱


作用域

在顶层声明的宏在任何地方都可见。如果顶层宏被标记为 private,则只能在该文件中访问。

宏也可以在类和模块中定义,并在这些作用域内可见。宏还会在祖先链(超类和包含的模块)中查找。

例如,通过 with ... yield 调用时,块可以访问该对象的祖先链中定义的宏:

class Foo
  macro emphasize(value)
    "***#{ {{value}} }***"
  end

  def yield_with_self(&)
    with self yield
  end
end

Foo.new.yield_with_self { emphasize(10) } # => "***10***"

类和模块中定义的宏也可以从外部调用:

class Foo
  macro emphasize(value)
    "***#{ {{value}} }***"
  end
end

Foo.emphasize(10) # => "***10***"

插值

你可以使用 {{...}} 来粘贴或插值一个 AST 节点,如上例所示。

注意,节点是按原样粘贴的。如果在上例中我们传递一个符号,生成的代码将变得无效:

# 这会生成:
#
#     def :foo
#       1
#     end
define_method :foo, 1

注意,:foo 是插值的结果,因为这是传递给宏的内容。在这些情况下,你可以使用 ASTNode#id 方法,当你只需要一个标识符时。


宏调用

你可以在编译时调用 AST 节点上的固定子集方法。这些方法记录在虚构的 Crystal::Macros 模块中。

例如,在上例中调用 ASTNode#id 可以解决问题:

macro define_method(name, content)
  def {{name.id}}
    {{content}}
  end
end

# 这会正确生成:
#
#     def foo
#       1
#     end
define_method :foo, 1

parse_type

大多数 AST 节点是通过手动传递的参数、硬编码的值或从类型或方法信息辅助变量中获取的。然而,在某些情况下,节点可能无法直接访问,例如,如果你使用来自不同上下文的信息来构造所需类型/常量的路径。

在这种情况下,parse_type 宏方法可以通过将提供的 StringLiteral 解析为可以解析为所需 AST 节点的内容来帮助。

MY_CONST = 1234

struct Some::Namespace::Foo; end

{{ parse_type("Some::Namespace::Foo").resolve.struct? }} # => true
{{ parse_type("MY_CONST").resolve }}                     # => 1234

{{ parse_type("MissingType").resolve }} # 错误:未定义的常量 MissingType

更多示例请参阅 API 文档。


模块和类

模块、类和结构体也可以生成:

macro define_class(module_name, class_name, method, content)
  module {{module_name}}
    class {{class_name}}
      def initialize(@name : String)
      end

      def {{method}}
        {{content}} + @name
      end
    end
  end
end

# 这会生成:
#     module Foo
#       class Bar
#         def initialize(@name : String)
#         end
#
#         def say
#           "hi " + @name
#         end
#       end
#     end
define_class Foo, Bar, say, "hi "

p Foo::Bar.new("John").say # => "hi John"

条件语句

你可以使用 {% if condition %} ... {% end %} 来有条件地生成代码:

macro define_method(name, content)
  def {{name}}
    {% if content == 1 %}
      "one"
    {% elsif content == 2 %}
      "two"
    {% else %}
      {{content}}
    {% end %}
  end
end

define_method foo, 1
define_method bar, 2
define_method baz, 3

foo # => one
bar # => two
baz # => 3

与常规代码类似,NopNilLiteralfalseBoolLiteral 被视为假值,而其他所有内容被视为真值。

宏条件语句可以在宏定义之外使用:

{% if env("TEST") %}
  puts "We are in test mode"
{% end %}

迭代

你可以迭代有限的次数:

macro define_constants(count)
  {% for i in (1..count) %}
    PI_{{i.id}} = Math::PI * {{i}}
  {% end %}
end

define_constants(3)

PI_1 # => 3.14159...
PI_2 # => 6.28318...
PI_3 # => 9.42477...

要迭代 ArrayLiteral

macro define_dummy_methods(names)
  {% for name, index in names %}
    def {{name.id}}
      {{index}}
    end
  {% end %}
end

define_dummy_methods [foo, bar, baz]

foo # => 0
bar # => 1
baz # => 2

上例中的 index 变量是可选的。

要迭代 HashLiteral

macro define_dummy_methods(hash)
  {% for key, value in hash %}
    def {{key.id}}
      {{value}}
    end
  {% end %}
end

define_dummy_methods({foo: 10, bar: 20})
foo # => 10
bar # => 20

宏迭代可以在宏定义之外使用:

{% for name, index in ["foo", "bar", "baz"] %}
  def {{name.id}}
    {{index}}
  end
{% end %}

foo # => 0
bar # => 1
baz # => 2

可变参数和展开

宏可以接受可变参数:

macro define_dummy_methods(*names)
  {% for name, index in names %}
    def {{name.id}}
      {{index}}
    end
  {% end %}
end

define_dummy_methods foo, bar, baz

foo # => 0
bar # => 1
baz # => 2

参数被打包成 TupleLiteral 并传递给宏。

此外,在插值 TupleLiteral 时使用 * 会将元素以逗号分隔的形式插值:

macro println(*values)
  print {{*values}}, '\n'
end

println 1, 2, 3 # 输出 123\n

类型信息

当宏被调用时,你可以通过特殊实例变量 @type 访问当前作用域或类型。该变量的类型是 TypeNode,它允许你在编译时访问类型信息。

注意,@type 始终是实例类型,即使宏在类方法中调用。

例如:

macro add_describe_methods
  def describe
    "Class is: " + {{ @type.stringify }}
  end

  def self.describe
    "Class is: " + {{ @type.stringify }}
  end
end

class Foo
  add_describe_methods
end

Foo.new.describe # => "Class is Foo"
Foo.describe     # => "Class is Foo"

顶层模块

可以通过特殊变量 @top_level 访问顶层命名空间,作为 TypeNode。以下示例展示了它的用途:

A_CONSTANT = 0

{% if @top_level.has_constant?("A_CONSTANT") %}
  puts "this is printed"
{% else %}
  puts "this is not printed"
{% end %}

方法信息

当宏被调用时,你可以通过特殊实例变量 @def 访问宏所在的方法。该变量的类型是 Def,除非宏在方法之外,此时它是 NilLiteral

示例:

module Foo
  def Foo.boo(arg1, arg2)
    {% @def.receiver %} # => Foo
    {% @def.name %}     # => boo
    {% @def.args %}     # => [arg1, arg2]
  end
end

Foo.boo(0, 1)

调用信息

当宏被调用时,你可以通过特殊实例变量 @caller 访问宏调用堆栈。该变量返回一个 Call 节点的 ArrayLiteral,数组中的第一个元素是最新的调用。在宏之外或宏没有调用者(例如钩子)时,该值为 NilLiteral

注意

截至目前,返回的数组将始终只有一个元素。

示例:

macro foo
  {{ @caller.first.line_number }}
end

def bar
  {{ @caller }}
end

foo # => 9
bar # => nil

常量

宏可以访问常量。例如:

VALUES = [1, 2, 3]

{% for value in VALUES %}
  puts {{value}}
{% end %}

如果常量表示一个类型,你将获得一个 TypeNode


嵌套宏

可以定义一个生成一个或多个宏定义的宏。你必须通过在内部宏表达式前加上反斜杠字符 \ 来转义它们,以防止它们被外部宏评估。

macro define_macros(*names)
  {% for name in names %}
    macro greeting_for_{{name.id}}(greeting)
      \{% if greeting == "hola" %}
        "¡hola {{name.id}}!"
      \{% else %}
        "\{{greeting.id}} {{name.id}}"
      \{% end %}
    end
  {% end %}
end

# 这会生成:
#
#     macro greeting_for_alice(greeting)
#       {% if greeting == "hola" %}
#         "¡hola alice!"
#       {% else %}
#         "{{greeting.id}} alice"
#       {% end %}
#     end
#     macro greeting_for_bob(greeting)
#       {% if greeting == "hola" %}
#         "¡hola bob!"
#       {% else %}
#         "{{greeting.id}} bob"
#       {% end %}
#     end
define_macros alice, bob

greeting_for_alice "hello" # => "hello alice"
greeting_for_bob "hallo"   # => "hallo bob"
greeting_for_alice "hej"   # => "hej alice"
greeting_for_bob "hola"    # => "¡hola bob!"

verbatim

另一种定义嵌套宏的方法是使用特殊的 verbatim 调用。使用这种方法,你将无法使用任何变量插值,但不需要转义内部宏字符。

macro define_macros(*names)
  {% for name in names %}
    macro greeting_for_{{name.id}}(greeting)

      # name 在 verbatim 块内不可用
      \{% name = {{name.stringify}} %}

      {% verbatim do %}
        {% if greeting == "hola" %}
          "¡hola {{name.id}}!"
        {% else %}
          "{{greeting.id}} {{name.id}}"
        {% end %}
      {% end %}
    end
  {% end %}
end

# 这会生成:
#
#     macro greeting_for_alice(greeting)
#       {% name = "alice" %}
#       {% if greeting == "hola" %}
#         "¡hola {{name.id}}!"
#       {% else %}
#         "{{greeting.id}} {{name.id}}"
#       {% end %}
#     end
#     macro greeting_for_bob(greeting)
#       {% name = "bob" %}
#       {% if greeting == "hola" %}
#         "¡hola {{name.id}}!"
#       {% else %}
#         "{{greeting.id}} {{name.id}}"
#       {% end %}
#     end
define_macros alice, bob

greeting_for_alice "hello" # => "hello alice"
greeting_for_bob "hallo"   # => "hallo bob"
greeting_for_alice "hej"   # => "hej alice"
greeting_for_bob "hola"    # => "¡hola bob!"

注意,内部宏中的变量在 verbatim 块内不可用。块的内容“按原样”传递,本质上是一个字符串,直到编译器重新检查。


注释

宏表达式在注释和代码的可编译部分中都会被评估。这可以用于为扩展提供相关文档:

{% for name, index in ["foo", "bar", "baz"] %}
  # 提供一个占位符 {{name.id}} 方法。始终返回 {{index}}。
  def {{name.id}}
    {{index}}
  end
{% end %}

这种评估适用于插值和指令。因此,宏无法被注释掉。

macro a
  # {% if false %}
  puts 42
  # {% end %}
end

a

上述表达式将不会产生任何输出。


合并扩展和调用注释

@caller 可以与 #doc_comment 方法结合使用,以允许合并宏生成的节点上的文档注释和宏调用本身的注释。例如:

macro gen_method(name)
 # {{ @caller.first.doc_comment }}
 #
 # 通过宏扩展添加的注释。
 def {{name.id}}
 end
end

# 宏调用上的注释。
gen_method foo

生成后,#foo 方法的文档将如下所示:

宏调用上的注释。

通过宏扩展添加的注释。

陷阱

在编写宏(尤其是在宏定义之外)时,重要的是要记住,宏生成的代码本身必须是有效的 Crystal 代码,即使它尚未合并到主程序的代码中。这意味着,例如,宏不能生成 case 语句的一个或多个 when 表达式,除非 case 是生成代码的一部分。

以下是一个无效宏的示例:

case 42
{% for klass in [Int32, String] %} # 语法错误:意外的标记: {% (期望 when, else 或 end)
  when {{klass.id}}
    p "is {{klass}}"
{% end %}
end

注意,case 不在宏内。宏生成的代码仅由两个 when 表达式组成,这本身不是有效的 Crystal 代码。我们必须将 case 包含在宏中,通过使用 beginend 使其有效:

{% begin %}
  case 42
  {% for klass in [Int32, String] %}
    when {{klass.id}}
      p "is {{klass}}"
  {% end %}
  end
{% end %}

宏方法(Macro Methods)

宏定义(macro defs)允许你为类层次结构定义一个方法,然后为每个具体子类型实例化该方法。

如果一个 def 包含引用 @type 的宏表达式,则它隐式被视为宏定义。例如:

class Object
  def instance_vars_names
    {{ @type.instance_vars.map &.name.stringify }}
  end
end

class Person
  def initialize(@name : String, @age : Int32)
  end
end

person = Person.new "John", 30
person.instance_vars_names # => ["name", "age"]

在宏定义中,参数作为它们的 AST 节点传递,使你可以在宏扩展中访问它们({{some_macro_argument}})。然而,宏方法并非如此。这里的参数列表是由宏方法生成的方法的参数列表。你无法在编译时访问调用参数。

class Object
  def has_instance_var?(name) : Bool
    # 我们无法在这里的宏扩展中访问 name,
    # 而是需要使用宏语言构造一个数组,
    # 并在运行时进行包含检查。
    {{ @type.instance_vars.map &.name.stringify }}.includes? name
  end
end

person = Person.new "John", 30
person.has_instance_var?("name")     # => true
person.has_instance_var?("birthday") # => false

钩子(Hooks)

存在一些特殊的宏,它们在某些情况下作为钩子在编译时被调用:

  • inherited:当定义子类时调用。@type 是继承的类型。
  • included:当模块被包含时调用。@type 是包含的类型。
  • extended:当模块被扩展时调用。@type 是扩展的类型。
  • method_missing:当找不到方法时调用。
  • method_added:在当前作用域中定义新方法时调用。
  • finished:在解析完成后调用,因此所有类型及其方法都已知。

inherited 示例

class Parent
  macro inherited
    def lineage
      "{{@type.name.id}} < Parent"
    end
  end
end

class Child < Parent
end

Child.new.lineage # => "Child < Parent"

method_missing 示例

macro method_missing(call)
  print "Got ", {{call.name.id.stringify}}, " with ", {{call.args.size}}, " arguments", '\n'
end

foo          # 输出: Got foo with 0 arguments
bar 'a', 'b' # 输出: Got bar with 2 arguments

method_added 示例

macro method_added(method)
  {% puts "Method added:", method.name.stringify %}
end

def generate_random_number
  4
end
# => Method added: generate_random_number

method_missingmethod_added 仅适用于宏定义所在类或其子类中的调用或方法,或者如果宏定义在类外部,则仅适用于顶层。例如:

macro method_missing(call)
  puts "In outer scope, got call: ", {{ call.name.stringify }}
end

class SomeClass
  macro method_missing(call)
    puts "Inside SomeClass, got call: ", {{ call.name.stringify }}
  end
end

class OtherClass
end

# 此调用由顶层的 `method_missing` 处理
foo # => In outer scope, got call: foo

obj = SomeClass.new
# 此调用由 SomeClass 内部的 `method_missing` 处理
obj.bar # => Inside SomeClass, got call: bar

other = OtherClass.new
# OtherClass 或其父类未定义 `method_missing` 宏
other.baz # => 错误:未定义的方法 'baz' for OtherClass

finished 示例

finished 在类型完全定义后调用——这包括对该类的扩展。考虑以下程序:

macro print_methods
  {% puts @type.methods.map &.name %}
end

class Foo
  macro finished
    {% puts @type.methods.map &.name %}
  end

  print_methods
end

class Foo
  def bar
    puts "I'm a method!"
  end
end

Foo.new.bar

print_methods 宏在遇到时会立即运行——并打印一个空列表,因为此时没有定义任何方法。一旦编译了 Foo 的第二个声明,finished 宏将运行,它将打印 [bar]


堆叠与覆盖

根据使用的宏钩子,钩子可以堆叠或覆盖。

堆叠
当堆叠时,钩子在其定义的上下文中多次执行,执行次数与钩子的定义次数相同。以这种方式执行的钩子将按定义顺序执行。考虑以下示例:

# 堆叠顶层的 finished 宏
macro finished
  {% puts "I will execute!" %}
end

macro finished
  {% puts "I will also execute!" %}
end

在上面的示例中,两个 finished 宏都会执行。堆叠适用于以下钩子:inheritedincludedextendedmethod_addedfinished

覆盖
method_missing 宏钩子的定义会覆盖同一上下文中此钩子的任何先前定义。只有最后定义的宏会执行。例如:

macro method_missing(name)
  {% puts "I didnt run! :(" %}
end

class Example
  macro method_missing(name)
    {% puts "I didnt run! :(" %}
  end

  macro method_missing(name)
    {% puts "I am the only one that will run!" %}
  end
end

macro method_missing(name)
  {% puts "I am the only one that will run!" %}
end

Example.new.call_a_missing_method # => I am the only one that will run!

call_a_missing_method # => I am the only one that will run!

新变量(Fresh Variables)

一旦宏生成代码,它们会通过常规的 Crystal 解析器进行解析,其中宏调用上下文中的局部变量被假定为已定义。

通过一个例子可以更好地理解这一点:

macro update_x
  x = 1
end

x = 0
update_x
x # => 1

有时,这可以通过有意读取/写入局部变量来避免重复代码,但也可能意外覆盖局部变量。为了避免这种情况,可以使用 %name 声明新变量:

macro dont_update_x
  %x = 1
  puts %x
end

x = 0
dont_update_x # 输出 1
x             # => 0

在上面的例子中,使用 %x 声明了一个变量,其名称保证不会与当前作用域中的局部变量冲突。

此外,可以使用 %var{key1, key2, ..., keyN} 声明相对于某些其他 AST 节点的新变量。例如:

macro fresh_vars_sample(*names)
  # 首先声明变量
  {% for name, index in names %}
    print "Declaring: ", stringify(%name{index}), '\n'
    %name{index} = {{index}}
  {% end %}

  # 然后打印它们
  {% for name, index in names %}
    print stringify(%name{index}), ": ", %name{index}, '\n'
  {% end %}
end

macro stringify(var)
  {{ var.stringify }}
end

fresh_vars_sample a, b, c

# 示例输出:
# Declaring: __temp_255
# Declaring: __temp_256
# Declaring: __temp_257
# __temp_255: 0
# __temp_256: 1
# __temp_257: 2

在上面的例子中,声明了三个带索引的变量,为它们赋值,然后打印它们,显示它们对应的索引。

注解(Annotations)

注解可以用于为源代码中的某些特性添加元数据。类型、方法、实例变量以及方法/宏参数都可以被注解。用户可以通过 annotation 关键字定义自定义注解,例如标准库中的 JSON::Field。编译器也提供了一些内置的注解。

用户可以使用 annotation 关键字定义自己的注解,其定义方式类似于定义类或结构体:

annotation MyAnnotation
end

然后可以将注解应用于以下各项:

  • 实例方法和类方法
  • 实例变量
  • 类、结构体、枚举和模块
  • 方法和宏参数(尽管后者目前无法访问)
annotation MyAnnotation
end

@[MyAnnotation]
def foo
  "foo"
end

@[MyAnnotation]
class Klass
end

@[MyAnnotation]
module MyModule
end

def method1(@[MyAnnotation] foo)
end

def method2(
  @[MyAnnotation]
  bar
)
end

def method3(@[MyAnnotation] & : String ->)
end

应用场景

注解最适合用于存储关于实例变量、类型或方法的元数据,以便在编译时通过宏读取。注解的主要优点之一是它们直接应用于实例变量或方法,这使得类看起来更自然,因为不需要使用标准的宏来创建这些属性或方法。

一些注解的应用场景包括:

对象序列化

可以定义一个注解,当应用于实例变量时,决定该实例变量是否应被序列化,或者使用什么键进行序列化。Crystal 的 JSON::SerializableYAML::Serializable 就是这种应用的例子。

ORM(对象关系映射)

注解可以用于将属性标记为 ORM 列。实例变量的名称和类型可以从 TypeNode 中读取,同时注解本身也可以用于存储关于列的元数据,例如是否可为空、列的名称或是否为主键。

字段

数据可以存储在注解中。

annotation MyAnnotation
end

# 字段可以是键值对
@[MyAnnotation(key: "value", value: 123)]

# 或者是位置参数
@[MyAnnotation("foo", 123, false)]

键值对

注解的键值对可以通过 [] 方法在编译时访问。

annotation MyAnnotation
end

@[MyAnnotation(value: 2)]
def annotation_value
  # 名称可以是 `String`、`Symbol` 或 `MacroId`
  {{ @def.annotation(MyAnnotation)[:value] }}
end

annotation_value # => 2

named_args 方法可以用于将注解中的所有键值对作为 NamedTupleLiteral 读取。此方法默认定义在所有注解上,并且对于每个应用的注解是唯一的。

annotation MyAnnotation
end

@[MyAnnotation(value: 2, name: "Jim")]
def annotation_named_args
  {{ @def.annotation(MyAnnotation).named_args }}
end

annotation_named_args # => {value: 2, name: "Jim"}

由于此方法返回 NamedTupleLiteral,因此可以使用该类型的所有方法,特别是 #double_splat,它使得将注解参数传递给方法变得非常容易。

annotation MyAnnotation
end

class SomeClass
  def initialize(@value : Int32, @name : String); end
end

@[MyAnnotation(value: 2, name: "Jim")]
def new_test
  {% begin %}
    SomeClass.new {{ @def.annotation(MyAnnotation).named_args.double_splat }}
  {% end %}
end

new_test # => #<SomeClass:0x5621a19ddf00 @name="Jim", @value=2>

位置参数

位置参数可以通过 [] 方法在编译时访问,但一次只能访问一个索引。

annotation MyAnnotation
end

@[MyAnnotation(1, 2, 3, 4)]
def annotation_read
  {% for idx in [0, 1, 2, 3, 4] %}
    {% value = @def.annotation(MyAnnotation)[idx] %}
    pp "{{ idx }} = {{ value }}"
  {% end %}
end

annotation_read

# 输出:
"0 = 1"
"1 = 2"
"2 = 3"
"3 = 4"
"4 = nil"

args 方法可以用于将注解中的所有位置参数作为 TupleLiteral 读取。此方法默认定义在所有注解上,并且对于每个应用的注解是唯一的。

annotation MyAnnotation
end

@[MyAnnotation(1, 2, 3, 4)]
def annotation_args
  {{ @def.annotation(MyAnnotation).args }}
end

annotation_args # => {1, 2, 3, 4}

由于 TupleLiteral 是可迭代的,我们可以以更好的方式重写前面的示例。同样,TupleLiteral 的所有方法也可以使用。

annotation MyAnnotation
end

@[MyAnnotation(1, "foo", true, 17.0)]
def annotation_read
  {% for value, idx in @def.annotation(MyAnnotation).args %}
    pp "{{ idx }} = #{{{ value }}}"
  {% end %}
end

annotation_read

# 输出:
"0 = 1"
"1 = foo"
"2 = true"
"3 = 17.0"

读取注解

可以使用 .annotation(type : TypeNode) 方法从 TypeNodeDefMetaVarArg 上读取注解。此方法返回一个表示所应用注解的 Annotation 对象。

注意:如果应用了多个相同类型的注解,.annotation 方法将返回最后一个。

@type@def 变量可以用于获取 TypeNodeDef 对象,以便使用 .annotation 方法。然而,也可以使用 TypeNode 上的其他方法获取 TypeNode/Def 类型,例如 TypeNode.all_subclassesTypeNode.methods

提示:查看 parse_type 方法以获取更高级的获取 TypeNode 的方式。

TypeNode.instance_vars 可以用于获取实例变量 MetaVar 对象的数组,从而读取定义在这些实例变量上的注解。

注意TypeNode.instance_vars 目前仅在实例/类方法的上下文中有效。

annotation MyClass
end

annotation MyMethod
end

annotation MyIvar
end

annotation MyParameter
end

@[MyClass]
class Foo
  pp {{ @type.annotation(MyClass).stringify }}

  @[MyIvar]
  @num : Int32 = 1

  @[MyIvar]
  property name : String = "jim"

  def properties
    {% for ivar in @type.instance_vars %}
      pp {{ ivar.annotation(MyIvar).stringify }}
    {% end %}
  end
end

@[MyMethod]
def my_method
  pp {{ @def.annotation(MyMethod).stringify }}
end

def method_params(
  @[MyParameter(index: 0)]
  value : Int32,
  @[MyParameter(index: 1)] metadata,
  @[MyParameter(index: 2)] & : -> String
)
  pp {{ @def.args[0].annotation(MyParameter).stringify }}
  pp {{ @def.args[1].annotation(MyParameter).stringify }}
  pp {{ @def.block_arg.annotation(MyParameter).stringify }}
end

Foo.new.properties
my_method
method_params 10, false do
  "foo"
end
pp {{ Foo.annotation(MyClass).stringify }}

# 输出:
"@[MyClass]"
"@[MyIvar]"
"@[MyIvar]"
"@[MyMethod]"
"@[MyParameter(index: 0)]"
"@[MyParameter(index: 1)]"
"@[MyParameter(index: 2)]"
"@[MyClass]"

警告:注解只能从类型化的块参数中读取。详见 https://github.com/crystal-lang/crystal/issues/5334

读取多个注解

#annotations 方法返回一个类型上所有注解的 ArrayLiteral。可选地,可以使用 #annotations(type : TypeNode) 方法过滤仅返回指定类型的注解。

annotation MyAnnotation; end
annotation OtherAnnotation; end

@[MyAnnotation("foo")]
@[MyAnnotation(123)]
@[OtherAnnotation(456)]
def annotation_read
  {% for ann in @def.annotations(MyAnnotation) %}
    pp "{{ann.name}}: {{ ann[0].id }}"
  {% end %}

  puts

  {% for ann in @def.annotations %}
    pp "{{ann.name}}: {{ ann[0].id }}"
  {% end %}
end

annotation_read

# 输出:
"MyAnnotation: foo"
"MyAnnotation: 123"

"MyAnnotation: foo"
"MyAnnotation: 123"
"OtherAnnotation: 456"

内置注解(Built-in Annotations)

Crystal 标准库中包含一些预定义的注解:

Link

告诉编译器如何链接 C 库。这在 lib 部分中有详细解释。

Extern

用此注解标记一个 Crystal 结构体,可以使其在 lib 声明中使用:

@[Extern]
struct MyStruct
end

lib MyLib
  fun my_func(s : MyStruct) # 正常(如果没有 Extern 注解会报错)
end

你还可以使结构体表现得像 C 的联合体(这可能非常不安全):

# 一个用于在 Int32 码点和字符之间轻松转换的结构体
@[Extern(union: true)]
struct Int32OrChar
  property int = 0
  property char = '\0'
end

s = Int32OrChar.new
s.char = 'A'
s.int # => 65

s.int = 66
s.char # => 'B'

ThreadLocal

@[ThreadLocal] 注解可以应用于类变量和 C 外部变量。它使它们成为线程局部的。

class DontUseThis
  # 每个线程一个
  @[ThreadLocal]
  @@values = [] of Int32
end

ThreadLocal 在标准库中用于实现运行时,通常不应在外部使用。

Packed

将 C 结构体标记为 packed,防止在字段之间自动插入填充字节。通常仅在 C 库明确使用 packed 结构体时才需要。

AlwaysInline

提示编译器始终内联一个方法:

@[AlwaysInline]
def foo
  1
end

NoInline

告诉编译器永远不要内联一个方法调用。如果方法包含 yield,则此注解无效,因为包含 yield 的函数总是内联的。

@[NoInline]
def foo
  1
end

ReturnsTwice

标记一个方法或 lib 函数为可能返回两次。C 的 setjmp 就是这种函数的一个例子。

Raises

标记一个方法或 lib 函数为可能引发异常。这在回调部分中有详细解释。

CallConvention

指示 lib 函数的调用约定。例如:

lib LibFoo
  @[CallConvention("X86_StdCall")]
  fun foo : Int32
end

有效的调用约定列表包括:

  • C(默认)
  • Fast
  • Cold
  • WebKit_JS
  • AnyReg
  • X86_StdCall
  • X86_FastCall

它们的详细解释可以参考相关文档。

Flags

将一个枚举标记为“标志枚举”,这会改变其某些方法的行为,例如 to_s

@[Flags]
enum Permissions
  Read
  Write
  Execute
end

perms = Permissions::Read | Permissions::Write
perms.to_s # => "Read | Write"

低级原语(Low-level Primitives)

Crystal 提供了一些低级原语,主要用于与 C 库交互以及编写低级代码。

pointerof

pointerof 表达式返回一个指向变量或实例变量内容的指针(Pointer)。

示例 1:指向变量的指针

a = 1

ptr = pointerof(a)
ptr.value = 2

a # => 2

示例 2:指向实例变量的指针

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

  def x
    @x
  end

  def x_ptr
    pointerof(@x)
  end
end

point = Point.new 1, 2

ptr = point.x_ptr
ptr.value = 10

point.x # => 10

由于 pointerof 涉及指针操作,因此被认为是不安全的。

sizeof

sizeof 表达式返回一个 Int32 值,表示给定类型的大小(以字节为单位)。例如:

sizeof(Int32) # => 4
sizeof(Int64) # => 8

对于引用类型,大小与指针的大小相同:

# 在 64 位机器上
sizeof(Pointer(Int32)) # => 8
sizeof(String)         # => 8

这是因为引用类型的内存分配在堆上,并且传递的是指向它的指针。要获取类的实际大小,请使用 instance_sizeof

sizeof 的参数是一个类型,通常与 typeof 结合使用:

a = 1
sizeof(typeof(a)) # => 4

instance_sizeof

instance_sizeof 表达式返回一个 Int32 值,表示给定类的实例大小。

sizeof 不同,sizeof 返回的是指向分配对象的引用(指针)的大小,而 instance_sizeof 返回的是分配对象本身的大小。

例如:

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

Point.new 1, 2

# 2 x Int32 = 2 x 4 = 8
instance_sizeof(Point) # => 12

尽管实例有两个 Int32 字段,但编译器始终会为对象的类型 ID 包含一个额外的 Int32 字段。这就是为什么实例大小最终是 12 而不是 8。

alignof

alignof 表达式返回一个 Int32 值,表示给定类型的 ABI 对齐方式(以字节为单位)。例如:

alignof(Int32) # => 4
alignof(Int64) # => 8

struct Foo
  def initialize(@x : Int8, @y : Int16)
  end
end

@[Extern]
@[Packed]
struct Bar
  def initialize(@x : Int8, @y : Int16)
  end
end

alignof(Foo) # => 2
alignof(Bar) # => 1

对于引用类型,对齐方式与指针的对齐方式相同:

# 在 64 位机器上
alignof(Pointer(Int32)) # => 8
alignof(String)         # => 8

这是因为引用类型的内存分配在堆上,并且传递的是指向它的指针。要获取类的实际对齐方式,请使用 instance_alignof

alignof 的参数是一个类型,通常与 typeof 结合使用:

a = 1
alignof(typeof(a)) # => 4

instance_alignof

instance_alignof 表达式返回一个 Int32 值,表示给定类的实例对齐方式。

alignof 不同,alignof 返回的是指向分配对象的引用(指针)的对齐方式,而 instance_alignof 返回的是分配对象本身的对齐方式。

例如:

class Foo
end

class Bar
  def initialize(@x : Int64)
  end
end

instance_alignof(Foo) # => 4
instance_alignof(Bar) # => 8

尽管 Foo 没有实例变量,但编译器始终会为对象的类型 ID 包含一个额外的 Int32 字段。这就是为什么实例对齐方式最终是 4 而不是 1。

offsetof

offsetof 表达式返回类或结构体实例中某个字段的字节偏移量。

offsetof 表达式有两种形式。第一种形式接受任何类型作为第一个参数,并以 @ 开头的实例变量名作为第二个参数,返回该实例变量相对于给定类型实例的字节偏移量:

struct Foo
  @x = 0_i64
  @y = 34_i8
  @z = 42_u16
end

offsetof(Foo, @x) # => 0
offsetof(Foo, @y) # => 8
offsetof(Foo, @z) # => 10

第二种形式接受任何元组实例类型作为第一个参数,并以整数字面量索引作为第二个参数,返回对应元组元素相对于给定类型实例的字节偏移量:

offsetof(Tuple(Int64, Int8, UInt16), 0) # => 0
offsetof(Tuple(Int64, Int8, UInt16), 1) # => 8
offsetof(Tuple(Int64, Int8, UInt16), 2) # => 10

这是一个低级原语,仅在 C API 需要直接与 Crystal 类型的数据布局交互时有用。

未初始化变量声明(Uninitialized Variable Declaration)

Crystal 允许声明未初始化的变量:

x = uninitialized Int32
x # => 某个随机值,垃圾数据,不可靠

这是不安全的代码,通常用于低级编程中,以声明未初始化的 StaticArray 缓冲区,从而避免性能开销:

buffer = uninitialized UInt8[256]

该缓冲区在栈上分配,避免了堆分配。

uninitialized 关键字后的类型遵循类型语法规则。

asm

asm 关键字可用于插入内联汇编,这在极少数情况下是必需的,例如纤程切换和系统调用:

# 仅适用于 x86-64 目标
dst = 0
asm("mov $$1234, $0" : "=r"(dst))
dst # => 1234

asm 表达式最多由 5 个用冒号分隔的部分组成,每个部分内的组件用逗号分隔。例如:

asm(
  # 汇编模板字符串,遵循 LLVM 集成汇编器的语法
  "nop" :
  # 输出操作数
  "=r"(foo), "=r"(bar) :
  # 输入操作数
  "r"(1), "r"(baz) :
  # 被破坏的寄存器名称
  "eax", "memory" :
  # 可选标志,对应于 LLVM IR 的
  # sideeffect / alignstack / inteldialect / unwind 属性
  "volatile", "alignstack", "intel", "unwind"
)

只有模板字符串是必需的,其他部分可以为空或省略:

asm("nop")
asm("nop" :: "b"(1), "c"(2)) # 输出操作数为空

更多详细信息,请参阅 LLVM 文档中关于内联汇编表达式的部分。

编译时标志(Compile-time Flags)

编译时标志是通过编译器提供的布尔值,允许根据编译时条件包含或排除代码。

编译器提供了几个默认标志,包含有关编译器选项和目标平台的信息。用户提供的标志通过编译器传递,可以用作功能标志。

查询标志

标志是一个命名的标识符,可以是设置或未设置的状态。可以通过宏方法 flag? 查询标志的状态。它接收标志的名称(字符串或符号字面量),并返回一个布尔字面量,表示标志的状态。

以下程序展示了如何使用编译时标志来打印目标操作系统的家族:

{% if flag?(:unix) %}
  puts "This program is compiled for a UNIX-like operating system"
{% elsif flag?(:windows) %}
  puts "This program is compiled for Windows"
{% else %}
  # 目前,所有支持的目标平台都是 UNIX 或 Windows 平台,
  # 因此这个分支实际上是不可达的。
  puts "Compiling for some other operating system"
{% end %}

还有一个宏方法 host_flag?,它返回主机平台的标志状态,在交叉编译时可能与目标平台的标志状态(由 flag? 查询)不同。

编译器提供的标志

编译器定义了一些隐式标志。它们描述了目标平台或编译器选项。

目标平台标志

平台特定的标志源自目标三元组。有关支持的目标平台列表,请参阅平台支持

crystal --version 显示编译器的默认目标三元组。可以使用 --target 选项更改它。

以下表格中的标志是互斥的,除非标记为(衍生)。

架构

目标架构是目标三元组的第一个组件。

标志名称 描述
aarch64 AArch64 架构
avr AVR 架构
arm ARM 架构
i386 x86 架构(32 位)
wasm32 WebAssembly
x86_64 x86-64 架构
bits32 (衍生) 32 位架构
bits64 (衍生) 64 位架构
供应商

供应商是目标三元组的第二个组件。通常不使用,因此最常见的供应商是 unknown

标志名称 描述
macosx 苹果
portbld FreeBSD 变体
unknown 未知供应商
操作系统

操作系统源自目标三元组的第三个组件。

标志名称 描述
bsd (衍生) BSD 家族(DragonFlyBSD、FreeBSD、NetBSD、OpenBSD)
darwin Darwin(MacOS)
dragonfly DragonFlyBSD
freebsd FreeBSD
linux Linux
netbsd NetBSD
openbsd OpenBSD
solaris Solaris/illumos
unix (衍生) UNIX 类(BSD、Darwin、Linux、Solaris)
windows Windows
ABI

ABI 源自目标三元组的最后一个组件。

标志名称 描述
android Android(Bionic C 运行时)
armhf (衍生) ARM EABI 硬浮点
gnu GNU
gnueabihf GNU EABI 硬浮点
msvc Microsoft Visual C++
musl musl
wasi Web Assembly 系统接口
win32 (衍生) Windows API

编译器选项

编译器根据编译器配置设置这些标志。

标志名称 描述
release 编译器以发布模式运行(--release-O3 --single-module CLI 选项)
debug 编译器生成调试符号(没有 --no-debug CLI 选项)
static 编译器创建静态链接的可执行文件(--static CLI 选项)
docs 代码被处理以生成 API 文档(crystal docs 命令)
interpreted 在解释器中运行(crystal i

用户提供的标志

用户提供的标志不会自动定义。可以通过 --define-D 命令行选项将它们传递给编译器。

这些标志通常启用某些功能,激活新的或遗留的功能、新功能的预览,或完全替代的行为(例如用于调试目的)。

$ crystal eval -Dfoo 'puts {{ flag?(:foo) }}'
true

标准库功能

这些标志在构建 Crystal 程序时启用或禁用标准库中的功能。

标志名称 描述
gc_none 禁用垃圾回收(#5314)
debug_raise 用于 raise 逻辑的调试标志。在引发之前打印回溯。
preview_mt 启用多线程预览。在 0.28.0 中引入(#7546)
skip_crystal_compiler_rt 排除 Crystal 的原生 compiler-rt 实现。
tracing 构建时支持运行时跟踪。
use_libiconv 使用 libiconv 而不是系统的 iconv 库
use_pcre2 使用 PCRE2 作为正则表达式引擎(而不是旧的 PCRE)。在 1.7.0 中引入。
use_pcre 使用 PCRE 作为正则表达式引擎(而不是 PCRE2)。在 1.8.0 中引入。
win7 使用 Windows 7 的 Win32 WinNT API
without_iconv 不链接 iconv/libiconv
without_openssl 构建时不支持 OpenSSL
without_zlib 构建时不支持 Zlib

编译器功能

这些标志在构建 Crystal 程序时启用或禁用编译器功能。

标志名称 描述
no_number_autocast 不会自动转换数字表达式,仅转换字面量
no_restrictions_augmenter 禁用增强的限制增强器。在 1.5 中引入(#12103)。
preview_overload_order 启用更强大的 def 重载排序。在 1.6 中引入(#10711)。
strict_multi_assign 启用一对多赋值的严格语义。在 1.3.0 中引入(#11145, #11545)

编译器构建功能

这些标志在构建 Crystal 编译器时启用或禁用功能。

标志名称 描述
without_ffi 构建不带 libffi 的编译器
without_interpreter 构建不带解释器支持的编译器
without_playground 构建不带 playground 的编译器(crystal play
i_know_what_im_doing 防止无意中构建编译器的安全防护

用户代码功能

自定义标志可以在用户代码中自由使用,只要它们不与编译器提供的标志或其他用户定义的标志冲突。当使用特定于 shard 的标志时,建议使用 shard 名称作为前缀。

交叉编译(Cross-compilation)

Crystal 支持基本的交叉编译功能。

为了实现这一点,编译器可执行文件提供了两个标志:

  1. --cross-compile:启用交叉编译模式。
  2. --target:指定 LLVM 目标三元组,并设置默认的编译时标志。

要获取 --target 标志,可以在目标系统上安装 LLVM 后执行 llvm-config --host-target。例如,在 Linux 系统上,可能会输出 "x86_64-unknown-linux-gnu"

如果需要设置未通过 --target 隐式设置的编译时标志,可以使用 -D 命令行标志。

使用这两个标志,我们可以在 Mac 上编译一个将在 Linux 上运行的程序,如下所示:

crystal build your_program.cr --cross-compile --target "x86_64-unknown-linux-gnu"

这将生成一个 .o(目标文件),并打印一行命令,该命令需要在目标系统上执行。例如:

cc your_program.o -o your_program -lpcre -lrt -lm -lgc -lunwind

你需要将此 .o 文件复制到目标系统并执行这些命令。完成后,可执行文件将在目标系统上生成。

此过程通常用于将编译器本身移植到尚未支持的新平台。因为要编译 Crystal 编译器,我们需要一个旧版本的 Crystal 编译器,所以在没有编译器的系统上生成编译器的唯一两种方法是:

  1. 检出用 Ruby 编写的最新版本编译器,然后从该编译器逐步编译到当前版本。
  2. 在目标系统上生成 .o 文件,然后从该文件创建编译器。

第一种方法既漫长又繁琐,而第二种方法则简单得多。

交叉编译可以用于其他可执行文件,但其主要目标是编译器。如果 Crystal 在某个系统上不可用,你可以尝试在那里交叉编译它。

C 绑定(C Bindings)

Crystal 允许你绑定到现有的 C 库,而无需编写任何 C 代码。

此外,它还提供了一些便利功能,例如 outto_unsafe,使得编写绑定尽可能简单。

lib

lib 声明用于将与某个库相关的 C 函数和类型分组。

@[Link("pcre")]
lib LibPCRE
end

尽管编译器没有强制要求,但 lib 的名称通常以 Lib 开头。

属性用于向链接器传递标志以查找外部库:

  • @[Link("pcre")] 会将 -lpcre 传递给链接器,但编译器会首先尝试使用 pkg-config
  • @[Link(ldflags: "...")] 会将这些标志直接传递给链接器,而不进行修改。例如:@[Link(ldflags: "-lpcre")]。一种常见的技术是使用反引号执行命令:@[Link(ldflags: "pkg-config libpcre --libs")]
  • @[Link(framework: "Cocoa")] 会将 -framework Cocoa 传递给链接器(仅在 macOS 上有用)。

如果库是隐式链接的(例如 libc),则可以省略属性。

反射

lib 函数在宏语言中通过 TypeNode#methods 方法在整个程序中可见:

lib LibFoo
  fun foo
end

{{ LibFoo.methods }} # => [fun foo]

fun

lib 中的 fun 声明用于绑定到 C 函数。

lib C
  # 在 C 中:double cos(double x)
  fun cos(value : Float64) : Float64
end

一旦绑定,该函数就可以在 C 类型中像类方法一样使用:

C.cos(1.5) # => 0.0707372

如果函数没有参数,可以省略括号(调用时也可以省略):

lib C
  fun getch : Int32
end

C.getch

如果返回类型是 void,可以省略它:

lib C
  fun srand(seed : UInt32)
end

C.srand(1_u32)

你可以绑定到可变参数函数:

lib X
  fun variadic(value : Int32, ...) : Int32
end

X.variadic(1, 2, 3, 4)

注意,在调用 C 函数时没有隐式转换(除了稍后解释的 to_unsafe):你必须传递确切的预期类型。对于整数和浮点数,可以使用各种 to_... 方法。

函数名

lib 定义中的函数名可以以大写字母开头。这与方法和 lib 外部的函数定义不同,后者必须以小写字母开头。

Crystal 中的函数名可以与 C 名称不同。以下示例展示了如何将 C 函数名 SDL_Init 绑定为 Crystal 中的 LibSDL.init

lib LibSDL
  fun init = SDL_Init(flags : UInt32) : Int32
end

C 名称可以放在引号中,以便能够编写不是有效标识符的名称:

lib LLVMIntrinsics
  fun ceil_f32 = "llvm.ceil.f32"(value : Float32) : Float32
end

这也可以用于为 C 函数提供更短、更友好的名称,因为这些名称往往很长,并且通常以库名作为前缀。

C 绑定中的类型

在 C 绑定中使用的有效类型包括:

  • 基本类型(Int8, ..., Int64, UInt8, ..., UInt64, Float32, Float64
  • 指针类型(Pointer(Int32),也可以写成 Int32*
  • 静态数组(StaticArray(Int32, 8),也可以写成 Int32[8]
  • 函数类型(Proc(Int32, Int32),也可以写成 Int32 -> Int32
  • 之前声明的其他结构体、联合体、枚举、类型或别名。
  • Void:表示没有返回值。
  • NoReturn:类似于 Void,但编译器理解在该调用之后不会执行任何代码。
  • 带有 @[Extern] 注解的 Crystal 结构体

有关 fun 类型中使用的符号,请参阅类型语法。

标准库定义了 LibC 库,其中包含常见 C 类型的别名,如 intshortsize_t。在绑定中使用它们如下:

lib MyLib
  fun my_fun(some_size : LibC::SizeT)
end

注意:C 中的 char 类型在 Crystal 中是 UInt8,因此 char*const char*UInt8*。Crystal 中的 Char 类型是一个 Unicode 码点,因此它由四个字节表示,类似于 Int32,而不是 UInt8。如果有疑问,还可以使用别名 LibC::Char

out

考虑 waitpid 函数:

lib C
  fun waitpid(pid : Int32, status_ptr : Int32*, options : Int32) : Int32
end

该函数的文档说明:

子进程的状态信息存储在 status_ptr 指向的对象中,除非 status_ptr 是空指针。

我们可以这样使用这个函数:

status_ptr = uninitialized Int32

C.waitpid(pid, pointerof(status_ptr), options)

通过这种方式,我们将 status_ptr 的指针传递给函数,以便它填充其值。

有一种更简单的方法可以编写上述代码,即使用 out 参数:

C.waitpid(pid, out status_ptr, options)

编译器会自动声明一个 status_ptr 变量,类型为 Int32,因为参数的类型是 Int32*

这种方法适用于任何 fun 参数,只要其类型是指针(当然,前提是函数确实填充了指针所指向的值)。

to_unsafe

如果一个类型定义了 to_unsafe 方法,当将其传递给 C 函数时,将传递该方法返回的值。例如:

lib C
  fun exit(status : Int32) : NoReturn
end

class IntWrapper
  def initialize(@value)
  end

  def to_unsafe
    @value
  end
end

wrapper = IntWrapper.new(1)
C.exit(wrapper) # wrapper.to_unsafe 被传递给 C 函数,其类型为 Int32

这对于定义 C 类型的包装器非常有用,而无需显式将其转换为包装的值。

例如,String 类实现了 to_unsafe 以返回 UInt8*

lib C
  fun printf(format : UInt8*, ...) : Int32
end

a = 1
b = 2
C.printf "%d + %d = %d\n", a, b, a + b

struct

lib 中的 struct 声明用于声明一个 C 结构体。

lib C
  # 在 C 中:
  #
  #  struct TimeZone {
  #    int minutes_west;
  #    int dst_time;
  #  };
  struct TimeZone
    minutes_west : Int32
    dst_time : Int32
  end
end

你也可以指定多个相同类型的字段:

lib C
  struct TimeZone
    minutes_west, dst_time : Int32
  end
end

递归结构体的行为与预期一致:

lib C
  struct LinkedListNode
    prev, _next : LinkedListNode*
  end

  struct LinkedList
    head : LinkedListNode*
  end
end

要创建结构体的实例,使用 new

tz = C::TimeZone.new

这会在栈上分配结构体。

C 结构体的所有字段初始值为“零”:整数和浮点数从零开始,指针从地址零开始,等等。

为了避免这种初始化,可以使用 uninitialized

tz = uninitialized C::TimeZone
tz.minutes_west # => 某个垃圾值

你可以设置和获取其属性:

tz = C::TimeZone.new
tz.minutes_west = 1
tz.minutes_west # => 1

如果赋值的类型与属性的类型不完全相同,将尝试调用 to_unsafe

你也可以使用类似于命名参数的语法初始化某些字段:

tz = C::TimeZone.new minutes_west: 1, dst_time: 2
tz.minutes_west # => 1
tz.dst_time     # => 2

C 结构体在传递给函数和方法时是按值传递的(作为副本),在从方法返回时也是按值传递的:

def change_it(tz)
  tz.minutes_west = 1
end

tz = C::TimeZone.new
change_it tz
tz.minutes_west # => 0

有关结构体字段类型中使用的符号,请参阅类型语法。

union

lib 中的 union 声明用于声明一个 C 联合体:

lib U
  # 在 C 中:
  #
  #  union IntOrFloat {
  #    int some_int;
  #    double some_float;
  #  };
  union IntOrFloat
    some_int : Int32
    some_float : Float64
  end
end

要创建联合体的实例,使用 new

value = U::IntOrFloat.new

这会在栈上分配联合体。

C 联合体的所有字段初始值为“零”:整数和浮点数从零开始,指针从地址零开始,等等。

为了避免这种初始化,可以使用 uninitialized

value = uninitialized U::IntOrFloat
value.some_int # => 某个垃圾值

你可以设置和获取其属性:

value = U::IntOrFloat.new
value.some_int = 1
value.some_int   # => 1
value.some_float # => 4.94066e-324

如果赋值的类型与属性的类型不完全相同,将尝试调用 to_unsafe

C 联合体在传递给函数和方法时是按值传递的(作为副本),在从方法返回时也是按值传递的:

def change_it(value)
  value.some_int = 1
end

value = U::IntOrFloat.new
change_it value
value.some_int # => 0

有关联合体字段类型中使用的符号,请参阅类型语法。

enum

lib 中的 enum 声明用于声明一个 C 枚举:

lib X
  # 在 C 中:
  #
  #  enum SomeEnum {
  #    Zero,
  #    One,
  #    Two,
  #    Three,
  #  };
  enum SomeEnum
    Zero
    One
    Two
    Three
  end
end

与 C 语言一样,枚举的第一个成员值为零,后续值依次递增。

使用枚举值:

X::SomeEnum::One # => One

你可以指定成员的值:

lib X
  enum SomeEnum
    Ten       = 10
    Twenty    = 10 * 2
    ThirtyTwo = 1 << 5
  end
end

如你所见,成员值允许一些基本的数学运算:+, -, *, /, &, |, <<, >>%

默认情况下,枚举成员的类型是 Int32,即使你在常量值中指定了不同的类型:

lib X
  enum SomeEnum
    A = 1_u32
  end
end

X::SomeEnum # => 1_i32

然而,你可以更改这个默认类型:

lib X
  enum SomeEnum : Int8
    Zero
    Two  = 2
  end
end

X::SomeEnum::Zero # => 0_i8
X::SomeEnum::Two  # => 2_i8

你可以在 fun 参数或结构体、联合体成员中使用枚举作为类型:

lib X
  enum SomeEnum
    One
    Two
  end

  fun some_fun(value : SomeEnum)
end

变量(Variables)

C 库暴露的变量可以在 lib 声明中使用类似全局变量的声明来定义:

lib C
  $errno : Int32
end

然后可以获取和设置它:

C.errno # => 某个值
C.errno = 0
C.errno # => 0

可以使用注解将变量标记为线程局部的:

lib C
  @[ThreadLocal]
  $errno : Int32
end

有关外部变量类型中使用的符号,请参阅类型语法。

常量(Constants)

你也可以在 lib 声明中定义常量:

@[Link("pcre")]
lib PCRE
  INFO_CAPTURECOUNT = 2
end

PCRE::INFO_CAPTURECOUNT # => 2

type

lib 中的 type 声明用于声明一种 C 的 typedef,但更加强大:

lib X
  type MyInt = Int32
end

与 C 不同,Int32MyInt 不能互换:

lib X
  type MyInt = Int32

  fun some_fun(value : MyInt)
end

X.some_fun 1 # 错误:'X#some_fun' 的参数 'value' 必须是 X::MyInt,而不是 Int32

因此,type 声明对于由你包装的 C 库创建的不透明类型非常有用。一个例子是 C 的 FILE 类型,你可以通过 fopen 获取它。

有关 typedef 类型中使用的符号,请参阅类型语法。

alias

lib 中的 alias 声明用于声明一个 C 的 typedef

lib X
  alias MyInt = Int32
end

现在 Int32MyInt 可以互换:

lib X
  alias MyInt = Int32

  fun some_fun(value : MyInt)
end

X.some_fun 1 # 正常

alias 最常用于避免重复编写长类型,也可以根据编译时标志声明类型:

lib C
  {% if flag?(:x86_64) %}
    alias SizeT = Int64
  {% else %}
    alias SizeT = Int32
  {% end %}

  fun memcmp(p1 : Void*, p2 : Void*, size : C::SizeT) : Int32
end

有关 alias 类型中使用的符号,请参阅类型语法。

回调(Callbacks)

你可以在 C 声明中使用函数类型:

lib X
  # 在 C 中:
  #
  #    void callback(int (*f)(int));
  fun callback(f : Int32 -> Int32)
end

然后你可以像这样传递一个函数(Proc):

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

如果你在调用中内联定义函数,可以省略参数类型,编译器会根据 fun 签名为你添加类型:

X.callback ->(x) { x + 1 }

但请注意,传递给 C 的函数不能形成闭包。如果编译器在编译时检测到正在传递闭包,则会报错:

y = 2
X.callback ->(x) { x + y } # 错误:无法将闭包传递给 C 函数

如果编译器在编译时无法检测到这一点,则在运行时将引发异常。

有关回调和 Proc 类型中使用的符号,请参阅类型语法。

如果你想传递 NULL 而不是回调,只需传递 nil

# 等同于 C 中的 callback(NULL)
X.callback nil

将闭包传递给 C 函数

大多数情况下,允许设置回调的 C 函数还会提供一个参数用于自定义数据。然后,该自定义数据会作为参数传递给回调。例如,假设一个 C 函数在每次触发时调用回调,并传递该触发:

lib LibTicker
  fun on_tick(callback : (Int32, Void* ->), data : Void*)
end

为了正确定义此函数的包装器,我们必须将 Proc 作为回调数据传递,然后将该回调数据转换为 Proc,最后调用它。

module Ticker
  # 用户的回调没有 Void*
  @@box : Pointer(Void)?

  def self.on_tick(&callback : Int32 ->)
    # 由于 Proc 是 {Void*, Void*},我们无法将其转换为 Void*,因此我们
    # “装箱”它:我们分配内存并将 Proc 存储在那里
    boxed_data = Box.box(callback)

    # 我们必须将其保存在 Crystal 中,以免 GC 回收它 (*)
    @@box = boxed_data

    # 我们传递一个不形成闭包的回调,并将 boxed_data 作为
    # 回调数据传递
    LibTicker.on_tick(->(tick, data) {
      # 现在我们将 data 转换回 Proc,使用 Box.unbox
      data_as_callback = Box(typeof(callback)).unbox(data)
      # 最后调用用户的回调
      data_as_callback.call(tick)
    }, boxed_data)
  end
end

Ticker.on_tick do |tick|
  puts tick
end

请注意,我们将装箱的回调保存在 @@box 中。原因是如果我们不这样做,并且我们的代码不再引用它,GC 将回收它。C 库当然会存储回调,但 Crystal 的 GC 无法知道这一点。

Raises 注解

如果 C 函数执行用户提供的回调并且该回调可能引发异常,则必须使用 @[Raises] 注解标记它。

如果方法调用标记为 @[Raises] 或引发异常的方法(递归地),编译器会推断此注解。

然而,一些 C 函数接受由其他 C 函数执行的回调。例如,假设一个虚构的库:

lib LibFoo
  fun store_callback(callback : ->)
  fun execute_callback
end

LibFoo.store_callback ->{ raise "OH NO!" }
LibFoo.execute_callback

如果传递给 store_callback 的回调引发异常,那么 execute_callback 将引发异常。然而,编译器不知道 execute_callback 可能引发异常,因为它没有标记为 @[Raises],并且编译器无法推断这一点。在这种情况下,你必须手动标记这些函数:

lib LibFoo
  fun store_callback(callback : ->)

  @[Raises]
  fun execute_callback
end

如果你不标记它们,围绕此函数调用的 begin/rescue 块将无法按预期工作。

不安全代码(Unsafe Code)

以下语言部分被视为不安全的:

  • 涉及原始指针的代码:Pointer 类型和 pointerof
  • allocate 类方法。
  • 涉及 C 绑定的代码。
  • 未初始化变量声明。

“不安全”意味着可能导致内存损坏、段错误和崩溃。例如:

a = 1
ptr = pointerof(a)
ptr[100_000] = 2 # 未定义行为,可能会导致段错误

然而,常规代码通常不涉及指针操作或未初始化变量。而且 C 绑定通常被包装在安全的包装器中,包括空指针和边界检查。

没有一种语言是 100% 安全的:某些部分不可避免地是低级的,与操作系统交互并涉及指针操作。但一旦你抽象了这些内容并在更高的层次上操作,并且通过数学证明或彻底测试假设底层是安全的,你就可以确信你的整个代码库是安全的。