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

Mojo变量生命周期


“值的生命周期”:值的生成、存在和销毁

到目前为止,您应该已经了解了 Mojo 函数和类型的核心语义和特性,所以现在我们可以讨论它们如何结合在一起来表达 Mojo 中的新类型。

许多现有的编程语言通过不同的权衡来表达设计要点。例如,C++ 非常强大,但常常被指责“默认设置不正确”,导致错误和不合理的特性。Swift 易于使用,但其模型的可预测性较低,会频繁地复制值,并且依赖于“ARC 优化器”来提高性能。Rust 以满足其借用检查器的目标为出发点,开始时具备强大的值所有权机制,但它依赖于值的可移动性,这使得表达自定义移动构造器变得困难,并对 memcpy 的性能产生很大压力。在 Python 中,所有东西都是对类的引用,因此它从未真正面临类型问题。

对于 Mojo,我们从这些现有系统中吸取了教训,并致力于提供一个非常强大的模型,同时易于学习和理解。我们也不希望要求编译器具备“尽力而为”和难以预测的优化处理的能力。

为了探索这些问题,我们将关注不同的值分类以及表达它们所需的相关 Mojo 特性,并从基础开始逐步构建。在示例中,我们将 C++ 作为主要的比较对象,因为它被广泛知晓,但偶尔我们会参考其他语言,如果它们能提供更好的比较对象的话。

无法实例化的类型

在 Mojo 中,最基本的类型是那些不允许您创建其实例的类型:这些类型根本没有初始化器,如果它们具有析构函数,将永远不会被调用(因为没有实例需要销毁):

struct NoInstances:
    var state: Int  # 相当无用

    alias my_int = Int

    @staticmethod
    fn print_hello():
        print("hello world")

Mojo 类型不会自动获得默认构造函数、移动构造函数、成员初始化器或任何其他东西,因此无法创建此 NoInstances 类型的实例。要获得它们,需要定义一个 __init__ 方法或使用一个能够合成初始化器的装饰器。如上所示,这些类型可以作为“命名空间”很有用,因为虽然无法实例化此类型,但您可以引用静态成员,如 NoInstances.my_intNoInstances.print_hello()

不可移动和不可复制的类型

如果我们向上迈一步,我们将会遇到可以实例化的类型,但一旦它们固定在内存地址上,它们便无法被隐式移动或复制。这对于实现原子操作(如 C++ 中的 std::atomic)或其他类型,在其中内存地址是其唯一标识且对其目的至关重要,是非常有用的:

struct Atomic:
    var state: Int

    fn __init__(inout self, state: Int = 0):
        self.state = state

    fn __iadd__(inout self, rhs: Int):

    fn get_value(self) -> Int:
        return atomic_load_int(self.state)

该类定义了一个初始化器,但没有复制或移动构造函数,所以一旦它被初始化后,就无法再移动或复制。这是安全且有用的,因为 Mojo 的所有权系统完全“地址正确” - 当此类型被初始化到堆栈或某些其他类型的字段中时,它永远不需要移动。

请注意,Mojo 的方法仅控制内置的移动操作,例如 a = b 的复制和转移运算符 ^。您可以对自己的类型(如上面的 Atomic)使用一个有着显式 copy() 方法(非“dunder”方法)的有用模式。当程序员知道对实例进行显式复制是安全的时候,这将非常有用。

独特的“只移动”类型

如果我们再往高级能力方向迈进一步,我们会遇到“独特”的类型——C++中有很多这样的例子,例如std::unique_ptr或拥有底层 POSIX 文件描述符的FileDescriptor类型。这些类型在Rust等语言中非常普遍,其中不鼓励复制,但“移动”是免费的。在Mojo中,你可以通过定义__moveinit__方法来实现这些移动操作,将其定义为独特类型的所有权。例如:

