一架梯子,一头程序猿,仰望星空!
Mojo教程 > 内容正文

系统编程能力


基本的系统编程扩展

鉴于我们的目标是兼容性,而Python在高级应用和动态API方面的优势,我们不需要花太多时间解释语言的这些部分是如何工作的。另一方面,Python对系统编程的支持主要是通过C来实现的,我们希望提供一个在这个领域表现出色的统一系统。因此,本节将介绍每个主要组件和特性,并说明如何使用它们并提供示例。

letvar声明

在Mojo中的def内部,您可以为一个名称赋值,并且它会自动创建一个函数作用域变量,就像在Python中一样。这为编写代码提供了一种非常动态和低仪式的方式,但它面临两个挑战:

  1. 系统程序员通常希望声明一个值是不可变的,以确保类型安全性和性能。
  2. 他们可能希望在赋值时对变量名拼写错误时获得一个错误。

为了支持这一点,Mojo提供了作用域运行时值声明:let是不可变的,而var是可变的。这些值使用词法作用域,并支持名称屏蔽:

def your_function(a, b):
    let c = a

    if c != b:
        let d = b
        print(d)

your_function(2, 3)
3

letvar声明支持类型声明和模式匹配,以及延迟初始化:

def your_function():
    let x: Int = 42
    let y: Float64 = 17.0

    let z: Float32
    if x != 0:
        z = 1.0
    else:
        z = foo()
    print(z)

def foo() -> Float32:
    return 3.14

your_function()
1.0

请注意,在def函数中使用letvar完全是可选的(您可以像在Python中一样使用隐式声明的值),但在fn函数中,所有变量都必须使用它们进行声明。

此外,注意当在REPL环境(例如笔记本)中使用Mojo时,顶级变量(在函数或结构之外存在的变量)被视为def函数中的变量,因此它们允许隐式值类型声明(它们不需要varlet声明,也不需要类型声明)。这与Python REPL的行为匹配。

struct类型

Mojo基于MLIR和LLVM,这是一个先进的编译器和代码生成系统,用于许多编程语言。这使得我们可以更好地控制数据组织、直接访问数据字段和其他提高性能的方式。现代系统编程语言的一个重要特性是能够在这些复杂的低级操作之上构建高级的、安全的抽象,而不会有任何性能损失。在Mojo中,这是由struct类型提供的。

在Mojo中,struct类似于Python的class:它们都支持方法、字段、运算符重载、元编程的装饰器等等。它们的区别如下:

  • Python的类是动态的:它们允许动态调度、monkey-patching(或“swizzling”)和在运行时动态绑定实例属性。
  • Mojo的结构是静态的:它们在编译时绑定(不能在运行时添加方法)。结构允许您在安全而易于使用的同时以性能为代价进行灵活性交换。

这是一个简单的结构定义:

struct MyPair:
    var first: Int
    var second: Int

    fn __init__(inout self, first: Int, second: Int):
        self.first = first
        self.second = second

    fn __lt__(self, rhs: MyPair) -> Bool:
        return self.first

从句法上看,与Python的class最大的区别在于,struct中的所有实例属性必须使用varlet声明进行显式声明。

在Mojo中,“struct”的结构和内容是预先设置的,程序运行时不能更改。与Python不同,您不能在运行程序的过程中添加、删除或更改对象的属性。这意味着您不能使用del来删除方法或在程序运行中更改其值。

然而,struct的静态性质带来了一些巨大的好处!它有助于Mojo更快地运行您的代码。程序知道在哪里找到结构的信息以及如何使用它,而无需任何额外的步骤或延迟。

Mojo的结构也与您可能已经从Python中知道的一些特性非常契合,比如运算符重载(它使您可以更改如+-这样的数学符号与您自己的数据的工作方式)。此外,所有“标准类型”(如IntBoolString甚至Tuple)都是使用struct创建的。这意味着它们是您可以使用的标准工具集的一部分,而不是硬编码到语言本身中。这在编写代码时为您提供了更大的灵活性和控制能力。

如果您想知道self参数上的inout是什么意思:这表示参数是可变的,并且在函数内部进行的更改对调用者可见。

Int vs int

在Mojo中,您可能会注意到我们使用的是Int(大写字母“I”),与Python的int(小写字母“i”)不同。这种差异是有意的,而且实际上是一件好事!

