Mojo 学习 —— 值的生命周期

Mojo 学习 —— 值的生命周期

介绍

前面介绍了如何使用 Mojo 的所有权模型来构建高性能的代码,而无需手动管理内存。

然而,Mojo 是为系统编程而设计的,而系统编程通常需要对自定义数据类型进行手动内存管理。

所以,Mojo 也提供了自定义内存管理机制。再次强调Mojo 没有引用计数器和垃圾回收器。

标准库中的所有数据类型(如 BoolIntString)都是以结构体的形式实现的。所以你自定义的结构体和他们没什么区别。

当然你也可以使用 MLIR 方言提供的底层原语来重写这些类型。

Mojo 语言的强大之处在于,它不仅提供了系统编程的底层工具,同时又在一个框架内帮助你构建安全且易于使用的高层程序。

也就是说,你可以在底层编写所有你想要的 “不安全” 代码,只要你是按照 Mojo 的值语义编写的,那么实例化你的类型/对象的程序员就完全不需要考虑内存管理问题,而且由于值所有权的存在,其行为将是安全和可预测的。

总之,类型的提供者有责任通过实现特定的生命周期方法,如构造函数、复制构造函数、移动构造函数和析构函数,来管理每个值类型的内存和资源。

Mojo 默认不创建任何构造函数,但是会给未定义构造函数的类型添加一个简单的无操作析构函数。

接下来我们将详细介绍如何根据值语义定义这些生命周期方法

lifecycles 和 lifetimes

首先,让我们澄清一些术语:

  • 生命周期(lifecycles):

值的生命周期是由结构体中的各种魔法方法定义的。每个生命周期事件都由不同的方法处理,例如构造函数(__init__)、析构函数(__del__)、复制构造函数(__copyinit__)和移动构造函数(__moveinit__)。所有声明为相同类型的值的生命周期相同。

  • 生存期(lifetimes):

值的生存期是指在程序执行过程中,每个值被认为有效的时间跨度。值的生存期从初始化时开始,到销毁时结束,一般(但不总是)从 __init____del__。没有两个值的生存期是完全相同的,因为每个值都是在不同的时间点创建和销毁的(即使两者之间的差异微乎其微)。

Mojo 中,一个值的生存期从变量初始化时开始,直到该值最后一次被使用,然后立即将其销毁。并使用 “as soon as possibleASAP)” 销毁策略,在最后使用该值或对象的子表达式之后运行。

生存期管理的最后一块拼图是值的生命周期:每个值都需要实现关键的生命周期方法,这些方法定义了值是如何创建和销毁的。

值的生存期

Mojo 中,一个值的生存期从变量初始化时开始,一直持续到该值最后一次被使用,然后将其销毁。

Mojo 结构体默认是不包含生命周期方法的,所以你可以创建一个没有构造函数的结构体,但你无法实例化它,只能作为静态方法的命名空间。例如

struct NoInstances:
    var state: Int

    @staticmethod
    fn print_hello():
        print("Hello world!")

由于没有构造函数,它无法实例化,所以它没有生命周期。

而且 state 字段是没有用的,因为 Mojo 结构体不支持默认字段值,必须在构造函数中进行初始化。没有构造函数,所以无法初始化

但是你还是可以调用结构体中的静态方法:

NoInstances.print_hello()
# Hello world!

构造函数

要创建 Mojo 类型的实例,需要使用 __init__ 构造函数方法。构造函数的主要职责是初始化所有字段,而且是必须初始化所有字段。例如

struct MyPet:
    var name: String
    var age: Int

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

现在可以创建一个实例:

var mine = MyPet("Loki", 4)

MyPet 的实例也可以借用和销毁,但目前还无法复制和移动。

不添加默认行为的好处是,会减少很多意外的情况,你不实现生命周期函数,意味着别人不能使用它的生命周期行为,一旦实现了复制和移动构造函数,就明确决定了如何复制或移动类型。

注意

