Mojo 中的 Value Semantics 以及所有权特性探索

出于深入了解 Mojo 语言的目的,且由于之前写过一些 Rust,所以我想仔细看看 Mojo 是怎么处理参数传递和所有权的问题的。由于 Mojo 目前的文档对于这方面似乎有一些情况不是特别清楚,所以在调研过程中稍微试验了下 Mojo 编译器对这些问题的处理。

本文中的实验使用的 Mojo 编译器版本是 mojo 24.3.0 (9882e19d)

最简单的一个原则是 deffn 都适用 Value Semantics,但是 fn 默认的参数传递方式实际上是 borrowed 方式(或许类比 rust 中的 &)而 def 则默认使用 owned 获取所有权传递(是否发生拷贝事实上取决于编译器优化),文档中对此的解释是

Thus, the default behavior for def and fn arguments is fully value semantic: arguments are either copies or immutable references, and any living variable from the callee is not affected by the function.

也就是原始的变量值不会被传参之后函数的行为所影响。

根据 Mojo 的文档来看,owned 这个关键字要求获得所有权,除非传参时使用了 ^ 进行移动,否则编译器似乎会尝试调用 __copyinit__

但是在 __copyinit__ 以及 __moveinit__ 中似乎有一个未定义行为的问题(或者只是编译器暂时没有稳定的问题)。在 __copyinit____moveinit__ 中所包括的副作用行为似乎有可能会被优化掉。以如下这三种类型和函数为例

struct Copyable:
    var a: Int

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

    fn __copyinit__(inout self, other: Self):
        print("copyinit of Copyable")
        self.a = other.a

struct Movable:
    var a: Int

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

    fn __moveinit__(inout self, owned other: Self):
        print("moveinit of Movable")
        self.a = other.a

struct CopyMovable:
    var a: Int

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

    fn __copyinit__(inout self, other: Self):
        print("copyinit of CopyMovable")
        self.a = other.a

    fn __moveinit__(inout self, owned other: Self):
        print("moveinit of CopyMovable")
        self.a = other.a

def try_to_copy(c: Copyable):
    print("c.a:", c.a)

def try_to_move(m: Movable):
    print("m.a:", m.a)

def try_to_copy(cm: CopyMovable):
    print("cm.a:", cm.a)

以下的主函数并不会展示任何应该会 print 出来的信息

def main():
    c = Copyable(1)
    try_to_copy(c)

    m = Movable(2)
    try_to_move(m^)

    cm = CopyMovable(3)
    try_to_copy(cm)

猜测应该是编译器优化的结果,所以我还试了下使用 --no-optimization 这个选项,但是最终结果似乎仍然一样。

--no-optimization, -O0 的描述如下:Disables compiler optimizations. This might reduce the amount of time it takes to compile the Mojo source file. It might also reduce the runtime performance of the compiled executable.

于是我想到扩大一下这里 cm 变量的生命周期试试

def main():
    c = Copyable(1)
    try_to_copy(c)

    m = Movable(2)
    try_to_move(m^)

    cm = CopyMovable(3)
    try_to_copy(cm)

    cm0 = cm
    cm1 = cm^

输出的结果就变成了

c.a: 1
m.a: 2
copyinit of CopyMovable
cm.a: 3
copyinit of CopyMovable
moveinit of CopyMovable

看来确实是有所影响的。但是不是很确定这个行为到底是不是 UB,也没有在手册中找到相关的内容,但是手册中有这样一段描述,表明 __moveinit__对于所有权的转移并不是必须的。

A move constructor is not required to transfer ownership of a value. Unlike in Rust, transferring ownership is not always a move operation; the move constructors are only part of the implementation for how Mojo transfers ownership of a value. You can learn more in the section about ownership transfer.

目前 mojo 的编译器还没开源(截止 2024.5.10),所以也不确定编译器中是如何实现这个语义的。

在 GitHub 上还有这样一个 issue,所以我尝试去复现了一下

def main():
    cm = CopyMovable(3)
    try_to_copy(cm)

    cm0 = cm
    cm1 = cm

也就是将最后一个显式的移动去掉,输出变成了

copyinit of CopyMovable
cm.a: 3
copyinit of CopyMovable

从结果来看,最终的 __copyinit__ 只调用了两次,最后这一次赋值没有发生拷贝,也没有发生移动(至少不是调用 __moveinit__ 的移动)。之后我再次尝试扩大 cm1 的生命周期

def main():
    cm = CopyMovable(3)
    try_to_copy(cm)

    cm0 = cm
    cm1 = cm

    print(cm1.a)

输出变为了

copyinit of CopyMovable
cm.a: 3
copyinit of CopyMovable
moveinit of CopyMovable
3

这下变移动了(应该是因为 cm 在之后没有被调用所以自动变为了移动,和 issue 中的情况一致)。

看来 mojo 对 __copyinit____moveinit__ 的处理方式还挺复杂,但是似乎都在尽可能减少拷贝的次数,而且这种操作应该是在前端翻译到 MLIR 的时候就完成了(因为 --no-optimization 也没有作用)。

从目前 mojo 的情况来看,这个所有权的模型似乎还不是特别完善(之后好像还会添加生命周期的内容),inoutowned 以及 borrowed 目前似乎只在函数传参里面有,和 rust 的 &mut, & and move/clone/copy 差别还挺大。文档中对这个的描述也不是特别的清晰,而只是提到编译器会对移动进行优化。但是鉴于目前编译器还没有开源,所以此处的这些实验可能也不能完全反映 mojo 编译器处理的方式。

另外,对于生命周期、所有权在 mojo 的 repo 里面还有几个有关的提案

如果从 Rust 和 C++ 目前的实践来看,所有权、生命周期还有值的语义对于语言来说都是非常重要的部分。之后 Mojo 应该也会在这个特性上做出更加完善的处理,希望能够给出一个更加易用的解决方案。

5 Likes

期待 mojo compiler 早日开源!

1 Like