2  控制流、结构、模块和类型

我们在上一章中讨论了 Zig 的大量语法,尤其是在1.2.2 节1.2.3 节中。但我们仍然需要讨论该语言的其他一些非常重要的元素。这些元素将在你的日常工作中不断使用。

本章首先讨论 Zig 中与控制流相关的不同关键字和结构(例如循环和 if 语句)。然后,我们讨论结构体以及如何在 Zig 中使用它们来实现一些基本的面向对象 (OOP) 模式。我们还讨论了类型推断和类型转换。最后,我们以讨论模块及其与结构体的关系来结束本章。

2.1控制流

有时,你需要在程序中做出一些决策。也许你需要决定是否执行一段特定的代码。又或者,你需要对一系列值应用相同的操作。这类任务需要使用能够改变程序“控制流”的结构体。

在计算机科学中,“控制流”通常指给定语言或程序中表达式(或命令)的求值顺序。但该术语也用于指能够改变给定语言/程序执行的命令“求值顺序”的结构。

这些结构通常被称为“循环”、“if/else 语句”、“switch 语句”等等。循环和 if/else 语句是可以改变程序“控制流”的结构示例。关键字continuebreak也是可以改变求值顺序的符号示例,因为它们可以将程序移动到循环的下一次迭代,或者完全停止循环。

2.1.1 If/else 语句

if/else 语句执行“条件流操作”。条件流控制(或选择控制)允许您根据逻辑条件执行或忽略特定的命令块。许多程序员和计算机科学专业人士在这种情况下也使用术语“分支”。本质上,if/else 语句允许我们根据逻辑测试的结果来决定是否执行给定的命令块。

在 Zig 中,我们使用关键字if和编写 if/else 语句else。我们以关键字 开头,if后跟一对括号内的逻辑测试,然后是一对花括号,其中包含在逻辑测试返回值时要执行的代码行true

之后,您可以选择添加一条else语句。为此,只需添加关键字,后跟一对花括号,并在定义的逻辑测试返回else时执行代码行。if``false

在下面的示例中,我们测试对象是否x包含大于 10 的数字。根据控制台打印的输出判断,我们知道此逻辑测试返回了。因为控制台中的输出与if/else 语句分支false中的代码行兼容。else

const x = 5;
if (x > 10) {
    try stdout.print(
        "x > 10!\n", .{}
    );
} else {
    try stdout.print(
        "x <= 10!\n", .{}
    );
}
x <= 10!

2.1.2 Switch 语句

Zig 中也提供 Switch 语句,其语法与 Rust 中的 switch 语句非常相似。正如您所料,要在 Zig 中编写 switch 语句,我们使用switch关键字。我们在一对括号内提供要“切换”的值。然后,我们在一对花括号内列出可能的组合(或“分支”)。

我们来看下面的代码示例。可以看到,我正在创建一个名为 的枚举类型。我们将在7.6 节Role中详细讨论枚举。总而言之,此类型列出了一家虚构公司中的不同类型的职位,例如软件工程师、数据工程师、产品经理等等。Role``SE``DE``PM

role请注意,我们在 switch 语句中使用了对象的值,以发现需要在area变量对象中存储的具体区域。还要注意,我们在 switch 语句中使用了类型推断,并使用了点字符,正如我们将在2.4 节中描述的那样。这使得zig编译器能够为我们推断出值的正确数据类型(PMSE等)。

还要注意,我们在 switch 语句的同一个分支中对多个值进行了分组。我们只需用逗号分隔每个可能的值即可。例如,如果role包含 或DEDAarea变量将包含值"Data & Analytics", ,而不是"Platform""Sales"

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const Role = enum {
    SE, DPE, DE, DA, PM, PO, KS
};

pub fn main() !void {
    var area: []const u8 = undefined;
    const role = Role.SE;
    switch (role) {
        .PM, .SE, .DPE, .PO => {
            area = "Platform";
        },
        .DE, .DA => {
            area = "Data & Analytics";
        },
        .KS => {
            area = "Sales";
        },
    }
    try stdout.print("{s}\n", .{area});
}
Platform

2.1.2.1 Switch 语句必须穷尽所有可能性

Zig 中 switch 语句的一个非常重要的方面是它们必须穷尽所有现有的可能性。换句话说,role对象内部可能找到的所有可能值都必须在此 switch 语句中明确处理。

由于该role对象的类型为,因此Role该对象中唯一可能的值是PMSEDPEPO、和。此对象中没有其他可能的值可以存储。因此,switch 语句必须针对这些值中的每一个都有一个组合(分支)。这就是“穷尽所有现有可能性”的含义。switch 语句涵盖了所有可能的情况。DE``DA``KS``role

因此,你不能在 Zig 中编写 switch 语句,而留下一个没有明确操作的边缘情况。这与 Rust 中的 switch 语句类似,后者也必须处理所有可能的情况。

2.1.2.2 else 分支

dump_hex_fallible()以下面的函数为例。此函数来自 Zig 标准库。更准确地说,来自模块debug.zig1。函数中有多行代码,但我省略了它们,以便仅关注其中的 switch 语句。请注意,此 switch 语句有四种可能的情况(即四个显式分支)。另外,请注意,我们else在本例中使用了一个分支。

switch 语句中的分支else充当“默认分支”。当你在 switch 语句中遇到多个 case 并希望执行完全相同的操作时,可以使用分支else来实现。

