一架梯子,一头程序猿,仰望星空!
Mojo教程 > 内容正文

Mojo函数参数详解


参数传递控制和内存所有权

在Python和Mojo中,很多语言特性都围绕着函数调用展开:许多(表面看起来)内置的行为都是由标准库中的“双下划线”(dunder)方法实现的。在这些魔法函数中,很多内存所有权是通过参数传递来确定的。

让我们回顾一下Python和Mojo是如何传递参数的:

  • 所有传递给Python def函数的值都使用引用语义。这意味着函数可以修改传递给它的可变对象,并且这些变化在函数外部是可见的。然而,对于未经培训的人来说,这种行为有时可能会让人感到惊讶,因为您可以更改参数所指向的对象,而这种变化在函数外部是不可见的。
  • 所有传递给Mojo def函数的值默认情况下都使用值语义。与Python相比,这是一个重要的区别:Mojo def函数接收所有参数的副本-它可以在函数内部修改参数,但是这些变化在函数外部是不可见的。
  • 所有传递给Mojo fn函数的值默认情况下都是不可变的引用。这意味着函数可以读取原始对象(它不是副本),但是不能修改对象。

在Mojo中,将不可变值传递给fn的这种约定被称为“借用”。在接下来的几节中,我们将介绍如何在Mojo中更改deffn函数的参数传递行为。

参数约定的重要性

在Python中,所有基本值都是对象的引用-如上所述,Python函数可以修改原始对象。因此,Python开发人员习惯于将所有内容都视为引用语义。然而,在CPython或机器级别上,您可以看到引用本身实际上是通过复制传递的- Python复制一个指针并调整引用计数。

这种Python方法为大多数人提供了一种舒适的编程模型,但它要求所有值都在堆上分配(并且由于引用共享,结果偶尔会出现意外结果)。Mojo类(待完成)对大部分对象采用了相同的引用语义方法,但是对于系统编程环境中的简单类型(如整数),这种方法并不实际。在这些情况下,我们希望值存储在堆栈上,甚至是硬件寄存器中。因此,Mojo结构总是内联到其容器中,无论是作为另一种类型的字段,还是作为包含函数的堆栈帧。

这引发了一些有趣的问题:如何实现需要修改结构类型的self的方法,例如__iadd__let如何工作,以及如何防止其变异?如何控制这些值的生命周期,以使Mojo成为一种内存安全的语言?

答案是Mojo编译器使用数据流分析和类型注释来实现对值的复制、引用别名和变异控制的完全控制。这些功能在某种程度上类似于Rust语言中的某些功能,但它们的工作方式略有不同,以使Mojo更易于学习,并且更好地与Python生态系统集成,而不需要大量的注释负担。

在接下来的几节中,您将了解如何在传递给Mojo fn函数的对象中控制内存所有权。

不可变参数(borrowed)

借用对象是函数接收的对象的不可变引用,而不是对象的副本。因此,被调用函数可以完全读写访问该对象,但不能修改它(调用者仍然拥有对象的“所有权”)。

例如,考虑以下在传递实例时不想复制的结构体:

struct SomethingBig:
    var id_number: Int
    var huge: HeapArray
    fn __init__(inout self, id: Int):
        self.huge = HeapArray(1000, 0)
        self.id_number = id

    fn set_id(inout self, number: Int):
        self.id_number = number

    fn print_id(self):  # Same as: fn print_id(borrowed self):
        print(self.id_number)

当向函数传递SomethingBig的实例时,需要传递一个引用,因为SomethingBig不能复制(它没有__copyinit__方法)。并且,如上所述,fn参数默认是不可变引用,但可以使用borrowed关键字显式定义,如下面的use_something_big()函数所示:

fn use_something_big(borrowed a: SomethingBig, b: SomethingBig):
    """'a' and 'b' are both immutable, because 'borrowed' is the default."""
    a.print_id()
    b.print_id()

let a = SomethingBig(10)
let b = SomethingBig(20)
use_something_big(a, b)

输出结果:

10
20

这个默认规则适用于所有参数,包括方法的self参数。当传递大型值或者传递昂贵的值(如引用计数指针,这是Python/Mojo类的默认值)时,这样做更高效,因为无需调用复制构造函数和析构函数来传递参数。

由于fn函数的默认参数约定是borrowed,因此Mojo具有简单而逻辑清晰的代码,默认情况下可以正确运行。例如,我们不希望为了调用print_id()方法或调用use_something_big()方法而复制或移动整个SomethingBig

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

  1. Mojo编译器实施了借用检查器(类似于Rust),防止在存在不可变引用时动态形成可变引用,并防止对同一值进行多个可变引用。允许拥有多个借用(如上面对use_something_big的调用),但不能同时通过可变引用传递和借用某个值(TODO:目前尚未启用)。
  2. IntFloatSIMD等小值直接通过机器寄存器传递,而不需要额外的间接引用(这是因为它们使用了@register_passable装饰器)。与C++和Rust等语言相比,这是一个重要的性能提升),将这种优化从每个调用点移到类型上进行声明。