struct FileDescriptor:
    var fd: Int

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

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

    fn __init__(inout self, path: String):
        self = FileDescriptor(open(path, ...))

    fn __del__(owned self):
        close(self.fd)   # 伪代码,调用close(2)

    fn dup(self) -> Self:
        return Self(dup(self.fd))
    fn read(...): ...
    fn write(...): ...

消费式移动构造函数(__moveinit__)接管一个现有的FileDescriptor的所有权,并将其内部实现细节移动到新的实例中。这是因为FileDescriptor的实例可能存在于不同的位置,并且它们可以在逻辑上移动——窃取一个值的内容并将它移到另一个值中。

下面是一个恶劣的示例,将会多次调用__moveinit__

fn egregious_moves(owned fd1: FileDescriptor):
    let fd2 = fd1^

    let fd3 = fd2^

    let fd4 = fd3^
    fd4.read(...)

请注意,值的所有权在拥有它的各个值之间转移,使用后置^“转移”运算符,该运算符销毁前一个绑定并将所有权转移给一个新的常数。如果您熟悉C++,可以将转移运算符简单地视为std::move,但在这种情况下,我们可以看到它能够在不重置到可销毁状态下移动对象:在C++中,如果移动操作符未能更改旧值的fd实例,它将被关闭两次。

Mojo会跟踪值的存活状态,并允许您定义自定义的移动构造函数。尽管很少需要,但当需要时它非常强大。例如,某些类型(如llvm::SmallVector类型)使用“内联存储”优化技术,并且可能希望使用指向其实例的“内部指针”来实现。这是减轻malloc内存分配器压力的众所周知的技巧,但这意味着“移动”操作需要自定义逻辑来更新指针。

在Mojo中,实现自定义的__moveinit__方法非常简单。这在C++中也很容易实现(尽管在不需要自定义逻辑的情况下会有样板代码),但在其他流行的内存安全语言中很难实现。

另外需要注意的是,尽管Mojo编译器提供了良好的可预测性和控制性,但它也非常复杂。它保留了消除临时变量和相应的复制/移动操作的权利。如果对于你的类型来说这是不合适的,应该使用显式的方法(如copy())而不是dunder方法。

支持“盗取移动”(stealing move)的类型

内存安全语言面临的一个挑战是,它们需要提供一个可预测的编程模型,以便编译器能够追踪的内容,而编译器中的静态分析在本质上是有限的。例如,编译器可能能够理解下面第一个示例中的两个数组访问是针对不同的数组元素的,但是通常(在一般情况下)是无法推断出下面第二个示例的情况(以下是 C++ 代码):

std::pair getValues1(MutableArray &array) {
    return { std::move(array[0]), std::move(array[1]) };
}
std::pair getValues2(MutableArray &array, size_t i, size_t j) {
    return { std::move(array[i]), std::move(array[j]) };
}

问题在于,在仅看到上述函数体的情况下,根本无法知道或证明 ij 的动态值不是相同的。虽然可以维护动态状态以跟踪数组的各个元素是否活跃,但这往往会导致显著的运行时开销(即使没有使用 move/transfer),而这是 Mojo 和其他系统级编程语言不愿意做的。处理此问题的方法有多种,包括一些相当复杂的解决方案,不一定容易学习。

Mojo采用了实用主义的方法,让Mojo程序员在无需绕过类型系统的情况下完成工作。如上所示,它不强制要求类型可复制、可移动,甚至可构造,但要求类型表达其完整契约,并且希望能够支持程序员从C++等语言中期望的流畅设计模式。如下(众所周知)观察所示,许多对象具有可以“盗取”的内容,而无需禁用其析构函数,这要么是因为它们具有“空状态”(例如可选类型或可为空指针),要么是因为它们具有可以高效创建并且销毁时无操作的空值(例如 std::vector 可以具有空指针用于其数据)。