pub fn dump_hex_fallible(bytes: []const u8) !void {
    // Many lines ...
    switch (byte) {
        '\n' => try writer.writeAll("␊"),
        '\r' => try writer.writeAll("␍"),
        '\t' => try writer.writeAll("␉"),
        else => try writer.writeByte('.'),
    }
}

许多程序员也会使用else分支来处理“不支持”的情况。也就是说,你的代码无法正确处理这种情况,或者只是不应该“修复”的情况。因此,你可以使用分支else在程序中触发 panic(或引发错误)来停止当前执行。

以下面的代码示例为例。我们可以看到,我们正在处理对象为level1、2 或 3 的情况。所有其他可能的情况默认情况下均不受支持,因此,在这种情况下,我们会通过@panic()内置函数引发运行时错误。

还要注意,我们将 switch 语句的结果赋值给一个名为 的新对象category。这是在 Zig 中使用 switch 语句可以做的另一件事。如果分支输出一个值作为结果,则可以将 switch 语句的结果值存储到一个新对象中。

const level: u8 = 4;
const category = switch (level) {
    1, 2 => "beginner",
    3 => "professional",
    else => {
        @panic("Not supported level!");
    },
};
try stdout.print("{s}\n", .{category});
thread 13103 panic: Not supported level!
t.zig:9:13: 0x1033c58 in main (switch2)
            @panic("Not supported level!");
            ^

2.1.2.3在 switch 中使用范围

此外,您还可以在 switch 语句中使用值的范围。也就是说,您可以在 switch 语句中创建一个分支,只要输入值在指定范围内,就会使用该分支。这些“范围表达式”由运算符创建...。需要强调的是,此运算符创建的范围包含两端。

例如,我可以轻松地更改前面的代码示例以支持 0 到 100 之间的所有级别。如下所示:

const level: u8 = 4;
const category = switch (level) {
    0...25 => "beginner",
    26...75 => "intermediary",
    76...100 => "professional",
    else => {
        @panic("Not supported level!");
    },
};
try stdout.print("{s}\n", .{category});
beginner

这很简洁,而且它也能用于字符范围。也就是说,我可以简单地写'a'...'z', 来匹配任何小写字母的字符值,这样就可以了。

2.1.2.4带标签的 switch 语句

在1.7 节中,我们讨论了如何给代码块添加标签,以及如何使用这些标签从代码块中返回值。从 0.14.0 及以后的zig编译器版本开始,你也可以在 switch 语句上添加标签,这使得我们几乎可以实现goto类似“C”的模式。

例如,如果你给xswswitch 语句指定了标签,则可以将此标签与关键字结合使用continue,以返回到 switch 语句的开头。在下面的示例中,执行过程两次返回到 switch 语句的开头,然后结束于分支3

xsw: switch (@as(u8, 1)) {
    1 => {
        try stdout.print("First branch\n", .{});
        continue :xsw 2;
    },
    2 => continue :xsw 3,
    3 => return,
    4 => {},
    else => {
        try stdout.print(
            "Unmatched case, value: {d}\n", .{@as(u8, 1)}
        );
    },
}

2.1.3关键字defer

使用defer关键字 ,你可以注册一个在退出当前作用域时执行的表达式。因此,该关键字的功能与on.exit()R 中的函数类似。以foo()下面的函数为例。执行此foo()函数时,打印“Exiting function ...”消息的表达式仅在函数退出其作用域时执行。

const std = @import("std");
const stdout = std.io.getStdOut().writer();
fn foo() !void {
    defer std.debug.print(
        "Exiting function ...\n", .{}
    );
    try stdout.print("Adding some numbers ...\n", .{});
    const x = 2 + 2; _ = x;
    try stdout.print("Multiplying ...\n", .{});
    const y = 2 * 8; _ = y;
}

pub fn main() !void {
    try foo();
}
Adding some numbers ...
Multiplying ...
Exiting function ...

因此,我们可以使用defer来声明一个表达式,该表达式将在代码退出当前作用域时执行。有些程序员喜欢将“退出当前作用域”解释为“当前作用域的结束”。但这种解释可能并不完全正确,这取决于你对“当前作用域的结束”的定义。

我的意思是,你认为当前作用域的结束在哪里?是作用域的右花括号(})吗?是函数中最后一个表达式执行的时候吗?是函数返回到前一个作用域的时候吗?等等。例如,将“退出当前作用域”解释为作用域的右花括号是不对的。因为函数可能从比这个右花括号更早的位置退出(例如,在函数内部的上一行生成了一个错误值;函数到达了更早的 return 语句;等等)。无论如何,请谨慎对待这种解释。

现在,如果你还记得我们在1.7 节中讨论过的内容,你会发现语言中有多种结构会创建各自独立的作用域。例如,for/while 循环、if/else 语句、函数、普通代码块等等。这也会影响 的解释defer。例如,如果你defer在 for 循环中使用 ,那么每次这个特定的 for 循环退出其自身作用域时,都会执行给定的表达式。

在继续之前,值得强调的是,该defer关键字是“无条件延迟”的。这意味着无论代码如何退出当前作用域,给定的表达式都会被执行。例如,你的代码可能因为生成错误值、return 语句、break 语句等而退出当前作用域。

2.1.4关键字errdefer

在上一节中,我们讨论了defer关键字,它可以用于注册一个在当前作用域退出时执行的表达式。但是这个关键字还有一个兄弟,那就是errdefer关键字。关键字defer是“无条件延迟”,而errdefer关键字 是“有条件延迟”。这意味着,只有在特定情况下退出当前作用域时,给定的表达式才会执行。