与Rust类似,Mojo的借用检查器强制执行不变式的排他性。Rust和Mojo之间的主要区别在于,Mojo在调用方不需要使用特殊标记通过借用进行传递。此外,与通过借用传递值不同,默认情况下Rust会移动值。这些策略和语法决策使Mojo能够提供更易于使用的编程模型。

可变参数(inout

另一方面,如果你定义了一个 fn 函数并且想要一个参数是可变的,你必须使用 inout 关键字来声明该参数是可变的。

提示: 当你看到 inout 时,它意味着在函数内部对参数所做的任何更改都会在函数外部可见。

考虑以下示例,__iadd__ 函数(用于实现原地加法操作,如 x += 2)试图修改 self

struct MyInt:
    var value: Int

    fn __init__(inout self, v: Int):
        self.value = v

    fn __copyinit__(inout self, other: MyInt):
        self.value = other.value

    fn __add__(self, rhs: MyInt) -> MyInt:
        return MyInt(self.value + rhs.value)

如果取消注释 __iadd__() 方法,会得到编译错误。

问题在于 self 是不可变的,因为这是一个 Mojo 的 fn 函数,所以它不能改变参数的内部状态(默认参数约定是 borrowed)。解决办法是通过在 self 参数名上添加 inout 关键字来声明参数是可变的:

struct MyInt:
    var value: Int

    fn __init__(inout self, v: Int):
        self.value = v

    fn __copyinit__(inout self, other: MyInt):
        self.value = other.value

    fn __add__(self, rhs: MyInt) -> MyInt:
        return MyInt(self.value + rhs.value)

    fn __iadd__(inout self, rhs: Int):
        self = self + rhs

现在,在函数中,self 参数是可变的,并且任何更改都在调用者中可以看到,因此我们可以在 MyInt 中执行就地加法:

var x: MyInt = 42
x += 1
print(x.value) # 打印 43,符合预期

let y = x
43

如果取消上面的最后一行的注释,对 let 值进行更改会失败,因为无法将不可变值形成可变的引用(let 使变量不可变)。

当然,你可以声明多个 inout 参数。例如,你可以像这样定义和使用一个交换函数:

fn swap(inout lhs: Int, inout rhs: Int):
    let tmp = lhs
    lhs = rhs
    rhs = tmp

var x = 42
var y = 12
print(x, y)  # 打印 42, 12
swap(x, y)
print(x, y)  # 打印 12, 42
42 12
12 42

这个系统的一个非常重要的方面是它的所有部分都正确地组合在一起。

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

转移参数(owned^

Mojo支持的最后一种参数约定是owned参数约定。该约定用于希望独占某个值的函数,并且通常与后缀为^的操作符一起使用。

例如,假设你正在使用一个只能移动的类型,比如独特指针:

struct UniquePointer:
    var ptr: Int

    fn __init__(inout self, ptr: Int):
        self.ptr = ptr

    fn __moveinit__(inout self, owned existing: Self):
        self.ptr = existing.ptr

    fn __del__(owned self):
        self.ptr = 0

使用borrow约定可以方便地操作这个独特指针,但是在某些情况下,你可能想将所有权转移到其他函数中。这种情况下,你可以使用^“转移”操作符与可移动类型一起使用。

^操作符终止一个值绑定的生命周期,并将值所有权转移给其他对象(在以下示例中,所有权转移到take_ptr()函数)。为了支持这一点,你可以将函数定义为接受owned参数。例如,你可以定义take_ptr()以以下方式接受参数的所有权:

fn take_ptr(owned p: UniquePointer):
    print("take_ptr")
    print(p.ptr)

fn use_ptr(borrowed p: UniquePointer):
    print("use_ptr")
    print(p.ptr)

fn work_with_unique_ptrs():
    let p = UniquePointer(100)
    use_ptr(p)    # 传递给借用函数。
    take_ptr(p^)  # 将`p`值的所有权传递给另一个函数。


work_with_unique_ptrs()

由上面的输出结果可知,如果取消第二次对use_ptr()的调用的注释,会出现错误,因为p的值已经转移到take_ptr()函数中,这样就销毁了p的值。

由于被声明为ownedtake_ptr()函数知道它对该值有唯一访问权限。对于类似独特指针的东西来说,这非常重要,当你想要避免复制时它也很有用。

例如,你会在析构函数和消耗性移动初始化器中明显看到owned约定。例如,我之前定义的HeapArray结构在其__del__()方法中使用了owned,因为你需要拥有一个值才能销毁它(或者在移动构造函数的情况下窃取其部分)。

比较deffn的参数传递

Mojo的def函数本质上只是fn函数的语法糖:

  • 没有显式类型注释的def参数默认为Object
  • 没有约定关键字(如inoutowned)的def参数将被隐式复制并传递给与参数名称相同的可变变量。(这要求该类型具有__copyinit__方法。)

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

def example(inout a: Int, b: Int, c):
    ...

fn example(inout a: Int, b_in: Int, c_in: Object):
    var b = b_in
    var c = c_in
    ...

通常,阴影复制不会增加额外的开销,因为像Object这样的小类型的引用复制起来很廉价。昂贵的部分是调整引用计数,但通过移动优化可以消除这个开销。