Mojo 学习 —— 值的所有权

Mojo 学习 —— 值的所有权

所有现代编程语言都将数据存储在两个地方:调用栈(stack)和堆(heap)(有时也存储在 CPU 寄存器中,但不在我们本次讨论的范围内)。

但是,每种语言都有自己特定的读写数据的方式,有的甚至大相径庭。所以,在后面的内容中,我们主要学习一下 Mojo 是如何管理程序中的内存,以及这对编写 Mojo 代码的影响

介绍

栈和堆

一般来说,所有编程语言都以相同的方式使用调用栈:

  1. 当调用一个函数时,编译器会在栈上分配一个内存块,其大小等于需要存储的执行逻辑和固定大小的局部数据值;

  2. 当调用另一个函数时,其数据以同样的方式被添加到栈的顶部;

  3. 当一个函数执行完毕后,栈中的所有数据都会被销毁,这快内存就可以供其他代码使用了。

请注意,中只存储 “固定大小的局部数据值”,而那些运行时会发生变化的动态值则存储在

堆是一个更大的内存区域,允许在运行时进行动态的内存访问。

但是从技术上讲,这种值的局部变量还是存储在调用栈中,只是它的值是指向堆上真实值的固定大小的指针。

此外,需要在函数生命周期之外使用的值(例如在函数之间传递且不应被复制的数组)会存储在堆中,因为堆内存可从调用栈中的任何位置访问(栈上存储的是指针),即使创建该值的函数从栈中移除后也是如此。

这种堆分配的值被多个函数使用,最容易导致内存错误,这也是不同编程语言内存管理策略差异最大的地方。

内存管理策略

由于内存有限,程序必须尽快从堆中删除未使用的数据(释放内存),但是要知道何时应该释放内存是非常复杂的。

一些编程语言试图通过使用 “垃圾回收器” 进程来隐藏内存管理的复杂性,该进程会跟踪所有内存的使用情况,并定期卸载未使用的堆内存(也称为自动内存管理)。

这种方法最大的好处是减轻了开发人员手动管理内存的负担,通常可以避免更多错误,提高开发人员的工作效率。

不过,这种方法也会产生性能代价,因为垃圾回收器会中断程序的执行,而且回收内存的速度可能不会很快。

其他语言则需要手动释放内存,如果操作得当,可以让程序快速执行。但是也很容易出现问题,比如可能会在程序使用数据之前不小心将其内存释放,或者执行双重释放,或忘了释放(导致内存泄露)。

诸如此类的错误可能会给程序带来灾难性的后果,而且这些错误通常很难被追踪到,所以如何避免出现这些错误很重要

Mojo 使用的是第三种方法,称为 “所有权”,如果熟悉 Rust,对此应该很熟悉。

它依赖于程序员在传递值时必须遵守的一系列规则,这些规则能够确保每块内存在同一时间只有一个 “所有者”,并相应地对该内存进行释放

遵循这样的规则之后,Mojo 就可以自动为你分配和释放堆内存,这种分配和释放内存的方式是确定的,不会出现释放后使用、双重释放和内存泄漏等错误。并且它的性能开销也非常低。

在解释 Mojo 值所有权规则和语法之前,您首先需要了解什么是值语义

值语义

Mojo 不会强制执行值语义或引用语义。它同时支持这两种语义,并允许对每种类型定义其创建、复制和移动的方式

所以,在创建自己的类型的,你可以实现值语义、引用语义或两者兼而有之。

Mojo 的参数行为默认是值语义,同时也为引用语义提供了严格控制,以避免内存错误

一般来说,每个变量对值都有唯一的访问权限,该变量作用域之外的任何代码都不能修改其值。

什么是值语义

最简单的情况是,共享值语义类型意味着创建值的副本。也就是我们所说的 “按值传递”。例如

x = 1
y = x
y += 1

print(x)
print(y)

这里的 y = x,是先复制了一份 x 让后将其分配给 y。也就是说每个变量都独占了一个值。所以输出结果是

1
2

