在使用某些编程语言时,你可能会面临一个挑战,即必须手动分配和释放内存。当程序的多个部分需要访问同一块内存时,很难追踪哪个值”拥有”它,并确定何时释放它。如果犯了一个错误,可能会导致 “use-after-free” 错误、”double free” 错误或 “leaked memory” 错误,其中任何一个都可能是灾难性的。

Mojo 通过确保每个值在同一时间只有一个变量拥有来避免这些错误,同时允许您与其他函数共享引用。当所有者的生命周期结束时,Mojo 会销毁该值。

在本页中,我们将解释规定这种所有权模型的规则,以及如何指定定义值如何共享到函数中的不同参数约定。

参数约定

在所有编程语言中,代码质量和性能在很大程度上取决于函数如何处理参数值。也就是说,函数接收的值是唯一值还是引用,以及它是可变的还是不可变的,都会产生一系列后果,定义了语言的可读性、性能和安全性。

在 Mojo 中,我们默认提供完整的值语义,这提供了一致和可预测的行为。但作为一种系统编程语言,我们还需要提供对内存优化的完全控制,这通常需要引用语义。关键在于以确保所有代码通过追踪每个值的生命周期并在正确的时间(且仅一次)销毁每个值的方式引入引用语义。在 Mojo 中,通过使用参数约定来实现所有这些。

参数约定指定了参数是可变的还是不可变的,以及函数是否拥有该值。每个约定由参数声明的开头的关键字定义:

  • borrowed:函数接收一个不可变引用。这意味着函数可以读取原始值(它不是副本),但不能修改它。

  • inout:函数接收一个可变引用。这意味着函数可以读取和修改原始值(它不是副本)。

  • owned:函数拥有所有权。这意味着函数对参数具有独占的可变访问权——函数调用者不再对此值具有访问权限。通常,这也意味着调用者应该将所有权转移给该函数,但并不总是发生这种情况,而可能是一个副本(后面将会了解)。

例如,此函数有一个可变引用参数和一个不可变引用参数:

  1. fn add(inout x: Int, borrowed y: Int):
  2. x += y
  3. fn main():
  4. var a = 1
  5. var b = 2
  6. add(a, b)
  7. print(a) # 输出 3

你可能已经看到了一些没有声明约定的函数参数。这是因为每个参数都有一个默认约定,取决于函数是用fn还是def声明的:

  • 所有传递给 Mojo def函数的值默认为owned

  • 所有传递给 Mojo fn函数的值默认为borrowed

在接下来的章节中,我们将详细解释每个参数约定。

拥有权概述

Mojo 的拥有权模型工作的基本规则如下:

  • 每个值在任何时候只有一个所有者。
  • 当所有者的生命周期结束时, Mojo 销毁该值。

“借用检查器”是 Mojo 编译器中的一个过程,确保每个值在任何时候只有一个所有者。它还强制执行一些其他的内存安全规则:

  • 不能为同一个值创建多个可变引用(inout)。(多个不可变引用(borrowed)是可以的。)

  • 如果存在对同一值的不可变引用(borrowed),则不能创建可变引用(inout)。 (待办事项:目前尚未实现。)

因为 Mojo 不允许可变引用与另一个可变引用或不可变引用重叠,所以它提供了一个可预测的编程模型,用于确定哪些引用是有效的(无效引用是指其生命周期已结束,可能是因为值的所有权已经转移)。重要的是,这种逻辑允许 Mojo 在值的生命周期结束时立即销毁值。