Mojo 可以不需要自定义析构函数来销毁对象。只要结构体中的所有字段都是可销毁的(标准库指针除外的所有类型都是可销毁的),那么 Mojo 就知道如何在其生命周期结束时销毁该类型。

重载构造函数

像其他函数/方法一样,你可以重载 __init__ 构造函数,用不同的参数初始化对象。

例如,你可能需要一个默认构造函数来执行参数的初始化,并且不需要任何参数,然后再使用其他构造函数来接受更多的参数。

例如,以下是重载构造函数的方法:

struct MyPet:
    var name: String
    var age: Int

    fn __init__(inout self):
        self.name = ""
        self.age = 0

    fn __init__(inout self, name: String):
        self = MyPet()
        self.name = name

请注意,每个构造函数都必须使用 inout 声明 self 参数,保证其是可修改的。

如果要从一个构造函数中调用另一个构造函数,直接像调用普通函数一样,无需使用 self

字段初始化

再强调一遍,每个构造函数结束时,所有字段都必须初始化,这是构造函数的唯一要求。

事实上,__init__ 构造函数非常聪明,只要所有字段都已初始化,即使在构造函数结束之前,它也会将 self 对象视为已完全初始化。

只要完全初始化之后,self 对象就可以被传递使用。例如

fn use(arg: MyPet):
    pass

struct MyPet:
    var name: String
    var age: Int

    fn __init__(inout self, name: String, age: Int, cond: Bool):
        self.name = name
        if cond:
            self.age = age
            use(self)

        self.age = age
        use(self)

构造函数和隐式转换

Mojo 支持隐式的类型转换,当出现以下某种情况时,隐式转换就会发生:

  • 将一种类型的值赋值给另一种类型的变量
  • 将一种类型的值传递给一个需要不同类型值的函数

在这两种情况下,如果目标类型定义了一个构造函数,而该构造函数只需要一个源类型的非关键字参数,则支持隐式转换。例如

struct Source:
    fn __init__(inout self): ...

struct Target:
    fn __init__(inout self, s: Source): ...

fn main():
    var a = Source()
    var b: Target = a

上面的隐式转换相当于直接使用构造函数

var b = Target(a)

用于隐式转换的构造函数可以接受可选参数。下面的构造函数也支持从 SourceTarget 的隐式转换:

struct Target:
  fn __init__(inout self, s: Source, reverse: Bool = False): ...

如果一个类型没有声明自己的构造函数,而是使用 @value 装饰器,并且该类型只有一个字段,则也会发生隐式转换。

这是因为 Mojo 会自动为每个字段创建一个成员构造函数,当只有一个字段时,合成的构造函数就会像转换构造函数一样工作。例如:

@value
struct Target:
  var s: Source

如果 Mojo 无法明确地将转换匹配到构造函数,隐式转换可能会失败。

例如,如果 Target 类型有两个不同类型的重载构造函数,而每个构造函数都支持从 Source 类型的隐式转换,那么编译器就有两条同样有效的路径来转换值:

struct A: 
  fn __init__(inout self, s: Source): ...

struct B: 
  fn __init__(inout self, s: Source): ...

struct Target:
  fn __init__(inout self, a: A): ...
  fn __init__(inout self, b: B): ...

var t = Target(Source())

在这种情况下,程序将会报错,无法确定使用哪个构造函数。只要移除 Target 中任何一个构造函数都能解决问题。

如果您想定义一个单参数构造函数,但又不想隐式转换类型,那么您可以定义一个只有关键字参数的构造函数(任何可变参数之后的都是关键字函数):

struct Target:
  fn __init__(inout self, *, source: Source): ...

var t = Target(source=a)

今后,可能会提供一种更明确的方法来声明构造函数是否应支持隐式转换。

拷贝构造函数

Mojo 遇到赋值运算符 (=) 时,它就会通过调用该类型的复制构造函数:__copyinit__ 方法来复制右边的值。