更详细地说,仅当当前作用域发生错误时,才会执行给定的表达式。因此,如果函数(或 for/while 循环、if/else 语句等)在正常情况下退出当前作用域,且没有发生错误,则不会执行errdefer给定的表达式。errdefer

这使得errdefer关键字成为 Zig 中可用于错误处理的众多工具之一。在本节中,我们更关注周围的控制流方面errdefer。但我们稍后将在10.2.4 节errdefer中讨论它作为错误处理工具的作用。

下面的代码示例演示了三件事:

  • defer是一个“无条件延迟”,因为无论函数如何foo()退出其自身范围,给定的表达式都会被执行。
  • errdefer由于函数foo()返回了错误值而执行该操作。
  • and表达式按照 LIFO (后进先出)的顺序defer执行errdefer
const std = @import("std");
fn foo() !void { return error.FooError; }
pub fn main() !void {
    var i: usize = 1;
    errdefer std.debug.print("Value of i: {d}\n", .{i});
    defer i = 2;
    try foo();
}
Value of i: 2
error: FooError
/t.zig:6:5: 0x1037e48 in foo (defer)
    return error.FooError;
    ^

当我说“defer 表达式”按照后进先出 (LIFO) 的顺序执行时,我的意思是代码中最后一个defererrdefer表达式最先被执行。你也可以理解为:“defer 表达式”从下往上执行,或者从最后面往前执行。

defer因此,如果我改变and表达式的顺序,你会注意到打印到控制台的 thaterrdefer的值变为 1。这并不意味着本例中该表达式没有被执行。实际上,这意味着该表达式仅在该表达式之后执行。下面的代码示例演示了这一点:i``defer``defer``errdefer

const std = @import("std");
fn foo() !void { return error.FooError; }
pub fn main() !void {
    var i: usize = 1;
    defer i = 2;
    errdefer std.debug.print("Value of i: {d}\n", .{i});
    try foo();
}
Value of i: 1
error: FooError
/t.zig:6:5: 0x1037e48 in foo (defer)
    return error.FooError;
    ^

2.1.5 For 循环

循环允许你多次执行相同的代码行,从而在程序的执行流程中创建一个“重复空间”。当我们想要在不同的输入上复制相同的函数(或相同的命令集)时,循环特别有用。

Zig 中有不同类型的循环。但其中最重要的可能是_for 循环_。for 循环用于将同一段代码应用于切片或数组的元素。

Zig 中的 for 循环使用了一种其他语言程序员可能不熟悉的语法。首先输入for关键字,然后在一对括号内列出要迭代的项。然后,在一对竖线 ( |) 内,声明一个标识符作为迭代器,或者说是“循环的重复索引”。

for (items) |value| {
    // code to execute
}

因此,在 Zig 中,for 循环不使用(value in items)语法,而是使用语法(items) |value|。在下面的示例中,您可以看到我们正在循环遍历存储在对象中的数组项name,并将该数组中每个字符的十进制表示形式打印到控制台。

如果需要,我们也可以迭代数组的某个部分(或切片),而不是迭代存储在name对象中的整个数组。只需使用范围选择器来选择所需的部分即可。例如,我可以为 for 循环提供表达式name[0..3],以便仅迭代数组中的前 3 个元素。

const name = [_]u8{'P','e','d','r','o'};
for (name) |char| {
    try stdout.print("{d} | ", .{char});
}
80 | 101 | 100 | 114 | 111 | 

在上面的例子中,我们使用数组中每个元素本身的值作为迭代器。但在很多情况下,我们需要使用索引而不是元素的实际值。

您可以通过提供第二组要迭代的项目来实现这一点。更准确地说,您为 for 循环提供了范围选择器0..。所以,是的,您可以在 Zig 的 for 循环中同时使用两个不同的迭代器。

但请记住,从1.4 节开始,在 Zig 中创建的每个对象都必须以某种方式使用。因此,如果在 for 循环中声明了两个迭代器,则必须在 for 循环体中使用这两个迭代器。但是,如果您只想使用索引迭代器,而不使用“值迭代器”,则可以通过将值项与下划线字符匹配来丢弃值迭代器,如下例所示:

const name = "Pedro";
for (name, 0..) |_, i| {
    try stdout.print("{d} | ", .{i});
}
0 | 1 | 2 | 3 | 4 |

2.1.6 While 循环

while 循环由关键字创建while。whilefor循环会遍历数组的所有元素,而 whilewhile循环则会无限循环,直到某个逻辑测试(由您指定)为 false。

从关键字开始while,然后在一对括号内定义一个逻辑表达式,并在一对花括号内提供循环主体,如下例所示:

var i: u8 = 1;
while (i < 5) {
    try stdout.print("{d} | ", .{i});
    i += 1;
}
1 | 2 | 3 | 4 | 

您还可以指定在 while 循环开头使用的增量表达式。为此,我们将增量表达式写在冒号 ( :) 后的一对括号内。下面的代码示例演示了另一种模式。

var i: u8 = 1;
while (i < 5) : (i += 1) {
    try stdout.print("{d} | ", .{i});
}
1 | 2 | 3 | 4 | 

2.1.7使用breakcontinue

break在 Zig 中,您可以分别使用关键字和显式停止循环的执行,或者跳转到循环的下一次迭代continuewhile下一个代码示例中显示的循环乍一看是一个无限循环。因为括号内的逻辑值始终等于。但是,当对象达到计数 10 时,true是什么让这个while循环停止呢?答案是关键字!i``break

在 while 循环中,我们有一个 if 语句,它不断检查i变量是否等于 10。由于我们i在 while 循环的每次迭代中都会增加的值,所以这个i对象的值最终会等于 10,当它等于 10 时,if 语句将执行break表达式,结果,while 循环的执行停止。