如果使用引用语义的话,yx 会指向相同的值,修改任何一个都会影响另一个的值。

下面是一个使用函数的例子:

def add_one(y: Int):
    y += 1
    print(y)

x = 1
add_one(x)
print(x)

同样的,y 是一个副本,函数不能修改原始 x 值。

因为,在 Mojo 中,所有函数参数的默认行为都是使用值语义。如果函数要修改输入参数的值,则必须明确声明,以避免意外突变。

我们知道,def 函数的参数类型都是通过值传递的。例如,尽管 MojoTensor 类型会在堆上分配值,但当你将一个实例传递给 def 函数时,它会为所有值创建一个唯一的副本。

因此,在函数中的修改不会影响原始值

from tensor import Tensor

def update_tensor(t: Tensor[DType.uint8]):
    t[1] = 3
    print(t)
def main():
    t = Tensor[DType.uint8](2)
    t[0] = 1
    t[1] = 2
    update_tensor(t)
    print(t)

输出

Tensor([[1, 3]], dtype=uint8, shape=2)
Tensor([[1, 2]], dtype=uint8, shape=2)

def 与 fn 的值语义

默认情况下,def 函数会获得参数的所有权(通常是副本)。而 fn 函数默认以不可变引用的形式接收参数。这是一种内存优化,以避免产生不必要的副本。

例如,我们用 fn 创建一个函数

fn add_two(y: Int):
    var z = y
    z += 2
    print(z)

x = 1
add_two(x)
print(x)

在这种情况下,y 参数默认是不可变的,因此如果函数要修改本地作用域中的值,就需要创建一个本地副本

这一切都符合值语义,因为每个变量都对其值拥有唯一的所有权。

fn 函数在处理内存密集型参数时,是一种更节省内存的方法,因为除非我们自己明确地复制参数,否则 Mojo 不会复制任何参数。

因此,deffn 参数的默认行为是完全值语义的:参数要么是副本,要么是不可变引用,任何来自被调用者的变量都不会受到函数的影响

但是,我们也必须允许引用语义(可变引用),因为这是我们构建高性能、高内存效率程序的方法(复制是非常昂贵的操作)。我们面临的挑战是如何在不影响值语义的可预测性和安全性的前提下引入引用语义。

Mojo 中,不强制每个变量都对某个值拥有 “唯一访问权”,而是确保每个值都有一个 “唯一所有者”,并在其所有者的生命周期结束时销毁对应的值。

我们要学习如何修改默认参数约定,并安全地使用引用语义,从而确保每个值在同一时间只有一个所有者。

Python 风格的引用语义

注意

如果您始终使用严格的类型声明,则可以跳过本节,它只适用于使用未进行类型声明的 def 函数(或声明为对象的值)的 Mojo 代码。

正如我们之前所说,Mojo 并不强制执行值语义或引用语义。可以自由决定如何创建、复制和移动其类型的实例。

为了提供与 Python 的兼容性,Mojo 的对象类型被设计为支持 Python 的函数参数传递风格,这一点与 Mojo 中的其他类型不同。

Python 的参数传递约定是 “通过对象引用传递”。这意味着当您将变量传递给 Python 函数时,实际上是将对象的引用作为值传递给函数(因此它不是严格的引用语义)。

将对象引用 "作为值 "传递,意味着参数名只是一个容器,就像原始对象的别名。如果在函数内部重新分配参数,则不会影响调用者的原始值。但是,如果您修改了对象本身(例如在列表中调用 append),函数外部的原始对象就会发生改变。

例如,下面是一个接收列表并对其进行修改的 Python 函数:

# python
def modify_list(l):
    l.append(3)
    print("func:", l)

ar = [1, 2]
modify_list(ar)
print("orig:", ar)
# func: [1, 2, 3]
# orig: [1, 2, 3]

函数可以更改原始值,但也可以为参数名赋值一个新对象。

通过对象引用传递

目前 Mojo 还没有实现和 Python 中的 def 一样的对象引用传递,但他们还是努力尝试,希望他们能够尽快实现