因此,类型作者有责任实现 __copyinit__ 方法,以便返回值的副本。

例如,上面的 MyPet 类型没有复制构造函数,所以这段代码无法编译:

var mine = MyPet("Loki", 4)
var yours = mine  # This requires a copy, but MyPet has no copy constructor

为了让它正常工作,我们需要添加复制构造函数,就像这样:

struct MyPet:
    var name: String
    var age: Int

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

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

注意

这里的 Self(大写的 S)指代的是当前类型名称(本例中为 MyPet)的别名。此外,请注意 __copyinit__ 中的 existing 参数是不可变的,因为 fn 函数的默认参数约定是借用,我们也不应在这里修改被复制的值的内容。

现在,这段代码可以用来赋值:

var mine = MyPet("Loki", 4)
var yours = mine

与其他语言相比,Mojo 复制行为的不同之处在于 __copyinit__ 旨在对类型中的所有字段进行深拷贝。也就是说,它会复制堆分配的值,而不仅仅是复制指针。

Mojo 编译器并不会强制执行这一点,因此类型作者有责任用值语义的方式实现 __copyinit__

例如,下面是一个新的 HeapArray 类型,它在复制构造函数中执行深拷贝:

struct HeapArray:
    var data: Pointer[Int]
    var size: Int
    var cap: Int

    fn __init__(inout self, size: Int, val: Int):
        self.size = size
        self.cap = size * 2
        self.data = Pointer[Int].alloc(self.cap)
        for i in range(self.size):
            self.data.store(i, val)

    fn __copyinit__(inout self, existing: Self):
        self.size = existing.size
        self.cap = existing.cap
        self.data = Pointer[Int].alloc(self.cap)
        for i in range(self.size):
            self.data.store(i, existing.data.load(i))

    fn __del__(owned self):
        self.data.free()

    fn append(inout self, val: Int):
        if self.size < self.cap:
            self.data.store(self.size, val)
            self.size += 1
        else:
            print("Out of bounds")

    fn dump(self):
        print("[", end="")
        for i in range(self.size):
            if i > 0:
                print(", ", end="")
            print(self.data.load(i), end="")
        print("]")

__copyinit__ 我们并没有直接简单复制 Pointer 值(这样只会复制指针的值),而是重新开辟了一段内存地址,然后拷贝了所有堆分配的值(这就是深拷贝)。

因此,当我们复制 HeapArray 实例时,每个副本在堆上都有自己的值,因此一个值的变化不会影响另一个值。例如:

fn copies():
    var a = HeapArray(2, 1)
    var b = a    # Calls the copy constructor
    a.dump()     # Prints [1, 1]
    b.dump()     # Prints [1, 1]

    b.append(2)  # Changes the copied data
    b.dump()     # Prints [1, 1, 2]
    a.dump()     # Prints [1, 1] (the original did not change)

注意

HeapArray 的生命周期结束时,我们必须使用自定义的 __del__ 析构函数来释放堆分配的数据,其他字段会在生命周期结束时自动销毁它们。

如果你的类型不为堆分配的数据使用任何指针,对于大多数不显式管理内存的结构体,你只需在结构体定义中添加 @value 装饰器即可。

注意

当一个值作为函数的 owned 参数,但该值的生命周期在此处还没有结束时,Mojo 会调用复制构造函数。如果值的生命周期该处结束了(通常用转移运算符 ^ 表示),那么 Mojo 会调用移动构造函数。

移动构造函数

当复制某些数据类型会严重影响性能时,我们可以将值作为引用共享。如果不再需要原始变量,则将原始变量作废,以避免出现双重释放或释放后使用的错误。

这通常被称为移动操作:存放数据的内存块保持不变(内存实际上没有移动),但指向该内存的指针移动到了一个新变量。

__moveinit__ 方法执行消耗性移动:当原变量的生命周期结束时,它将值的所有权从一个变量转移到新变量。