注意在 while 循环后使用了expect()Zig 标准库中的函数。该expect()函数是一个“断言”类型的函数。此函数检查提供的逻辑测试是否等于 true。如果是,则函数不执行任何操作。否则(即逻辑测试等于 false),该函数将引发断言错误。

var i: usize = 0;
while (true) {
    if (i == 10) {
        break;
    }
    i += 1;
}
try std.testing.expect(i == 10);
try stdout.print("Everything worked!", .{});
Everything worked!

由于此代码示例已被编译器成功执行zig,且未引发任何错误,因此我们知道,在执行 while 循环后,i对象等于 10。因为如果它不等于 10,则会引发错误expect()

现在,在下一个示例中,我们来看一下该continue关键字的用例。if 语句会不断检查当前索引是否是 2 的倍数。如果是,则跳转到循环的下一次迭代。否则,循环只会将当前索引打印到控制台。

const ns = [_]u8{1,2,3,4,5,6};
for (ns) |i| {
    if ((i % 2) == 0) {
        continue;
    }
    try stdout.print("{d} | ", .{i});
}
1 | 3 | 5 | 

2.2函数参数是不可变的

我们已经在1.2.2节1.2.3节中讨论了函数声明背后的许多语法。但我想强调一下Zig中关于函数参数(又称函数参数)的一个有趣的事实。总而言之,函数参数在Zig中是不可变的。

以下面的代码示例为例,我们声明了一个简单的函数,它只是尝试将某个数添加到输入整数中,然后返回结果。如果仔细观察这个函数的主体add2(),你会注意到我们尝试将结果保存回x函数参数中。

换句话说,该函数不仅使用通过函数参数 接收到的值x,而且还尝试通过将加法结果赋值给 来更改该函数参数的值x。但是,Zig 中的函数参数是不可变的。您无法更改它们的值,或者,您无法在函数主体内部为它们赋值。

这就是为什么下面的代码示例无法成功编译的原因。如果您尝试编译此代码示例,您将收到一条关于“试图更改不可变(即常量)对象的值”的编译错误消息。

const std = @import("std");
fn add2(x: u32) u32 {
    x = x + 2;
    return x;
}

pub fn main() !void {
    const y = add2(4);
    std.debug.print("{d}\n", .{y});
}
t.zig:3:5: error: cannot assign to constant
    x = x + 2;
    ^

2.2.1免费优化

如果函数参数接收一个对象作为输入,而该对象的数据类型是我们在1.5 节中列出的任何原始类型,则该对象始终以值的形式传递给函数。换句话说,该对象会被复制到函数堆栈框架中。

但是,如果输入对象的数据类型更复杂,例如,它可能是结构体实例、数组或联合值等,在这种情况下,zig编译器将自行决定哪种策略最佳。因此,编译器zig将通过值或引用将对象传递给函数。编译器始终会选择对您来说更快的策略。这种免费获得的优化之所以能够实现,是因为函数参数在 Zig 中是不可变的。

2.2.2如何克服这一障碍

在某些情况下,您可能需要直接在函数体内部更改函数参数的值。当我们将 C 结构体作为输入传递给 Zig 函数时,这种情况更常发生。

在这种情况下,你可以使用指针来克服这个障碍。换句话说,你可以传递一个“指向值的指针”,而不是将值作为输入传递给参数。你可以通过取消引用来更改指针指向的值。

因此,以前面的例子为例,我们可以通过将参数标记为“指向值的指针”(即数据类型)而不是值,在函数体内add2()更改函数参数的值。通过将其设置为指针,我们最终可以在函数体内直接更改此函数参数的值。您可以看到下面的代码示例编译成功。x``x``u32``*u32``u32``add2()

const std = @import("std");
fn add2(x: *u32) void {
    const d: u32 = 2;
    x.* = x.* + d;
}

pub fn main() !void {
    var x: u32 = 4;
    add2(&x);
    std.debug.print("Result: {d}\n", .{x});
}
Result: 6

即使在上面的代码示例中,x参数仍然是不可变的。这意味着指针本身是不可变的。因此,您无法更改它指向的内存地址。但是,您可以取消引用该指针来访问它指向的值,并且如果需要,还可以更改该值。

2.3结构体和 OOP

Zig 是一种与 C(一种过程式语言)关系更密切的语言,而不是与 C++ 或 Java(面向对象语言)关系更密切的语言。因此,Zig 中不提供高级的 OOP(面向对象编程)模式,例如类、接口或类继承。不过,Zig 中的 OOP 仍然可以通过使用结构体定义来实现。

使用结构体定义,您可以在 Zig 中创建(或定义)新的数据类型。这些结构体定义的工作方式与 C 语言中的工作方式相同。您需要为这个新结构体(或您正在创建的新数据类型)命名,然后列出这个新结构体的数据成员。您还可以在这个结构体中注册函数,它们将成为这个特定结构体(或数据类型)的方法。这样,您使用此新类型创建的每个对象都将始终具有这些可用且与之关联的方法。

在 C++ 中,当我们创建一个新类时,我们通常有一个构造函数方法(或构造函数),用于构造(或实例化)该特定类的每个对象,我们还有一个析构函数方法(或析构函数),它是负责销毁该类的每个对象的函数。

init()在 Zig 中,我们通常通过在结构体内部声明一个和两个方法来声明结构的构造函数和析构函数deinit()。这只是一个命名约定,在整个 Zig 标准库中都会看到。因此,在 Zig 中,init()结构体的方法通常是该结构体所表示的类的构造函数方法。而deinit()方法是用于销毁该结构体现有实例的方法。