为了支持这些用例,^ 转移操作符, 支持任意的左值(LValues),当应用于一个左值时,它会调用“盗取移动构造函数”(stealing move constructor)。此构造函数必须将新值设置为活跃状态,并且可以改变旧值,但必须将旧值置于其析构函数仍然可用的状态。例如,如果我们想将我们的 FileDescriptor 放入一个向量中并从中移出,我们可以选择将其扩展以了解 -1 是一个哨兵值,表示它是“空”的。我们可以这样实现它:

struct FileDescriptor:
    var fd: Int

    fn __moveinit__(inout self, inout existing: Self):
        self.fd = existing.fd
        existing.fd = -1  # 中和 'existing'

    fn __moveinit__(inout self, owned existing: Self): # 同上
    fn __init__(inout self, fd: Int): # 同上
    fn __init__(inout self, path: String): # 同上

    fn __del__(owned self):
        if self.fd != -1:
            close(self.fd)   # 伪代码,调用 close(2) 函数

注意,“盗取移动”构造函数从现有值获取文件描述符并改变该值,使得其析构函数不会执行任何操作。这种技术有其利弊,并不适用于每种类型。我们可以看到,它在析构函数中添加了一个(廉价的)分支,因为它必须检查哨兵值的情况。通常将此类类型设置为可为空是不好的做法,因为更通用的功能,如 Optional[T] 类型,是处理此问题的更好方式。

此外,我们计划在 Mojo 自身中实现 Optional[T],而 Optional 需要此功能。我们还相信,库的作者比语言设计者更好地了解领域问题,并且通常更愿意将该领域的完全控制权交给库的作者。因此,您可以选择(但并非必须)以一种选择加入的方式使您的类型参与此行为。

可复制的类型

从可移动类型升级到可复制类型。可复制类型同样非常常见 - 程序员通常期望像字符串和数组这样的东西是可复制的,并且每个 Python 对象引用都是可复制的 - 通过复制指针并调整引用计数。

有很多方法可以实现可复制类型。可以实现像 Python 或 Java 这样的引用语义类型,在其间传播共享指针,可以使用不可变的数据结构,因为它们在创建后不会被改变,这样可以很容易地进行共享,并且可以通过 Swift 中的惰性写时复制来实现深度值语义。这些方法都有不同的权衡,Mojo 认为,虽然我们希望有一些常见的集合类型,但我们也可以支持广泛的专门用例的特殊集合类型。

在 Mojo 中,你可以通过实现 __copyinit__ 方法来实现这一点。下面是使用简单的 String(伪代码)示例:

struct MyString:
    var data: Pointer[UI8]

    def __init__(inout self, input: StringRef):
        self.data = ...

    def __copyinit__(inout self, existing: Self):
        self.data = strdup(existing.data)

    def __moveinit__(inout self, owned existing: Self):
        self.data = existing.data

    def __del__(owned self):
        free(self.data.address)

    def __add__(self, rhs: MyString) -> MyString: ...

这个简单的类型是指向使用 malloc 分配的“以空字符结尾”的字符串数据的指针,为了清晰起见,使用了老式的 C API。它实现了 __copyinit__,它保持了每个 MyString 实例都拥有其底层指针,并在销毁时释放它的不变性。这个实现基于我们之前看到的一些技巧,并实现了一个 __moveinit__ 构造函数,它允许在一些常见情况下完全消除临时拷贝。你可以在以下代码序列中看到这种行为:

fn test_my_string():
    var s1 = MyString("hello ")

    var s2 = s1    # 在这里运行 s2.__copyinit__(s1)

    print(s1)

    var s3 = s1^   # 在这里运行 s3.__moveinit__(s1)

    print(s2)
    print(s3)

在这种情况下,你可以看到为什么需要一个复制构造函数:如果没有一个复制构造函数,将 s1 的值复制给 s2 将是一个错误 - 因为你不能有两个同一非可复制类型的实例。移动构造函数是可选的,但它有助于将值分配给 s3:如果没有它,编译器将调用从 s1 复制构造函数,然后销毁旧的 s1 实例。这在逻辑上是正确的,但会引入额外的运行时开销。