注意:值的所有权转移并不需要移动构造函数。与 Rust 不同的是,所有权转移并不总是移动操作;移动构造函数只是 Mojo 转移值所有权功能的一部分。

当移动发生时,Mojo 会立即使原始变量失效,阻止对它的任何访问,并禁用它的析构函数。使原始变量失效对于避免在堆分配的数据上出现内存错误非常重要。

下面为 HeapArray 结构添加移动构造函数:

fn __moveinit__(inout self, owned existing: Self):
    print("move")
    self.size = existing.size
    self.data = existing.data

__moveinit__ 的关键特征是它将输入的值视为 owned,这意味着该方法获得了该值的唯一所有权。

Mojo 只有在执行移动(即所有权转移期间)时才会调用该方法,因此可以保证 existing 参数是对原始值的可变引用,而不是拷贝(与其他可能会将参数声明为 owned 的方法不同,如果调用该方法时没有使用 ^ 转移操作符,则可能接收到的是值的拷贝)。

也就是说,只有当原始变量在转移所有权后结束了它的生存期,Mojo 才会调用这个移动构造函数。

下面的示例展示了如何调用 HeapArray 的移动构造函数:

fn moves():
    var a = HeapArray(3, 1)
    a.dump()   # Prints [1, 1, 1]
    var b = a^ # Prints "move"; the lifetime of `a` ends here
    b.dump()   # Prints [1, 1, 1]
    #a.dump()  # ERROR: use of uninitialized value 'a'

请注意,__moveinit__ 执行的是对现有值的浅拷贝(只拷贝指针,而不是在堆上分配新的内存),这对那些具有堆分配值且拷贝代价高昂的类型很有用。

如果想让你的类型不会被复制,可以实现 __moveinit__ 方法但不实现 __copyinit__ 方法,使其成为 "只移动"类型。

只允许移动的类型可以传递给其他变量,也可以通过任何参数约定(borrowed, inoutowned)传递给函数。

唯一的问题是,在将只允许移动的类型赋值给新变量或作为 owned 参数传递时,必须使用 ^ 转移操作符来结束它的生存期。

注意

对于没有堆分配字段的数据类型,移动构造函数并不能带来真正的好处。在栈上复制简单数据类型(如整数、浮点数和布尔型)的成本很低。

简单值类型

但大多数结构体都是其他类型的简单聚合,可以很容易地被复制和移动,而且我们也不想为这些简单的值类型编写大量的构造函数。

为了解决这个问题,Mojo 为我们提供了 @value 装饰器,自动注入构造函数,我们前面也反复提到过。

例如,考虑这样一个简单的结构:

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

Mojo 看到 @value 装饰器后,发你没有定义任何构造函数,它会自动为你添加上。例如:

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

Mojo 只会生成那些没有定义的生命周期方法,因此您可以在使用 @value 的同时,定义自己的版本来覆盖默认行为。

例如,使用 @value 创建一个成员构造函数,然后添加重载以获取不同的参数值

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

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

请注意,重载的构造函数并不会阻止 @value 装饰器创建构造函数。要覆盖默认行为,您需要添加一个与默认成员构造函数具有相同签名的构造函数。

在这段代码中有一个小优化,可以使用只移动的类型。像 Int 这样的简单类型也会以 owned 的形式传递,但由于所有权对整数没有任何意义,为了简单起见,我们可以省略该声明和转移操作符 (^)。

在这种情况下,转移操作符也只是一种形式,因为即使不使用 self.name = name^Mojo 编译器也会注意到 name 在这里是最后使用的,并将此赋值转换为移动,而不是复制+删除。

注意:如果您的类型包含任何只移动的字段,Mojo 就不会生成复制构造函数,因为它无法复制这些字段。此外,如果您的任何成员既不可复制也不可移动,@value 装饰器根本就不起作用。

trivial 类型

到目前为止,我们已经讨论过内存中的值,这意味着它们有一个身份(地址),可以在函数之间传递(通过引用)。