init()和方法都在 Zig 代码中广泛使用,当我们在第 3.3 节deinit()讨论分配器时,您将看到它们都被使用。但是,作为另一个例子,让我们构建一个简单的结构体来表示某种系统的用户。User

如果您查看User下面的结构体,就会看到struct关键字。注意此结构体的数据成员:idname和。每个数据成员的类型都使用我们之前在1.2.2 节中描述的email冒号 () 语法明确注释。但也请注意,结构体中描述数据成员的每一行都以逗号 ( ) 结尾。因此,每次在 Zig 代码中声明数据成员时,请始终以逗号结束行,而不是以传统的分号 ( ) 结束。:,``;

接下来,我们注册了一个init()函数作为该结构体的方法User。此init()方法是构造函数,我们将使用它来实例化每个新User对象。因此,此init()函数会返回一个新User对象作为结果。

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const User = struct {
    id: u64,
    name: []const u8,
    email: []const u8,

    fn init(id: u64,
            name: []const u8,
            email: []const u8) User {

        return User {
            .id = id,
            .name = name,
            .email = email
        };
    }

    fn print_name(self: User) !void {
        try stdout.print("{s}\n", .{self.name});
    }
};

pub fn main() !void {
    const u = User.init(1, "pedro", "email@gmail.com");
    try u.print_name();
}
pedro

2.3.1关键字pub

关键字pub在结构体声明和 Zig 中的 OOP 中扮演着重要的角色。本质上,这个关键字是“public”的缩写,它使某个项/组件在声明该项/组件的模块之外可用。换句话说,如果我没有pub在某个东西上应用这个关键字,就意味着这个“东西”只能在声明这个“东西”的模块内部调用/使用。

为了演示此关键字的效果,让我们再次关注User上一节中声明的结构体。对于此处的示例,假设此User结构体是在名为的 Zig 模块内声明的user.zig。如果我不在结构体pub上使用关键字User,则意味着我只能在声明该结构的模块内(在本例中为模块)创建一个User对象并调用其方法(print_name()和) 。init()``User``user.zig

User这就是前面代码示例运行良好的原因。因为我们在同一个模块中声明并使用了该结构体。但是,当我们尝试从另一个模块导入并调用/使用这个结构体时,问题就开始出现了。例如,如果我创建一个名为 的新模块register.zig,并将该user.zig模块导入其中,并尝试用该User类型注释任何变量,编译器就会报错。

// register.zig
const user = @import("user.zig");
pub fn main() !void {
    const u: user.User = undefined;
    _ = u;
}
register.zig:3:18: error: 'User' is not marked 'pub'
    const u: user.User = undefined;
             ~~~~^~~~~