在Python中,int类型可以处理非常大的数字,并且具有一些额外的功能,比如检查两个数字是否是同一个对象。但这会带来一些额外的负担,可能会降低性能。Mojo的Int是不同的。它被设计为简单、快速,并经过调优,以便快速处理您计算机的硬件。

我们做出这个选择有两个主要原因:

  1. 我们希望为需要与计算机硬件密切合作(系统程序员)的程序员提供一种透明可靠的与硬件交互的方式。我们不希望依赖于花哨的技巧(如JIT编译器)来提高速度。
  2. 我们希望Mojo能够与Python很好地配合,而不会引起任何问题。通过使用不同的名称(Int而不是int),我们可以在Mojo中保留这两种类型,而不改变Python中int的工作方式。

另外,Int遵循与您可能在Mojo中创建的其他自定义数据类型相同的命名风格。此外,Int是Mojo标准工具集中包含的一个struct

强类型检查

尽管Mojo仍可以使用像Python那样灵活的类型,但它也允许你使用严格的类型检查。类型检查可使你的代码更加可预测、可管理和安全。

其中一种实现强类型检查的主要方式是使用Mojo的struct类型。在Mojo中,struct定义了一个在编译时绑定的名称,并且在类型上下文中对该名称的引用被视为对所定义的值的强制规定。例如,考虑下面使用上面所示的MyPair结构的代码:

def pair_test() -> Bool:
    let p = MyPair(1, 2)
    return True

如果你取消注释第一行的返回语句并运行它,你将得到一个编译时错误,告诉你4不能转换为MyPair,这是右侧的__lt__()所要求的(在MyPair的定义中)。

这在使用系统编程语言时是一个熟悉的经验,但Python的工作方式却不是这样的。Python在语法上具有与MyPy类型注解相同的特性,但不是通过编译器来强制执行的:相反,它们只是一些提供静态分析的提示。通过将类型与特定声明绑定,Mojo可以处理经典的类型注解提示和强类型规范,而不会破坏兼容性。

类型检查并不是唯一的使用强类型的用例。由于我们知道类型是准确的,我们可以基于这些类型优化代码,将值传入寄存器,并像C一样有效地进行参数传递和其他底层细节处理。这是Mojo为系统程序员提供的安全性和可预测性保证的基础。

重载函数和方法

与Python类似,你可以在Mojo中定义函数而不指定参数数据类型,Mojo将以动态方式处理它们。当你希望保证类型安全时,正如上面讨论的那样,Mojo还完全支持重载函数和方法。

这允许你以相同的名称但不同的参数定义多个函数。这是许多编程语言中常见的特性,如C++、Java和Swift。

在解析函数调用时,Mojo会尝试每个候选项并选择可行的那个(如果只有一个可行),或者它选择最接近的匹配项(如果可以确定接近的匹配),或者如果无法确定选择哪一个则报告调用是模棱两可的。在后一种情况下,你可以在调用处添加一个显示转换来解决模糊性。

让我们来看一个例子:

struct Complex:
    var re: Float32
    var im: Float32

    fn __init__(inout self, x: Float32):
        """给定一个实数构造一个复数。"""
        self.re = x
        self.im = 0.0

    fn __init__(inout self, r: Float32, i: Float32):
        """给定实部和虚部构造一个复数。"""
        self.re = r
        self.im = i

在结构体和类中,你可以重载方法,并且可以在模块级函数中重载。

Mojo不支持仅基于结果类型进行函数重载,并且不使用结果类型或上下文类型信息进行类型推导,以保持简单、快速和可预测。Mojo永远不会产生“表达式过于复杂”的错误,因为其类型检查器在设计上是简单、快速的。

同样地,如果你在参数名称上没有定义类型定义,那么该函数的行为就像Python一样具有动态类型。一旦定义了单个参数类型,Mojo将寻找重载候选项并解析函数调用,就像上面所描述的那样。

尽管我们还没有讨论参数(它们与函数参数不同),但你也可以基于参数进行函数和方法重载。

fn定义

上述扩展是提供低级编程和抽象能力的基础,但许多系统程序员更喜欢比Mojo中的def提供的更多控制和可预测性。回顾一下,def的定义是为了满足非常动态、灵活和与Python通用的需求:参数是可变的,局部变量在首次使用时隐式声明,且作用域没有限制。这对于高级编程和脚本编写很好,但对于系统编程来说并不总是很好。为了补充这一点,Mojo提供了一个fn声明,就像def的“严格模式”。