Mojo 毫不犹豫地销毁值,这使得它能够将复制+销毁对转换为单个移动操作,这比 C++ 要高效得多,而不需要对 std::move 进行广泛操作。

简单类型

最灵活的类型是那些只是“一堆二进制数据”的类型。这些类型是“简单的”,因为它们可以通过拷贝、移动和销毁而不需要调用定制代码。这些类型可以说是我们最常见的基本类型:比如整数和浮点数都是简单类型。从语言的角度来看,Mojo不需要为这些类型提供特殊支持,类型的作者可以将它们实现为空操作,并允许内联器将它们消除。

这种方法不够优化的原因有两个:首先,我们不希望在简单类型上定义大量方法的样板代码;其次,我们不希望在编译时生成和传递大量的函数调用,只为了让它们在内联时消失。此外,还有一个相关的问题,很多这些类型在另一方面也是简单的:它们是微小的,应该在CPU的寄存器中传递,而不是间接存储在内存中。

因此,Mojo提供了一种结构体修饰器来解决所有这些问题。你可以使用@register_passable("trivial")修饰器来实现一个类型,这告诉Mojo该类型应该是可拷贝和可移动的,但没有用户定义的逻辑来完成这些操作。它还告诉Mojo优先在CPU寄存器中传递值,这可能会带来效率上的好处。

待办事项:这个修饰器需要重新考虑。缺乏自定义的拷贝/移动/销毁逻辑和“在寄存器中可传递”的问题是无关的,应该分开考虑。前者的逻辑应该包含在一个更通用的@value("trivial")修饰器中,这与@register_passable是无关的。

@value装饰器

Mojo的value生命周期提供了简单可预测的钩子,使您能够正确表达Atomic等特殊低级事物。这对于控制和简单的编程模型非常有用,但大多数结构体只是其他类型的简单聚合,并且我们不希望为它们编写大量模板代码。为解决这个问题,Mojo为结构体提供了@value装饰器,可以为您合成许多模板代码。

您可以将@value视为Python的@dataclass的扩展,还处理Mojo的__moveinit____copyinit__方法。

@value装饰器会检查您的类型的字段,并生成一些缺失的成员。例如,考虑一个简单的结构体,像这样:

@value
struct MyPet:
    var name: String
    var age: Int

Mojo会注意到您没有逐成员初始化程序、移动构造函数或复制构造函数,并为您合成这些代码,就好像您已经编写了:

struct MyPet:
    var name: String
    var age: Int

    fn __init__(inout self, owned name: String, age: Int):
        self.name = name^
        self.age = age

    fn __copyinit__(inout self, existing: Self):
        self.name = existing.name
        self.age = existing.age

    fn __moveinit__(inout self, owned existing: Self):
        self.name = existing.name^
        self.age = existing.age

当您添加@value装饰器时,Mojo只会在特定方法不存在时合成每个特殊方法。您可以通过定义自己的版本来覆盖一个或多个方法的行为。例如,通常希望自定义复制构造函数但使用默认的逐成员和移动构造函数。

__init__的参数全部以owned参数传递,因为结构体会拥有并存储这些值。这是一个有用的微优化,并且可以使用只移动类型。像Int这样的简单类型也以拥有的值传递,但因为对它们来说这没有任何意义,所以我们省略了标记和转移操作符(^)以保持清晰。

注意: 如果您的类型包含任何只移动类型,Mojo将不会生成复制构造函数,因为它无法复制这些类型的字段。此外,@value装饰器仅适用于其成员可以复制和/或移动的类型。如果您的结构体中有类似Atomic的字段,则该结构体可能不是值类型,您也不需要这些成员。

还请注意上述MyPet结构体不包括__del__()析构函数——Mojo也会合成它,但它不需要@value装饰器(关于析构函数的行为,请参见下面关于析构函数的部分)。

目前无法抑制特定方法的生成或自定义生成方式,但如果有需求,我们可以为@value生成器添加参数来实现。