user.zig:3:1: note: declared here
const User = struct {
^~~~~

因此,如果你想在声明这个“东西”的模块之外使用它,你必须用pub关键字标记它。这个“东西”可以是模块、结构体、函数、对象等等。

对于我们这里的例子,如果我们回到user.zig模块,并将pub关键字添加到User结构声明中,那么我就可以成功编译register.zig模块。

// user.zig
// Added the `pub` keyword to `User`
pub const User = struct {
// ...
// register.zig
// This works fine now!
const user = @import("user.zig");
pub fn main() !void {
    const u: user.User = undefined;
    _ = u;
}

现在,如果我尝试实际调用结构体register.zig中的任何方法,你认为会发生什么User?例如,如果我尝试调用该init()方法?答案是:我会收到一条类似的错误消息,警告我该init()方法未标记为pub,如下所示:

const user = @import("user.zig");
pub fn main() !void {
    const u: user.User = user.User.init(
        1, "pedro", "email@gmail.com"
    );
    _ = u;
}
register.zig:3:35: error: 'init' is not marked 'pub'
    const u: user.User = user.User.init(
                         ~~~~~~~~~^~~~~
user.zig:8:5: note: declared here
    fn init(id: u64,
    ^~~~~~~

因此,仅仅因为我们在结构体声明中使用了pub关键字,并不意味着该结构体的方法也变为公共的。如果我们想在声明该结构体的模块之外使用该结构体中的任何方法(例如方法),我们也init()必须使用关键字标记此方法。pub

回到模块,用关键字user.zig标记init()和方法,使它们都可以供外界使用,从而使前面的代码示例能够工作。print_name()``pub

// user.zig
// Added the `pub` keyword to `User.init`
    pub fn init(
// ...
// Added the `pub` keyword to `User.print_name`
    pub fn print_name(self: User) !void {
// ...
// register.zig
// This works fine now!
const user = @import("user.zig");
pub fn main() !void {
    const u: user.User = user.User.init(
        1, "pedro", "email@gmail.com"
    );
    _ = u;
}

2.3.2匿名结构体字面量

可以将结构体对象声明为字面值。通常,在左花括号之前写出该结构体字面值的数据类型来指定其数据类型。例如,我可以User像这样写一个我们在上一节中定义的类型的结构体字面值:

const eu = User {
    .id = 1,
    .name = "Pedro",
    .email = "someemail@gmail.com"
};
_ = eu;

然而,在 Zig 中,我们也可以编写匿名结构体字面量。也就是说,你可以编写结构体字面量,但不必明确指定该特定结构的类型。匿名结构体使用以下语法编写.{}。因此,我们实际上用点字符 ( ) 替换了结构体字面量的显式类型.

正如我们在2.4 节中所述,当你在结构体字面量前面添加一个点时,zig编译器会自动推断该结构体字面量的类型。本质上,zig编译器会寻找一些关于该结构体类型的提示。这些提示可以是函数参数的类型注解,也可以是函数返回值的类型注解,或者是一个现有对象的类型注解。如果编译器找到了这样的类型注解,它就会在你的结构体字面量中使用它。

在 Zig 中,匿名结构体通常用作函数参数的输入。一个您经常看到的例子是print()来自stdout对象的函数。该函数接受两个参数。第一个参数是一个模板字符串,其中包含字符串格式说明符,用于指示如何将第二个参数中提供的值打印到消息中。

第二个参数是一个结构体字面量,它列出了要打印到第一个参数指定的模板消息中的值。通常情况下,你应该在这里使用一个匿名结构体字面量,这样zig编译器就会为你指定这个特定匿名结构体的类型。

const std = @import("std");
pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("Hello, {s}!\n", .{"world"});
}
Hello, world!

2.3.3结构体声明必须是常量

Zig 中的类型必须是const或(我们将在第 12.1 节comptime中详细讨论 comptime )。这意味着您不能创建新的数据类型,并使用关键字 将其标记为变量。因此,结构体声明始终是常量。您不能使用关键字 声明新的结构体类型。它必须是。var``var``const

在下面的示例中Vec3,允许这种声明,因为我使用const关键字来声明这种新的数据类型。

const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,
};

2.3.4方法self参数

在所有支持 OOP 的语言中,当我们声明某个类或结构体的方法时,我们通常会将其声明为一个带有self参数的函数。该self参数是对调用该方法的对象本身的引用。

使用此参数并非强制要求self。但为什么不使用它呢self?没有理由不使用它。因为访问结构体数据成员中存储的数据的唯一方法是通过此self参数访问它们。如果您不需要在方法中使用结构体数据成员中的数据,则很可能不需要方法。您可以将此逻辑声明为一个简单的函数,放在结构体声明之外。

以下面的结构体为例Vec3。在这个Vec3结构体中,我们声明了一个名为 的方法。该方法根据欧氏空间中的距离公式distance()计算两个对象之间的距离。注意,该方法接受两个对象作为输入,分别为 和。Vec3``distance()``Vec3``self``other

const std = @import("std");
const m = std.math;
const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,

    pub fn distance(self: Vec3, other: Vec3) f64 {
        const xd = m.pow(f64, self.x - other.x, 2.0);
        const yd = m.pow(f64, self.y - other.y, 2.0);
        const zd = m.pow(f64, self.z - other.z, 2.0);
        return m.sqrt(xd + yd + zd);
    }
};

参数self对应于调用Vec3此方法的对象。而是一个单独的对象,作为此方法的输入。在下面的示例中,参数 对应于对象,因为该方法是从对象 调用的,而参数 对应于对象。distance()``other``Vec3``self``v1``distance()``v1``other``v2

const v1 = Vec3 {
    .x = 4.2, .y = 2.4, .z = 0.9
};
const v2 = Vec3 {
    .x = 5.1, .y = 5.6, .z = 1.6
};

std.debug.print(
    "Distance: {d}\n",
    .{v1.distance(v2)}
);
Distance: 3.3970575502926055

2.3.5关于 struct state

有时你不需要关心结构体对象的状态。有时你只需要实例化并使用这些对象,而无需改变它们的状态。你可能会注意到,当你在结构体声明中有一些方法可能会使用数据成员中现有的值,但它们不会以任何方式改变结构体这些数据成员的值。

2.3.4 节Vec3中介绍的结构体就是一个例子。该结构体只有一个名为 的方法,并且该方法确实使用了结构体所有三个数据成员( 、和)中的值。但同时,该方法在任何时候都不会更改这些数据成员的值。distance()``x``y``z

因此,我们创建Vec3对象时通常将其创建为常量对象,例如2.3.4 节中介绍的v1和对象。如果需要,我们也可以使用关键字 将它们创建为变量对象。但由于此结构体的方法在任何时候都不会改变对象的状态,因此无需将它们标记为变量对象。v2var``Vec3

但为什么呢?我为什么要在这里讨论这个问题?因为self方法中的参数会受到影响,取决于结构体中的方法是否改变对象本身的状态。更具体地说,当结构体中有一个方法会改变对象的状态(即改变数据成员的值)时,self该方法中的参数必须以不同的方式进行注解。

正如我在2.3.4 节中所述,self结构体方法中的参数是指接收调用该方法的对象作为输入的参数。我们通常在方法中用 来注释此参数self,后跟冒号 ( :),以及该方法所属结构体的数据类型(例如 UserVec3等等)。

如果我们Vec3以上一节中定义的结构体为例,我们可以在distance()方法中看到这个self参数被注释为self: Vec3。因为对象的状态Vec3永远不会被此方法改变。

但是,如果我们确实有一个方法可以通过修改其数据成员的值来改变对象的状态,那么self在这种情况下我们应该如何注解呢?答案是:“我们应该将其注解self为 的指针x,而不仅仅是x”。换句话说,你应该将其注解selfself: *x,而不是将其注解为self: x

如果我们在对象内部创建一个新方法Vec3,例如,通过将向量的坐标乘以二来扩展向量,那么我们需要遵循上一段中指定的规则。下面的代码示例演示了这个想法:

const std = @import("std");
const m = std.math;
const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,

    pub fn distance(self: Vec3, other: Vec3) f64 {
        const xd = m.pow(f64, self.x - other.x, 2.0);
        const yd = m.pow(f64, self.y - other.y, 2.0);
        const zd = m.pow(f64, self.z - other.z, 2.0);
        return m.sqrt(xd + yd + zd);
    }

    pub fn twice(self: *Vec3) void {
        self.x = self.x * 2.0;
        self.y = self.y * 2.0;
        self.z = self.z * 2.0;
    }
};

请注意,在上面的代码示例中,我们向结构体添加了一个Vec3名为 的新方法twice()。此方法将向量对象的坐标值加倍。对于twice(),我们将self参数注释为*Vec3,表示此参数接收一个指向对象的指针(或者,如果您愿意,也可以这样称呼它,即引用)作为Vec3输入。

var v3 = Vec3 {
    .x = 4.2, .y = 2.4, .z = 0.9
};
v3.twice();
std.debug.print("Doubled: {d}\n", .{v3.x});
Doubled: 8.4

现在,如果您将此方法self中的参数更改为(就像在方法中一样),您将收到如下所示的编译器错误。请注意,此错误消息显示了方法主体中的一行,表明您无法更改数据成员的值。twice()``self: Vec3``distance()``twice()``x

// If we change the function signature of double to:
    pub fn twice(self: Vec3) void {
t.zig:16:13: error: cannot assign to constant
        self.x = self.x * 2.0;
        ~~~~^~

此错误消息表明该x数据成员属于常量对象,因此无法更改。最终,此错误消息告诉我们该self参数是常量。

如果您花点时间仔细思考一下这个错误信息,您就会明白。您已经掌握了理解为什么我们会收到此错误信息的工具。我们已经在2.2节讨论过了。所以请记住,在Zig中,每个函数参数都是不可变的,self这条规则也不例外。

在这个例子中,我们将v3对象标记为变量对象。但这无关紧要。因为它与输入对象无关,而是与函数参数有关。

当我们尝试直接修改 的值时self,问题就开始了,因为它是一个函数参数,而每个函数参数默认都是不可变的。你可能会问自己,我们该如何克服这个障碍?同样的,解决方案在2.2 节中也讨论过了。我们通过将self参数显式标记为指针来克服这个障碍。

笔记

如果结构体的方法x通过更改任何数据成员的值来改变对象的状态,那么请记住在该方法的函数签名中使用self: *x,而不是。self: x

x您还可以将本节讨论的内容解释为:“如果您需要在某个方法中更改结构体对象的状态,则必须x通过引用将结构体对象明确传递给self该方法的参数”。

2.4类型推断

Zig 是一种强类型语言。但是,在某些情况下,您不必像使用传统的强类型语言(例如 C 和 C++)那样在源代码中显式地写出每个对象的类型。

在某些情况下,zig编译器可以使用类型推断来帮你解决数据类型问题,从而减轻开发者的一些负担。最常见的做法是通过接收结构体对象作为输入的函数参数。

通常,Zig 中的类型推断是使用点字符 ( .) 来完成的。每当您在结构体字面量、枚举值或类似符号前看到一个点字符时,您就知道这个点字符在这里起着特殊的作用。更具体地说,它告诉zig编译器类似这样的信息:“嘿!你能帮我推断一下这个值的类型吗?拜托!”。换句话说,这个点字符的作用类似于autoC++ 中的关键字。

我在2.3.2 节中给出了一些示例,其中我们使用了匿名结构体字面量。匿名结构体字面量是指使用类型推断来推断特定结构体字面量确切类型的结构体字面量。这种类型推断是通过查找一些关于要使用的正确数据类型的最小提示来完成的。你可以说,zig编译器会查找任何可能告诉它正确类型的邻近类型注解。

在 Zig 中,我们使用类型推断的另一个常见地方是 switch 语句(我们在第 2.1.2 节中讨论过)。在第 2.1.2 节中,我还给出了一些其他类型推断的例子,其中我们推断了 switch 语句中列出的枚举值的数据类型(例如 .DE)。但作为另一个例子,请看一下fence()下面重现的这个函数,它来自Zig 标准库的atomic.zig模块2 。

这个函数中还有很多东西我们还没有讨论,比如:什么comptime意思inline???extern让我们忽略所有这些事情,只关注这个函数内部的 switch 语句。

我们可以看到这个 switch 语句使用order对象作为输入。这个order对象是这个函数的输入之一fence(),并且我们可以在类型注释中看到,这个对象的类型是AtomicOrder。我们还可以在 switch 语句中看到一堆以点字符开头的值,例如.release.acquire

因为这些奇怪的值前面有一个点字符,所以我们要求zig编译器在 switch 语句中推断这些值的类型。然后,zig编译器会查看当前使用这些值的上下文,并尝试推断这些值的类型。

由于它们是在 switch 语句中使用,zig编译器会检查传递给 switch 语句的输入对象的类型,order在本例中是 object 。由于此对象的类型为AtomicOrderzig编译器推断这些值是来自此类型的数据成员AtomicOrder

pub inline fn fence(self: *Self, comptime order: AtomicOrder) void {
    // many lines of code ...
    if (builtin.sanitize_thread) {
        const tsan = struct {
            extern "c" fn __tsan_acquire(addr: *anyopaque) void;
            extern "c" fn __tsan_release(addr: *anyopaque) void;
        };

        const addr: *anyopaque = self;
        return switch (order) {
            .unordered, .monotonic => @compileError(
                @tagName(order)
                ++ " only applies to atomic loads and stores"
            ),
            .acquire => tsan.__tsan_acquire(addr),
            .release => tsan.__tsan_release(addr),
            .acq_rel, .seq_cst => {
                tsan.__tsan_acquire(addr);
                tsan.__tsan_release(addr);
            },
        };
    }

    return @fence(order);
}

这就是 Zig 中基本类型推断的实现方式。如果我们在这个 switch 语句中的值前没有使用点字符,那么我们将被迫显式地写出这些值的数据类型。例如,.release我们不应该写成 ,而应该写成AtomicOrder.release。我们必须对这个 switch 语句中的每个值都执行此操作,这需要大量的工作。这就是为什么类型推断在 Zig 的 switch 语句中被广泛使用的原因。

2.5类型转换

在本节中,我想和大家讨论一下类型转换(type casting)。当我们有一个类型为“x”的对象,并且想要将其转换为类型为“y”的对象时,我们会使用类型转换,也就是说,我们想要改变该对象的数据类型。

大多数语言都有执行类型转换的正式方法。例如,在 Rust 中,我们通常使用关键字as;在 C 语言中,我们通常使用类型转换语法,例如 (int) x。在 Zig 中,我们使用@as()内置函数将类型为“x”的对象转换为类型为“y”的对象。

@as()函数是在 Zig 中执行类型转换(或类型强制转换)的首选方法。因为它是显式的,并且只有在明确且安全的情况下才会执行强制转换。要使用此函数,只需在第一个参数中提供目标数据类型,并在第二个参数中提供要强制转换的对象。

const std = @import("std");
const expect = std.testing.expect;
test {
    const x: usize = 500;
    const y = @as(u32, x);
    try expect(@TypeOf(y) == u32);
}
1/1 file3fc93b4ea641.test_0...OKAll 1 tests passed
  d.

这是在 Zig 中执行类型转换的一般方法。但请记住,@as()只有当类型转换明确且安全时,才有效。在很多情况下,这些假设并不成立。例如,

当将整数值转换为浮点值或反之亦然时,编译器不清楚如何安全地执行此转换。

因此,在这种情况下,我们需要使用专门的“强制类型转换函数”。例如,如果要将整数值转换为浮点数,则应该使用@floatFromInt()函数。反之,则应该使用@intFromFloat()函数。

在这些函数中,你只需提供要强制类型转换的对象作为输入。然后,“类型转换操作”的目标数据类型由保存结果的对象的类型注释决定。在下面的示例中,我们将对象转换x为 类型的值f32,因为y保存结果的对象 被注释为 类型的对象f32

const std = @import("std");
const expect = std.testing.expect;
test {
    const x: usize = 565;
    const y: f32 = @floatFromInt(x);
    try expect(@TypeOf(y) == f32);
}
1/1 file3fc91795a712.test_0...OKAll 1 tests passed
  d.

执行类型转换操作时非常有用的另一个内置函数是@ptrCast()。本质上,@as()当我们想要将 Zig 值/对象从类型“x”显式转换(或强制转换)为类型“y”等时,我们会使用内置函数。但是,指针(我们将在第 6 章更深入地讨论指针)是 Zig 中的一种特殊类型的对象,即,它们的处理方式与“普通对象”不同。

在 Zig 中,每当指针涉及某些“类型转换操作”时,@ptrCast()都会使用该函数。此函数的工作原理与 类似@floatFromInt()。您只需将要转换的指针对象作为输入提供给此函数,目标数据类型再次由存储结果的对象的类型注释决定。

const std = @import("std");
const expect = std.testing.expect;
test {
    const bytes align(@alignOf(u32)) = [_]u8{
        0x12, 0x12, 0x12, 0x12
    };
    const u32_ptr: *const u32 = @ptrCast(&bytes);
    try expect(@TypeOf(u32_ptr) == *const u32);
}
1/1 file3fc945f8b4b0.test_0...OKAll 1 tests passed
  d.

2.6模块

我们已经讨论了什么是模块,以及如何通过_import 语句_将其他模块导入到当前模块中。您在项目中编写的每个 Zig 模块(即.zig文件)在内部都存储为一个结构体对象。以下面显示的行为例。在这一行中,我们将 Zig 标准库导入到当前模块中。

const std = @import("std");

当我们想要访问标准库中的函数和对象时,我们实际上是在访问存储在std对象中的结构体的数据成员。因此,我们使用与普通结构体相同的语法,即点运算符 ( .) 来访问结构体的数据成员和方法。

当执行此“import 语句”时,此表达式的结果是一个包含 Zig 标准库模块、全局变量、函数等的结构对象。并且此结构对象被保存(或存储)在名为的常量对象内std

thread_pool.zig项目zap3中的模块为例。该模块的编写方式就像一个大型结构体。因此,我们init()在此模块中编写了一个顶级公共方法。其理念是,此模块中编写的所有顶级函数都是该结构体中的方法,所有顶级对象和结构体声明都是该结构体的数据成员。模块本身就是该结构体。

因此,您可以通过执行以下操作来导入和使用这个模块:

const std = @import("std");
const ThreadPool = @import("thread_pool.zig");
const num_cpus = std.Thread.getCpuCount()
    catch @panic("failed to get cpu core count");
const num_threads = std.math.cast(u16, num_cpus)
    catch std.math.maxInt(u16);
const pool = ThreadPool.init(
    .{ .max_threads = num_threads }
);

  1. https://github.com/ziglang/zig/blob/master/lib/std/debug.zig ↩︎

  2. https://github.com/ziglang/zig/blob/master/lib/std/atomic.zig↩︎

  3. https://github.com/kprotty/zap/blob/blog/src/thread_pool.zig ↩︎