这意味着您只需像 Python 一样编写 Mojo 代码,就能拥有动态类型和 "通过对象引用传递 "行为

所有权和借用

在使用某些编程语言时,手动分配和释放内存是很麻烦的问题。所以 Mojo 通过确保每次只有一个变量拥有每个值,同时允许您与其他函数共享引用,从而避免了这些错误。当所有者的生命周期结束时,Mojo 会自动销毁该值。

下面将解释管理此所有权模型的规则,以及如何指定不同的参数约定,来定义如何将值共享到函数中。

参数约定

在所有编程语言中,代码质量和性能在很大程度上取决于函数如何处理参数值。也就是说,函数接收到的值是唯一值还是引用,是可变还是不可变,都会带来一系列后果,从而决定了语言的可读性、性能和安全性。

所以在 Mojo 中,我们希望默认就能够提供完整的值语义,从而提供一致且可预测的行为。

但作为一种系统编程语言,我们还需要提供对内存优化的完全控制,这通常需要引用语义。通过跟踪每个值的生命周期并在正确的时间销毁每个值(而且只销毁一次),确保所有代码都是内存安全的。

Mojo 中,所有这些都可以通过使用参数约定来实现,从而确保每个值在同一时间只有一个所有者。

参数约定指定参数是可变的还是不可变的,以及函数是否拥有参数值。每个约定都由参数声明前的一个关键字定义:

  • borrowed:函数接收不可变引用
  • inout:函数接收一个可变引用
  • owned:函数拥有所有权

例如,这个函数有一个可变引用参数和一个不可变参数:

fn add(inout x: Int, borrowed y: Int):
    x += y

fn main():
    var a = 1
    var b = 2
    add(a, b)
    print(a)  # Prints 3

每个参数都有一个默认的约定,这取决于函数是用 fn 还是 def 声明的

  • def 函数默认是 owned
  • fn 函数默认是 browwed

两种函数声明方式都可以自定义参数的行为

所有权的总结

Mojo 所有权模式的基本规则如下:

  • 每个值一次只有一个所有者
  • 当所有者的生命周期结束时,Mojo 会销毁其值