可选方案:我们可以使用一个像@strict def这样的修饰符或装饰器来代替使用新的关键字fn。然而,我们无论如何都需要引入新的关键字,而这样做并没有太大的开销。此外,在实际的系统编程领域中,经常使用fn,所以将其作为一等公民可能是有意义的。

就调用者而言,fndef是可以互换的:在def不能提供的情况下,fn也不能(反之亦然)。区别在于,在函数内部,fn更受限制和控制(或者说是过于苛刻和严格)。具体来说,相较于def函数,fn具有以下几个限制:

  1. 函数体内的参数值默认为不可变的(类似于let变量),而不是可变的(类似于var变量)。这样可以防止意外的修改,并允许使用不可复制的类型作为参数。
  2. 函数参数值需要一个类型规范(除了方法中的self),以防止类型规范的意外遗漏。同样,如果缺少返回类型说明符,则会解释为返回None而不是未知的返回类型。注意,两者都可以明确声明为返回object,这允许将其视为def的行为(如果有需要的话)。
  3. 禁用隐式声明的局部变量,因此必须声明所有的局部变量。这样可以捕捉到命名拼写错误,并与letvar提供的作用域结合。
  4. 两者都支持引发异常,但在fn中必须使用raises关键字来显式声明。

编程模式在团队中会有很大的差异,这种严格程度并不适用于所有人。我们预期,习惯于C++并且已经在Python中使用MyPy风格类型注解的人会更喜欢使用fn,而高级程序员和机器学习研究人员会继续使用def。Mojo允许您自由地混合使用deffn声明,例如,使用其中一个实现一些方法,使用另一个实现其他方法,并且允许每个团队或程序员根据其用例选择最佳方法。

__copyinit____moveinit__特殊方法

Mojo支持完全的“值语义”,就像C++和Swift等语言中一样,并且使用@value装饰器非常容易定义简单的字段聚合。

对于高级用例,Mojo允许您使用Python现有的__init__特殊方法定义自定义构造函数,使用现有的__del__特殊方法定义自定义析构函数,以及使用新的__copyinit____moveinit__特殊方法定义自定义复制和移动构造函数。

这些底层自定义方法可以在进行低级系统编程时非常有用,例如手动内存管理。例如,考虑一个动态字符串类型,当构造时需要为字符串数据分配内存,并在销毁值时销毁该内存:

from memory.unsafe import Pointer

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

    fn __init__(inout self):
        self.cap = 16
        self.size = 0
        self.data = Pointer[Int].alloc(self.cap)

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

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

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

这个数组类型使用低级函数实现,以展示它如何工作的简单示例。然而,如果您尝试使用=运算符复制HeapArray的实例,您可能会感到惊讶:

var a = HeapArray(3, 1)
a.dump()   # 应该打印 [1, 1, 1]

var b = HeapArray(4, 2)
b.dump()   # 应该打印 [2, 2, 2, 2]
a.dump()   # 应该打印 [1, 1, 1]
[1, 1, 1]
[2, 2, 2, 2]
[1, 1, 1]

如果取消注释将a复制到b的代码行,您将看到Mojo不允许您复制我们的数组:HeapArray包含一个Pointer实例(相当于低级C指针),而Mojo不知道它指向的是何种数据或如何复制它。更一般地说,某些类型(如原子数值类型)不能被复制或移动,因为它们的地址提供了一个身份,就像类实例一样。

在这种情况下,我们确实希望我们的数组是可复制的。为了实现这一点,我们必须实现__copyinit__特殊方法,按照惯例实现如下:

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

    fn __init__(inout self):
        self.cap = 16
        self.size = 0
        self.data = Pointer[Int].alloc(self.cap)

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

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

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

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

通过这个实现,我们上面的代码可以正常工作,b = a复制产生了一个逻辑上不同的数组实例,具有自己的生命周期和数据:

var a = HeapArray(3, 1)
a.dump()   # 应该打印 [1, 1, 1]
var b = a

b.dump()   # 应该打印 [1, 1, 1]
a.dump()   # 应该打印 [1, 1, 1]
[1, 1, 1]
[1, 1, 1]
[1, 1, 1]

Mojo还支持__moveinit__方法,它允许Rust风格的move(在生命周期结束时取值)和C++风格的move(删除值的内容但仍运行析构函数),并允许定义自定义的移动逻辑。

Mojo完全控制值的生命周期,包括使类型可复制、仅移动和不可移动的能力。这比Swift和Rust等语言提供了更多控制,这些语言要求至少能够移动值。