析构函数的行为
在Mojo中,任何一个结构体都可以有一个析构函数(__del__()
方法),当值的生命周期结束时(通常是最后使用该值的时候),它会自动运行。例如,一个简单的字符串可能如下所示(伪码):
@value
struct MyString:
var data: Pointer[UInt8]
def __init__(inout self, input: StringRef): ...
def __add__(self, rhs: String) -> MyString: ...
def __del__(owned self):
free(self.data.address)
Mojo像这样销毁MyString
这种值(调用__del__()
析构函数)使用的是“尽早销毁”(ASAP)策略,该策略在每次调用之后运行。Mojo不会等到代码块的末尾才销毁未使用的值。即使在表达式a+b+c+d
中,Mojo也会急切地销毁中间表达式,只要它们不再需要它们,而不是等到语句结束才执行。
Mojo编译器会在值无效时自动调用析构函数,并且对析构函数何时运行提供了强有力的保证。Mojo使用静态编译器分析来推导你的代码,并决定何时插入对析构函数的调用。例如:
fn use_strings():
var a = String("hello a")
let b = String("hello b")
print(a)
print(b)
a = String("temporary a")
a = String("final a")
print(a)
use_strings()
上面的代码中,你会看到a
和b
的值在早期创建,并且每个值的初始化与调用析构函数相匹配。请注意,a
被多次销毁,每次都为它接收到新值时。
对于C++程序员来说,这可能令人惊讶,因为它与C++中的RAII模式不同,后者在作用域结束时销毁值。Mojo也遵循值在构造函数中获取资源,在析构函数中释放资源的原则,但是Mojo中的急切销毁相对于C++中基于作用域的销毁有许多强大优势:
- Mojo的方法消除了类型需要实现重赋值运算符(如C++中的
operator=(const T&)
和operator=(T&&)
)的需要,使得定义类型更容易,消除了一个概念。 - Mojo不允许可变引用与其他可变引用或不可变借用重叠。它通过尽早销毁引用来提供可预测的编程模型,避免了编译器认为一个值可能仍然存在并干扰另一个值的混乱情况,但这对用户来说并不清晰。
- 在“移动”优化方面,尽早销毁的方式很好地与Mojo中的“移动”操作相结合,它将“复制+删除”对转换为“移动”操作,这是C++中NRVO(命名返回值优化)等移动优化的一般化形式。
- 在C++中,作用域结束时销毁值对于一些常见模式,如尾递归,存在一些问题,因为析构函数的调用发生在尾调用之后。这对于某些函数式编程模式可能是一个重要的性能和内存问题。
值得注意的是,Mojo的急切销毁在Python样式的def
函数中也可以很好地工作,以提供细粒度的销毁保证(无需垃圾回收器)——请记住,Python并没有真正提供超出函数范围的作用域,因此在Mojo中采用C++样式的作用域销毁会显得不太有用。
注意: Mojo还支持Python样式的with
语句,它提供了更明确的作用域访问资源的方式。
Mojo的方法更类似于Rust和Swift的工作方式,因为它们都具有强大的值所有权追踪和提供内存安全性。一个区别是它们的实现需要使用动态“drop标志”,它们使用隐藏的影子变量来跟踪你的值的状态以提供安全性。这些标志通常会被优化掉,但Mojo的方法完全消除了这种开销,使生成的代码更快,并避免了歧义。
基于字段的生命周期管理
除了Mojo的生命周期分析对控制流非常敏感外,它还是完全基于字段的(对结构的每个字段都进行独立跟踪)。也就是说,Mojo分别跟踪“整个对象”是完全初始化还是仅部分初始化/销毁。
例如,考虑以下代码:
@value
struct TwoStrings:
var str1: String
var str2: String
fn use_two_strings():
var ts = TwoStrings("foo", "bar")
print(ts.str1)
ts.str1 = String("hello") # 覆盖ts.str1
print(ts.str1)
use_two_strings()
foo
hello
注意,ts.str1
字段几乎立即被销毁,因为Mojo知道它将在下面被覆盖。当使用传输操作符时,您也可以看到这一点,例如:
fn consume(owned arg: String):
pass
fn use(arg: TwoStrings):
print(arg.str1)
fn consume_and_use_two_strings():
var ts = TwoStrings("foo", "bar")
consume(ts.str1^)
ts.str1 = String("hello") # 现在所有的代码执行完毕
use(ts) # 这是可以的
consume_and_use_two_strings()
hello
请注意,代码转让了str1
字段的所有权:在other_stuff()
执行期间,str1
字段完全未初始化,因为所有权已转移到consume()
。然后,在use()
函数使用之前,str1
重新初始化(如果没有重新初始化,Mojo将拒绝具有未初始化字段的代码)。
Mojo有关此规则的规定是强大且意图明确的:字段可以被暂时转移,但是“整个对象”必须使用聚合类型的初始化器构建,并使用聚合析构函数销毁。这意味着不可能仅通过初始化其字段来创建对象,也不可能仅通过销毁其字段来拆除对象。例如,此代码无法编译:
fn consume_and_use_two_strings():
let ts = TwoStrings("foo", "bar") # ts被初始化
let ts2 : TwoStrings # 声明ts2类型而未初始化
ts2.str1 = String("foo")
ts2.str2 = String("bar") # 成员变量都被初始化
虽然我们可以允许发生这样的模式,但我们拒绝这样做,因为一个值不仅仅是其各部分的总和。考虑一个包含POSIX文件描述符的FileDescriptor
(作为整数值):销毁整数(无操作)和销毁FileDescriptor
(可能会调用close()
系统调用)之间有很大的区别。因此,我们要求所有的全值初始化都必须通过初始化器进行,并通过它们的全值析构函数进行销毁。
值得一提的是,Mojo内部实际上有一个Rust mem::forget
函数的等效实现,它显式地禁用了析构函数,并具有相应的内部功能来“标记”一个对象,但目前这些功能对用户不可见。
__init__
中的字段生命周期
__init__
方法的行为几乎与其他方法相同——只有一小段魔法:它知道对象的字段是未初始化的,但它认为整个对象已初始化。这意味着只要所有字段都初始化,就可以立即将self
作为整个对象使用:
fn use(arg: TwoStrings2):
pass
struct TwoStrings2:
var str1: String
var str2: String
fn __init__(inout self, cond: Bool, other: String):
self.str1 = String()
if cond:
self.str2 = other
use(self) # 立即使用是安全的!
self.str2 = self.str1
use(self) # 立即使用是安全的!
同样,在Mojo中,初始化器完全覆盖self
也是安全的,例如通过委托给其他初始化器:
struct TwoStrings3:
var str1: String
var str2: String
fn __init__(inout self):
self.str1 = String()
self.str2 = String()
fn __init__(inout self, one: String):
self = TwoStrings3() # 委派给基本初始化器
self.str1 = one
__moveinit__
和__del__
中owned
参数的字段生命周期
在__moveinit__()
移动初始化函数和__del__()
析构函数的owned
参数中存在一个特殊的魔法。回顾一下,这些方法的函数签名如下所示:
struct TwoStrings:
...
fn __moveinit__(inout self, owned existing: Self):
fn __del__(owned self):
这两个方法面临一个有趣但晦涩的问题:这两个方法都负责拆解owned
的existing
/self
值。也就是说,__moveinit__()
方法会销毁existing
的子元素,以便将所有权转移给新的实例,而__del__()
方法则实现了对self
的删除逻辑。因此,这两个方法都希望拥有并转换owned
值的元素,并且它们绝对不希望owned
值的析构函数也运行(在__del__()
方法的情况下,这将导致无限循环)。
为了解决这个问题,Mojo通过假设在方法的任何返回处,整个owned
值都会被销毁来特殊处理这两个方法。这意味着在字段值被传递之前,整个对象可能会被使用。例如,以下代码可以按预期工作:
fn consume(owned str: String):
print('Consumed', str)
struct TwoStrings4:
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仍然是整个对象
consume(self.str1^)
fn dump(inout self):
print('str1:', self.str1)
print('str2:', self.str2)
fn use_two_strings():
let two_strings = TwoStrings4("foo")
use_two_strings()
输出结果是:
str1: foo
str2: bar
Consumed foo
通常情况下,您不需要考虑这一点,但是如果您在逻辑中具有指向成员的内部指针,您可能需要为析构函数或移动初始化器本身的某些逻辑保持它们的存活状态。您可以通过将其赋值给 _
“丢弃” 模式来实现:
fn __del__(owned self):
self.dump() # 这里的self仍然是整个对象
consume(self.str1^)
_ = self.str2
在这种情况下,如果consume()
隐式地引用了str2
中的某个值,那么这将确保str2
在最后一次访问(通过_
丢弃模式访问)之前不会被销毁。
定义__del__
析构函数
你应该定义__del__()
方法来执行类型所需的任何清理工作。通常,这包括释放那些不是简单或可销毁的字段所占用的内存 - Mojo会在不再使用时自动销毁任何简单和可销毁类型。
例如,考虑以下结构体:
struct MyPet:
var name: String
var age: Int
fn __init__(inout self, owned name: String, age: Int):
self.name = name^
self.age = age
无需定义__del__()
方法,因为String
是可销毁的(它有自己的__del__()
方法),Mojo会在不再使用时立即销毁它(也就是MyPet
实例不再使用的时候),而Int
是简单类型,Mojo也会尽快回收这块内存(虽然有些不同,不需要__del__()
方法)。
而下面的结构体必须定义__del__()
方法来释放其Pointer
分配的内存:
struct Array[Type: AnyType]:
var data: Pointer[Type]
var size: Int
var cap: Int
fn __init__(inout self):
self.cap = 16
self.size = 0
self.data = Pointer[Type].alloc(self.cap)
fn __init__(inout self, size: Int, value: Type):
self.cap = size * 2
self.size = size
self.data = Pointer[Type].alloc(self.cap)
for i in range(self.size):
self.data.store(i, value)
fn __del__(owned self):
self.data.free()