这对大多数类型来说都很好,对于需要花费昂贵代价进行复制操作的来说,这也是一种安全的默认方式。

但是,对于像单个整数或浮点数这样的微小对象来说,这种方式效率很低。我们称这些类型为 “trivial”,因为它们只是 “比特包”,只需复制、移动和销毁,无需调用任何自定义生命周期方法。

trivial 类型指的就是我们身边最常见的简单类型,通常这些值非常小,应该在 CPU 寄存器中传递,而不是通过内存间接传递。

Mojo 提供了一个结构体装饰器来声明这些类型的值:@register_passable("trivial")。这个装饰器告诉 Mojo,该类型应该是可复制和可移动的,但它没有用户定义的逻辑(没有生命周期方法)来实现这一点。

它还告诉 Mojo 尽可能通过 CPU 寄存器来传递值,这对性能有明显的好处。比如标准库中的 Int 类型就有这个装饰器:

@register_passable("trivial")
struct Int:
    var value: __mlir_type.index

    fn __init__(value: __mlir_type.index) -> Int:
        return Self {value: value}
    ...

Mojo 标准库类型中会广泛使用该装饰器,但在一般应用级代码中可以忽略它。

注意

这个装饰器应该在后面的版本中被重新设置。因为缺少自定义的复制/移动/销毁逻辑和寄存器中可传递性是两个没有交集的问题,应该分开。

值的销毁

一旦某个值/对象不再使用,Mojo 会立即将其销毁,并不会等到代码块结束,甚至不会等到表达式结束才销毁未使用的值。

它的尽快销毁策略在每个子表达式之后运行。即使在 a+b+c+d 这样的表达式中,一旦不再需要中间的值,Mojo 就会立即将其销毁。

Mojo 使用静态编译器分析来查找值的最后使用时间,然后就会立即结束该值的生命,并调用 __del__ 析构函数对该类型进行必要的清理。

例如

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

    fn __del__(owned self):
        print("Destruct", self.name)

fn main():
    var a = MyPet("Loki", 4)
    var b = MyPet("Sylvie", 2)
    print(a.name)
    # a.__del__() runs here for "Loki"

    a = MyPet("Charlie", 8)
    # a.__del__() runs immediately because "Charlie" is never used

    print(b.name)
    # b.__del__() runs here

输出

Loki
Destruct Loki
Destruct Charlie
Sylvie
Destruct Sylvie

大多数结构体并不需要自定义的析构函数,如果没有定义析构函数,Mojo 会自动添加一个无操作的析构函数。

默认销毁行为

你可能想知道 Mojo 如何在没有自定义析构函数的情况下销毁一个类型,或者为什么无操作析构函数是有用的。

如果一个类型只是一个简单字段的集合,比如 MyPet,那么 Mojo 只需要销毁字段即可,结构体中没有动态分配内存或使用任何长期资源(如文件句柄)。销毁时,无需采取任何特殊操作。

因为 MyPet 只包含一个 IntString 类型,其中:

Int 是所谓的微小(trivial)类型,Mojo 清楚地知道它有多大,这些比特可以重复使用来存储其他东西。

Mojo 中的 String 类型是可变的。字符串对象有一个内部缓冲区(即 List 字段),用于保存组成字符串的字符。List 将其内容存储在堆上动态分配的内存中,它也没有什么特殊的析构逻辑,就是在析构的时候它会调用 List 字段的析构函数来释放内存。

由于 StringInt 不需要任何自定义的析构逻辑,Mojo 会自动帮我们管理。

尽快销毁的好处

与其他语言类似,Mojo 遵循的原则是,对象/值在构造函数 (__init__)中获取资源,在析构函数 (__del__)中释放资源。

不过,Mojo 的尽快销毁与基于作用域的销毁(如 C++RAII 模式,即等到代码作用域结束时才销毁值)相比有一些优势:

  • 在最后一次使用后立即销毁数值与 “移动” 优化功能完美结合,后者将 “复制+删除” 操作转换为 “移动” 操作。
  • C++ 中,由于析构函数调用发生在尾部调用之后,这可能会造成严重的性能和内存问题