借用检查器是 Mojo 编译器中的一个程序,它确保每个值在任何时候都只有一个所有者,同时它还执行其他一些内存安全规则:

  • 不能为同一个值创建多个可变引用(inout)(但可以创建多个不可变引用 browwed
  • 如果存在相同值的不可变引用(browwed),则不能创建可变引用(inout)(目前这个功能还没实现)

由于 Mojo 不允许一个可变引用与另一个可变或不可变引用重叠,因此它提供了一个可预测的编程模型来确定哪些引用有效,哪些引用无效(无效引用是指生命周期已结束的引用,可能是因为值的所有权已转移)。

重要的是,这种逻辑允许 Mojo 在值的生命周期结束时立即销毁它们。

不可变参数(borrowed)

如果希望函数接收不可变引用,请在参数名称前添加 borrowed 关键字。

例如当类型的复制成本很高时(或根本无法复制),而您只需要读取它

from tensor import Tensor, TensorShape

def print_shape(borrowed tensor: Tensor[DType.float32]):
    shape = tensor.shape()
    print(str(shape))
fn main() raises:
    var tensor = Tensor[DType.float32](256, 256)
    print_shape(tensor)
# 256x256

一般来说,传递不可变引用在处理数据或复制成本较高的值时效率更高,因为复制构造函数和析构函数时不会调用借用。

与 C++ 和 Rust 对比

Mojo 的借用约定与 C++ 中的 const& 有两个重要区别

  • Mojo 编译器实现了借用检查器(Rust
  • IntFloatSIMD 这样的小数值会直接在机器寄存器中传递,而不是通过额外的间接传递,这大大提高了性能,并将这种优化从每次调用转移到了类型定义的声明中。

RustMojo 之间的主要区别在于,Mojo 在传递小数值时效率更高,而 Rust 则默认移动数值,而不是通过借用传递。

可变参数(inout)

如果希望函数接收可变引用,可在参数名称前添加 inout 关键字,意味着在函数内部对参数的任何修改,在函数外部都是可见的。

例如,这个 mutate 函数会更新原始 x 值:

def mutate(inout y: Int):
    y += 1

var x = 1
mutate(x)
print(x)
# 2

不过,请记住作为 inout 传递的值必须已经可变的。例如,如果你试图获取一个借用值并将其作为 inout 传递给另一个函数,你会得到一个编译器错误,因为 Mojo 无法从一个不可变引用形成一个可变引用。

注意:虽然 inout引用 在概念上是相同的,但不称其为 “按引用传递”,因为实现过程中实际上可能使用指针传递值。

注意:不能为 inout 参数定义默认值。

转移参数(owned 和 ^)

最后,owned 关键字会将参数的所有权交给函数

这一参数约定通常与在传入函数的变量上使用"转移"操作符(^)作为后缀相结合,从而结束该变量的生命周期。

从技术上讲,owned 关键字并不保证接收到的值是对原始值的可变引用,它只保证函数获得对该特定值的唯一所有权(强制值语义)。

分为两种情况

  • 传递参数时使用 ^ 转移操作符:

该操作符会结束变量的生命周期(变量变为无效),并将所有权转移到函数中,而不会复制任何堆分配的数据。

fn take_text(owned text: String):
    text += "!"
    print(text)
    
fn main():
    var message: String = "Hello"
    take_text(message^)  
    print(message)  # ERROR: The `message` variable is uninitialized

在运行所有权转移 take_text(message^) 这个命令之后,message 变量的声明周期已经结束,不能再使用了。

  • 调用者不使用 ^ 转移操作符:

值会被复制到函数参数中,原始变量仍然有效(但如果变量不再使用,编译器会销毁变量,因为它的生命周期自然到此为止)。

fn main():
    var message: String = "Hello"
    take_text(message)
    print(message)
# Hello!
# Hello

无论如何,当函数将某个参数声明为 owned 时,就可以确定它对该值拥有唯一的可变访问权

MojoREPL 中,值的生命期并没有完全实现到顶级代码中,所以转移操作符目前只在函数内部使用时才能正常工作。

转移实现细节

Mojo 中,不要把 “转移所有权” 和 “移动操作” 混为一谈。严格来说,两者并不是一回事。

Mojo 可以通过多种方式转移值的所有权,而无需制作拷贝。如果一个类型

  • 实现了移动构造函数 __moveinit__,如果该类型的一个值作为一个 owned 参数被转移到一个函数中,并且原始值的生命周期在同一点结束(无论是否使用 ^ 转移操作符),Mojo 都可以调用该方法。
  • 没有实现 __moveinit__Mojo 可以通过简单地将调用栈中值的引用传递给接收者来转移所有权。

为了使 owned 约定在不使用转移操作符的情况下也能生效,值类型必须是可复制的(即实现了 __copyinit__ 方法)。

比较 def 和 fn 的参数约定

正如在有关函数的章节中所提到的,deffn 函数是可以互换的,它们都可以完成同样的事情。

Mojodef 函数本质上只是 fn 函数的语法糖。

  • 没有类型注解的 def 参数默认为对象类型(而 fn 则要求明确声明所有类型)。
  • 没有约定关键字(borrowed, inout, 或 owned)的 def 参数默认为 owned(它接收一个可变变量的副本)。

例如,这两个函数的行为完全相同:

def example(borrowed a: Int, inout b: Int, c):
    pass

fn example(a: Int, inout b: Int, owned c: object):
    pass

或者,你也可以在需要的时候手动复制一份,而不用将 c 参数指定为 owned

fn example(a: Int, inout b: Int, c_in: object):
    var c = c_in
    pass

这种浅拷贝通常不会增加开销,因为复制对象等小类型的引用很廉价的。代价较高的部分是调整引用计数,但编译器的优化也消除了这一点。

  • 21
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

名本无名

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

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

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

打赏作者

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

抵扣说明:

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

余额充值