不可变参数(borrowed

如果您希望函数接收一个不可变引用,请在参数名称前加上borrowed关键字。

borrowed约定是fn函数中所有参数的默认值,但您仍然可以明确指定它。它也适用于def函数,def函数通常按值接收参数,这可能不是理想的情况,比如类型的复制成本很高(或根本不能复制),而您只需要读取它。例如:

  1. from tensor import Tensor, TensorShape
  2. def print_shape(borrowed tensor: Tensor[DType.float32]):
  3. shape = tensor.shape()
  4. print(shape.__str__())
  5. var tensor = TensorDType.float32
  6. print_shape(tensor)
  1. 256x256

通常情况下,当处理大型或成本昂贵的值时,传递不可变引用要比传递值更高效,因为借用不会调用复制构造函数和析构函数。

与C++和Rust的比较

Mojo 的borrowed参数约定在某些方面类似于在C++中通过const&传递参数,它也避免了对值的复制并禁用了调用方的可变性。然而,borrowed约定与C++中的const&有两个重要的不同之处:

  • Mojo 编译器实现了一个借用检查器(类似于Rust),当存在不可变引用时,防止代码动态形成对值的可变引用,并防止对同一值的多个可变引用。

  • IntFloatSIMD这样的小值直接通过机器寄存器传递,而不是通过额外的间接方式传递(这是因为它们使用了@register_passable装饰器声明)。这是与C++和Rust等语言相比的重大性能提升 (opens in a new tab),将此优化从每个调用点移动到类型定义上。

与Rust类似, Mojo 的借用检查器强制执行不变量的排他性。Rust和 Mojo 之间的主要区别在于 Mojo 不需要在调用方上加上标记来进行借用传递。此外,在传递小值时, Mojo 更高效,而Rust默认将值移动而不是通过借用传递。这些策略和语法决策使 Mojo 能够提供更易于使用的编程模型。

可变参数(inout

如果您希望函数接收一个可变引用,请在参数名称前加上inout关键字。您可以这样理解inout:它意味着在函数内部对值的任何更改都在函数外部可见。

例如,这个mutate()函数更新了原始的x值:

  1. def mutate(inout y: Int):
  2. y += 1
  3. var x = 1
  4. mutate(x)
  5. print(x)
  1. 2

这就像是对下面这个优化的简写:

  1. def mutate_copy(y: Int) -> Int:
  2. y += 1
  3. return y
  4. var x = 1
  5. x = mutate_copy(x)
  6. print(x)
  1. 2

尽管使用inout的代码并没有那么短,但它更节省内存,因为它不会对值进行复制。

然而,请记住,作为inout传递的值必须已经是可变的。例如,如果您尝试将一个borrowed值作为inout传递给另一个函数,您将会得到一个编译器错误,因为 Mojo 不能从不可变引用形成可变引用。

请注意,我们不将此参数传递称为“按引用传递”。虽然inout约定在概念上是相同的,但我们不将其称为按引用传递,因为实际上实现可能使用指针来传递值。

您不能为inout参数定义默认值。

转移参数(owned^

最后,如果您希望函数接收值的所有权,请在参数名称前加上owned关键字。

通常,这个约定与在传递给函数的变量上使用后缀^“转移”运算符相结合,该运算符结束了该变量的生命周期。

从技术上讲,owned关键字并不保证接收到的值是对原始值的可变引用,它只保证函数对这个特定值拥有唯一的所有权(强制值语义)。这可以通过以下两种方式之一来实现:

  • 调用者使用带有^转移运算符的参数,这将结束该变量的生命周期(变量变为无效),并将所有权转移给函数,而不复制任何堆分配的数据。

  • 调用者不使用^转移运算符,此时该值将被复制到函数参数中,原始变量仍然有效(除非它不再使用,在这种情况下,编译器会销毁该变量,因为它的生命周期在那里自然结束)。

无论如何,当函数将参数声明为owned时,它可以确定自己对该值具有唯一的可变访问权限。

例如,以下代码通过复制字符串的方式工作,因为虽然take_text()使用了owned约定,但调用者没有包含转移运算符:

  1. fn take_text(owned text: String):
  2. text += "!"
  3. print(text)
  4. fn my_function():
  5. var message: String = "Hello"
  6. take_text(message)
  7. print(message)
  8. my_function()
  1. Hello!
  2. Hello

然而,如果在调用take_text()时添加了^转移运算符,编译器会对print(message)报错,因为此时message变量已经未初始化。也就是说,以下版本无法编译:

  1. fn my_function():
  2. var message: String = "Hello"
  3. take_text(message^)
  4. print(message) # 错误:`message`变量未初始化

这是 Mojo 借用检查器的一个关键特性,因为它确保没有两个变量可以拥有相同值的所有权。要修复错误,您必须在使用^转移运算符结束其生命周期后不再使用message变量。下面是修正后的代码:

  1. fn my_function():
  2. var message: String = "Hello"
  3. take_text(message^)
  4. my_function()
  1. Hello!

Mojo 的REPL中的顶层代码还没有完全实现值的生命周期,因此转移运算符目前仅在函数内部使用时起作用。

转移实现细节

在 Mojo 中,重要的是不要将“所有权转移”与“移动操作”混为一谈,它们并不完全相同。

Mojo 有多种方式可以在不进行复制的情况下转移值的所有权:

  • 如果一个类型实现了移动构造函数__moveinit__(),当这个类型的值作为owned参数传递到函数中时, Mojo 可能会调用这个方法,前提是原始值的生命周期在同一点结束(无论是否使用了^转移运算符)。

  • 如果一个类型没有实现__moveinit__(), Mojo 可以通过简单地将值的引用传递给调用者的堆栈中的接收者来转移所有权。

为了使owned约定在没有转移运算符的情况下工作,值类型必须是可复制的(通过__copyinit__())。

比较deffn的参数约定

如上所述,对于调用者来说,deffn函数是可以互换的,它们都可以实现相同的功能。它们的区别仅在于内部实现, Mojo 的def函数本质上只是fn函数的语法糖:

  • 没有类型注解的def参数默认为object类型(而fn要求所有类型都必须显式声明)。

  • 没有约定关键字(borrowedinoutowned)的def参数默认为owned(它接收一个带有可变变量的副本)。

例如,下面这两个函数具有完全相同的行为:

  1. def example(borrowed a: Int, inout b: Int, c):
  2. pass
  3. fn example(a: Int, inout b: Int, owned c: object):
  4. pass

或者,您可以通过在需要时手动进行复制来获得与c参数相同的效果:

  1. fn example(a: Int, inout b: Int, c_in: object):
  2. var c = c_in
  3. pass

这个影子复制通常不会增加额外的开销,因为像object这样的小型类型的引用是廉价的复制。昂贵的部分是调整引用计数,但这也可以通过编译器优化来消除。