Mojo 学习 —— 值的所有权
文章目录
所有现代编程语言都将数据存储在两个地方:调用栈(stack
)和堆(heap
)(有时也存储在 CPU
寄存器中,但不在我们本次讨论的范围内)。
但是,每种语言都有自己特定的读写数据的方式,有的甚至大相径庭。所以,在后面的内容中,我们主要学习一下 Mojo
是如何管理程序中的内存,以及这对编写 Mojo
代码的影响
介绍
栈和堆
一般来说,所有编程语言都以相同的方式使用调用栈:
-
当调用一个函数时,编译器会在栈上分配一个内存块,其大小等于需要存储的执行逻辑和固定大小的局部数据值;
-
当调用另一个函数时,其数据以同样的方式被添加到栈的顶部;
-
当一个函数执行完毕后,栈中的所有数据都会被销毁,这快内存就可以供其他代码使用了。
请注意,栈中只存储 “固定大小的局部数据值”,而那些运行时会发生变化的动态值则存储在堆中
堆是一个更大的内存区域,允许在运行时进行动态的内存访问。
但是从技术上讲,这种值的局部变量还是存储在调用栈中,只是它的值是指向堆上真实值的固定大小的指针。
此外,需要在函数生命周期之外使用的值(例如在函数之间传递且不应被复制的数组)会存储在堆中,因为堆内存可从调用栈中的任何位置访问(栈上存储的是指针),即使创建该值的函数从栈中移除后也是如此。
这种堆分配的值被多个函数使用,最容易导致内存错误,这也是不同编程语言内存管理策略差异最大的地方。
内存管理策略
由于内存有限,程序必须尽快从堆中删除未使用的数据(释放内存),但是要知道何时应该释放内存是非常复杂的。
一些编程语言试图通过使用 “垃圾回收器” 进程来隐藏内存管理的复杂性,该进程会跟踪所有内存的使用情况,并定期卸载未使用的堆内存(也称为自动内存管理)。
这种方法最大的好处是减轻了开发人员手动管理内存的负担,通常可以避免更多错误,提高开发人员的工作效率。
不过,这种方法也会产生性能代价,因为垃圾回收器会中断程序的执行,而且回收内存的速度可能不会很快。
其他语言则需要手动释放内存,如果操作得当,可以让程序快速执行。但是也很容易出现问题,比如可能会在程序使用数据之前不小心将其内存释放,或者执行双重释放,或忘了释放(导致内存泄露)。
诸如此类的错误可能会给程序带来灾难性的后果,而且这些错误通常很难被追踪到,所以如何避免出现这些错误很重要
Mojo
使用的是第三种方法,称为 “所有权”,如果熟悉 Rust
,对此应该很熟悉。
它依赖于程序员在传递值时必须遵守的一系列规则,这些规则能够确保每块内存在同一时间只有一个 “所有者”,并相应地对该内存进行释放
遵循这样的规则之后,Mojo
就可以自动为你分配和释放堆内存,这种分配和释放内存的方式是确定的,不会出现释放后使用、双重释放和内存泄漏等错误。并且它的性能开销也非常低。
在解释 Mojo
值所有权规则和语法之前,您首先需要了解什么是值语义
值语义
Mojo
不会强制执行值语义或引用语义。它同时支持这两种语义,并允许对每种类型定义其创建、复制和移动的方式
所以,在创建自己的类型的,你可以实现值语义、引用语义或两者兼而有之。
Mojo
的参数行为默认是值语义,同时也为引用语义提供了严格控制,以避免内存错误
一般来说,每个变量对值都有唯一的访问权限,该变量作用域之外的任何代码都不能修改其值。
什么是值语义
最简单的情况是,共享值语义类型意味着创建值的副本。也就是我们所说的 “按值传递”。例如
x = 1
y = x
y += 1
print(x)
print(y)
这里的 y = x
,是先复制了一份 x
让后将其分配给 y
。也就是说每个变量都独占了一个值。所以输出结果是
1
2
如果使用引用语义的话,y
和 x
会指向相同的值,修改任何一个都会影响另一个的值。
下面是一个使用函数的例子:
def add_one(y: Int):
y += 1
print(y)
x = 1
add_one(x)
print(x)
同样的,y
是一个副本,函数不能修改原始 x
值。
因为,在 Mojo
中,所有函数参数的默认行为都是使用值语义。如果函数要修改输入参数的值,则必须明确声明,以避免意外突变。
我们知道,def
函数的参数类型都是通过值传递的。例如,尽管 Mojo
的 Tensor
类型会在堆上分配值,但当你将一个实例传递给 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
不会复制任何参数。
因此,def
和 fn
参数的默认行为是完全值语义的:参数要么是副本,要么是不可变引用,任何来自被调用者的变量都不会受到函数的影响。
但是,我们也必须允许引用语义(可变引用),因为这是我们构建高性能、高内存效率程序的方法(复制是非常昂贵的操作)。我们面临的挑战是如何在不影响值语义的可预测性和安全性的前提下引入引用语义。
在 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
)- 像
Int
、Float
和SIMD
这样的小数值会直接在机器寄存器中传递,而不是通过额外的间接传递,这大大提高了性能,并将这种优化从每次调用转移到了类型定义的声明中。
Rust
和 Mojo
之间的主要区别在于,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
时,就可以确定它对该值拥有唯一的可变访问权
在
Mojo
的REPL
中,值的生命期并没有完全实现到顶级代码中,所以转移操作符目前只在函数内部使用时才能正常工作。
转移实现细节
在 Mojo
中,不要把 “转移所有权” 和 “移动操作” 混为一谈。严格来说,两者并不是一回事。
Mojo
可以通过多种方式转移值的所有权,而无需制作拷贝。如果一个类型
- 实现了移动构造函数
__moveinit__
,如果该类型的一个值作为一个owned
参数被转移到一个函数中,并且原始值的生命周期在同一点结束(无论是否使用^
转移操作符),Mojo
都可以调用该方法。 - 没有实现
__moveinit__
,Mojo
可以通过简单地将调用栈中值的引用传递给接收者来转移所有权。
为了使 owned
约定在不使用转移操作符的情况下也能生效,值类型必须是可复制的(即实现了 __copyinit__
方法)。
比较 def 和 fn 的参数约定
正如在有关函数的章节中所提到的,def
和 fn
函数是可以互换的,它们都可以完成同样的事情。
Mojo
的 def
函数本质上只是 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
这种浅拷贝通常不会增加开销,因为复制对象等小类型的引用很廉价的。代价较高的部分是调整引用计数,但编译器的优化也消除了这一点。