此外 Python 的垃圾回收器比基于作用域的销毁策略更频繁地清理资源。

Mojo 的销毁策略与 RustSwift 更为相似,它们都具有强大的值所有权跟踪功能,并提供内存安全。

不同之处在于,RustSwift 需要使用 “drop flag”(它们维护隐藏的影子变量,以跟踪值的状态,从而提供安全性)。但 Mojo 方法完全消除了这一开销,使生成的代码更快,并避免了歧义。

析构函数

通常,我们不需要自定义析构函数,只有在涉及动态分配内存(例如,通过 PointerDTypePointer),或任何长期存在的资源(如文件句柄)时,需要手动释放。

例如,前面定义的 HeapArray 需要释放指针分配的内存:

struct HeapArray:
    var data: Pointer[Int]
    var size: Int

    fn __init__(inout self, size: Int, val: Int):
        self.size = size
        self.data = Pointer[Int].alloc(self.size)
        for i in range(self.size):
            self.data.store(i, val)

    fn __del__(owned self):
        self.data.free()

只要释放指针执行的内存就行,不用释放 Int 类型的值。

如果指针指向的值也有析构函数,仅仅在指针上调用 free 是不够的。还应该确保 Mojo 通过它们的析构函数完全销毁所有分配的类型。

我们更新前面的 __del__ 方法,以确保在数组元素上调用析构函数。

struct HeapArray:
    var data: Pointer[Int]
    var size: Int

    fn __del__(owned self):
        for i in range(self.size):
            _ = __get_address_as_owned_value((self.data + i).address)
        self.data.free()

它通过将每个存储的值依次赋值给_ 实现这一点(给 _ 变量赋值会告诉编译器这是该值的最后一次使用,可以立即销毁它)

注意

你不能只是显式地调用析构函数,因为 __del__selfowned 声明的,默认情况下会复制参数,所以 foo._del__ 实际上会创建和销毁 foo 的副本。然而,当 Mojo 销毁一个值时,它会将原始值作为 self 传递,而不是一个副本。不要手动调用析构函数

如果前面的例子中,使用 UnsafePointer 类型的实例,就可以使用 destroy_pointee 函数来代替丢弃模式。例如:

fn __del__(owned self):
    for i in range(self.size):
        destroy_pointee(self.data + i)
    self.data.free()

__del__ 方法是一个额外的清理事件,并不会覆盖任何默认的销毁行为。例如,即使你实现__del__ 不做任何事情,Mojo 仍然会销毁 MyPet 中的所有字段:

字段的生存期

除了跟踪程序中所有对象的生存期外,Mojo 还会跟踪结构体中的每个字段。

也就是说,Mojo 跟踪整个对象是完全还是部分初始化或销毁,并使用其尽快销毁策略独立地销毁每个字段。

例如,下面这段代码改变了一个字段的值:

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

fn main():
    var pet = MyPet("Po", 8)
    print(pet.name)
    # pet.name 的值不再使用,运行 pet.name.__del__() 销毁 name

    pet.name = String("Lola") # 重新创建 pet.name
    print(pet.name)
    # 运行 pet.__del__() 销毁对象

name 字段在第一次 print 之后就被销毁,因为 Mojo 知道它将在下面将被覆盖。你也可以在使用转移操作符时看到类似的行为:

fn consume(owned arg: String):
    pass

fn use(arg: MyPet):
    print(arg.name)

fn consume_and_use():
    var pet = MyPet("Selma", 5)
    consume(pet.name^)
    # 运行 pet.name.__moveinit__() ,然后 pet.name 被销毁了
    # 现在 pet 是部分初始化

    # use(pet)  # 因为 pet 是部分初始化,所以无法进行传递

    pet.name = String("Jasper")  # 完全初始化
    use(pet)                     # 可以传递
    # 运行 pet.__del__() 并销毁对象

注意,代码中 name 字段将所有权转移给了 consume。所以在此之后的一段时间内,name 字段未初始化的。

然后 name 在传递给 use 函数之前被重新初始化。如果在 name 被重新初始化之前尝试调用 useMojo 会以报未初始化字段的错误

Mojo 在这里的策略很强大,字段可以临时转移,但是整个对象必须用必须保持完整性,同时被销毁

这意味着不可能通过只初始化对象的字段来创建对象,同样也不可能通过只销毁对象的字段来销毁对象。

移动和销毁时的字段生存期

对于调用移动构造函数和析构函数,有一个比较有意思的问题。回顾一下移动构造函数和析构函数的方法签名是这样的:

struct TwoStrings:
    fn __moveinit__(inout self, owned existing: Self):
        # 消耗 existing 来初始化 self 
    fn __del__(owned self):
        # 销毁 self 的所有数据

注意

大写的 Self 是当前类型名称的别名(用作现有参数的类型说明符),而小写的 self 是对当前实例的隐式传递引用的参数名称。

这两个生命周期方法都将自己类型的实例作为 owned 传递,也就是说 __moveinit__ 会隐式地销毁现有对象的子元素,以便将所有权转移到一个新实例;而 __del__ 为其自身实现删除逻辑。

所以,它们都需要拥有和转移实例,并且它们绝对不希望值的原始拥有者的析构函数也运行。这可能导致双重释放错误,并且在 __del__ 方法的情况下,它将成为一个无限循环。

为了解决这个问题,Mojo 对这两个方法进行了特殊处理,假设所有值在方法 return 之前都被销毁了。

这意味着可以像往常一样使用整个对象,直到转移字段值或方法返回为止。例如

fn consume(owned str: String):
    print('Consumed', str)

struct TwoStrings:
    var str1: String
    var str2: String

    fn __init__(inout self, one: String):
        self.str1 = one
        self.str2 = String("bar")

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

    fn __del__(owned self):
        self.dump() # Self 是完整的
        # str2 不再使用,运行 self.str2.__del__()

        consume(self.str1^)
        # self.str1 的所有权转移了,也被释放了;
        # `self.__del__()` 不会运行,避免无限循环

    fn dump(inout self):
        print('str1:', self.str1)
        print('str2:', self.str2)

fn main():
    var two_strings = TwoStrings("foo")

显式的生存期

在几乎所有情况下 Mojo 都会在最后一次使用后销毁值。但是在极少数情况下,Mojo 无法正确预测这一点,并且会销毁仍然被其他方式引用的值。

由于 Mojo 编译器无法对指针进行预测,所以当构建的结构体中,一个字段(ptr)携带的是指向另一个字段(obj)的指针。当字段 obj 在不再使用时,它可能会销毁该字段(ptr),即使 ptr 仍然持有指向该字段。

因此,在析构函数或移动构造函数中执行某些特殊逻辑之前,可能需要保持 obj 的活动状态。可以将值赋值给 _ 来强制 Mojo 将值保留到某一点。例如:

fn __del__(owned self):
    self.dump() # Self 还是完整的

    consume(self.ptr^)
    _ = self.obj
    # Mojo 将 `obj` 的值保留到此处

在这种情况下,如果 consume 以某种方式引用了 obj 中的某个值,这将确保 Mojo 直到调用 consume 之后才销毁 obj,因为对丢弃变量 _ 的赋值实际上是最后一次使用。

对于其他情况,还可以使用 Python 风格的 with 语句限定值的生存期。也就是说,对于在 with 语句入口处定义的任何值,Mojo 将保持该值有效,直到 with 语句结束。例如:

with open("my_file.txt", "r") as file:
    print(file.read())
    foo()
    # `file` 保留到此处

# `file` 被销毁
bar()
  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

名本无名

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值