1   Zig 简介

在本章中,我想向您介绍 Zig 的世界。Zig 是一种非常年轻的语言,目前正在积极开发中。因此,它的世界仍然充满未知,有待探索。本书旨在帮助您踏上理解和探索 Zig 精彩世界的个人旅程。

我假设你之前已经使用过本书中的某种编程语言,不一定是低级编程语言。所以,如果你有 Python 或 JavaScript 的经验,那就没问题。但是,如果你有 C、C++ 或 Rust 等低级语言的经验,那么你通过本书学习的速度可能会更快。

1.1什么是 Zig?

Zig 是一种现代、低级且通用的编程语言。一些程序员认为 Zig 是 C 语言的现代升级版。

在作者的个人理解中,Zig 与“少即是多”紧密相连。Zig 并非试图通过添加越来越多的功能来成为一门现代语言,而是带来了许多核心改进,实际上是为了移除 C 和 C++ 中令人讨厌的行为/功能。换句话说,Zig 试图通过简化语言,并使其行为更加一致和健壮来变得更好。因此,与 C 或 C++ 相比,在 Zig 中分析、编写和调试应用程序变得更容易、更简单。

Zig 官方网站的以下语句清楚地表明了这一理念:

“专注于调试您的应用程序而不是调试您的编程语言知识”。

这句话对于 C++ 程序员来说尤其适用。因为 C++ 是一门庞大的语言,拥有海量的特性,而且有很多不同的“C++ 风格”。正是这些因素使得 C++ 如此复杂,难以学习。Zig 则试图反其道而行之。Zig 是一种非常简单的语言,与 C 和 Go 等其他简单语言的关系更为密切。

上面这句话对 C 程序员来说仍然很重要。因为即使 C 语言本身很简单,阅读和理解 C 代码有时仍然很困难。例如,C 语言中的预处理器宏经常会让人感到困惑。有时,它们甚至会让调试 C 程序变得非常困难。因为宏本质上是嵌入在 C 语言中的第二种语言,它会掩盖你的 C 代码。使用宏,你不再能 100% 确定哪些代码片段会被发送到编译器,也就是说,它们会掩盖你编写的实际源代码。

Zig 中没有宏。在 Zig 中,你编写的代码是编译器编译的实际代码。你也没有在后台发生的隐藏控制流。而且,你也没有标准库中的函数或操作符在你背后进行隐藏的内存分配。

作为一种更简单的语言,Zig 变得更加清晰,更易于读写,但同时,它也达到了更加健壮的状态,在边缘情况下的行为更加一致。再一次,少即是多。

1.2 Zig 中的 Hello World

我们从创建一个小型的“Hello World”程序开始我们的 Zig 之旅。要在计算机上启动一个新的 Zig 项目,只需调用编译器init中的命令即可zig。只需在计算机上创建一个新目录,然后在该目录中初始化一个新的 Zig 项目,如下所示:

mkdir hello_world
cd hello_world
zig init
info: created build.zig
info: created build.zig.zon
info: created src/main.zig
info: created src/root.zig
info: see `zig build --help` for a menu of options

1.2.1了解项目文件

从编译器运行init命令后zig,当前目录中会创建一些新文件。首先,src创建一个“源”()目录,其中包含两个文件:main.zigroot.zig。每个.zig文件都是一个单独的 Zig 模块,它只是一个包含一些 Zig 代码的文本文件。

按照惯例,main.zig模块是你的主函数所在的位置。因此,如果你在 Zig 中构建一个可执行程序,你需要声明一个main()函数,它代表程序的入口点,即程序执行开始的地方。

但是,如果您要构建的是库(而不是可执行程序),那么通常的做法是删除此main.zig文件,然后从模块开始root.zig。按照惯例,root.zig模块是库的根源文件。

tree .
.
├── build.zig
├── build.zig.zon
└── src
    ├── main.zig
    └── root.zig

1 directory, 4 files

init命令还会在我们的工作目录中创建两个附加文件:build.zigbuild.zig.zon。第一个文件(build.zig)表示用 Zig 编写的构建脚本。当您buildzig编译器调用该命令时,将执行此脚本。换句话说,此文件包含执行构建整个项目所需步骤的 Zig 代码。

低级语言通常使用编译器将源代码构建为二进制可执行文件或二进制库。然而,随着项目规模越来越大,编译源代码并构建二进制可执行文件或二进制库的过程在编程世界中成为了一项真正的挑战。因此,程序员创建了“构建系统”,这是旨在简化编译和构建复杂项目过程的第二套工具。

构建系统的示例包括 CMake、GNU Make、GNU Autoconf 和 Ninja,它们用于构建复杂的 C 和 C++ 项目。使用这些系统,您可以编写脚本,这些脚本称为“构建脚本”。它们只是描述了编译/构建项目所需步骤的脚本。

然而,这些都是独立的工具,不属于 C/C++ 编译器,例如gccclang。因此,在 C/C++ 项目中,您不仅需要安装和管理 C/C++ 编译器,还需要单独安装和管理这些构建系统。

在 Zig 中,我们无需使用单独的工具来构建项目,因为语言本身就嵌入了构建系统。我们可以使用此构建系统在 Zig 中编写小脚本,这些脚本描述了构建/编译 Zig 项目1 的必要步骤。因此,构建一个复杂的 Zig 项目所需的只是zig编译器,仅此而已。

第二个生成的文件(build.zig.zon)是一个类似 JSON 的文件,您可以在其中描述您的项目,并声明一组您想要从互联网获取的项目依赖项。换句话说,您可以使用此build.zig.zon文件在项目中引入外部库列表。

在您的项目中包含外部 Zig 库的一种可能方法是在您的系统中手动构建和安装该库,然后在您的项目的构建步骤中将您的源代码与该库链接起来。

但是,如果这个外部 Zig 库在 GitHub 上可用,并且它build.zig.zon在项目的根文件夹中有一个描述项目的有效文件,那么您只需在build.zig.zon文件中列出这个外部库,就可以轻松地将该库包含在您的项目中。

换句话说,此build.zig.zon文件的工作方式与package.jsonJavascript 项目中的文件、PipfilePython 项目中的文件或Cargo.tomlRust 项目中的文件类似。您可以在互联网上的几篇文章中阅读有关此特定文件的更多信息2 3 ,也可以在 Zig 4build.zig.zon官方存储库中的文档文件中查看此文件的预期模式。

1.2.2文件root.zig

让我们看一下这个root.zig文件。你可能注意到,每行带有表达式的代码都以分号 ( ;) 结尾。这遵循了 C 语言家族的语法5

另外,请注意@import()第一行的调用。我们使用这个内置函数将其他 Zig 模块的功能导入到当前模块中。此@import()函数的工作原理类似于#includeC 或 C++ 中的预处理器,或者importPython 或 JavaScript 代码中的语句。在此示例中,我们导入了std模块,这使您可以访问 Zig 标准库。

在此root.zig文件中,我们还可以看到如何在 Zig 中进行赋值(即创建新对象)。您可以使用语法在 Zig 中创建新对象(const|var) name = value;。在下面的示例中,我们创建了两个常量对象(stdtesting)。在第 1.4 节中,我们将更详细地讨论一般对象。

const std = @import("std");
const testing = std.testing;

export fn add(a: i32, b: i32) i32 {
    return a + b;
}

Zig 中的函数使用关键字声明fn。在此root.zig模块中,我们声明了一个名为的函数add(),它有两个名为a和 的参数b。该函数返回一个 类型的整数i32作为结果。

Zig 是一种强类型语言。在某些特定情况下,如果编译器可以推断出对象的类型(我们将在2.4 节zig中详细讨论),您可以(如果愿意)在代码中省略对象的类型。但在其他情况下,您确实需要明确指定。例如,您必须明确指定每个函数参数的类型,以及在 Zig 中创建的每个函数的返回类型。

在 Zig 中,我们使用冒号 ( :) 来指定对象或函数参数的类型,后跟该对象/函数参数名称后的类型。通过表达式a: i32和,我们知道和参数的类型b: i32都是,它是一个有符号的 32 位整数。在这一部分中,Zig 中的语法与 Rust 中的语法相同,后者也使用冒号字符来指定类型。a``b``i32

最后,在打开花括号开始编写函数主体之前,我们在行尾给出了函数的返回类型。在上面的例子中,此类型也是一个有符号的 32 位整数(i32)。

请注意,函数声明前还有一个export关键字。此关键字类似于externC 语言中的关键字。它暴露函数,使其在库 API 中可用。因此,如果您正在编写一个供其他人使用的库,则必须使用此关键字将您编写的函数暴露在该库的公共 API 中。如果我们从函数声明中export删除该关键字,那么该函数将不再在编译器构建的库对象中暴露。export``add()``zig

1.2.3文件main.zig

现在我们已经从文件中了解了很多关于 Zig 语法的知识root.zig,让我们来看看这个main.zig文件。我们在 中看到的很多元素root.zig也出现在 中main.zig。但是还有一些我们还没有看到的元素,所以让我们深入了解一下。

首先,查看此文件中函数的返回类型main()。我们可以看到一个细微的变化。函数的返回类型(void)后面跟着一个感叹号(!)。这个感叹号告诉我们,该main()函数可能返回错误。

值得注意的是,main()Zig 中的函数可以返回任何内容(void),或者返回一个无符号的 8 位整数(u8)值6,或者返回一个错误。换句话说,您可以main()在 Zig 中编写函数使其基本上不返回任何内容(void),或者,如果您愿意,也可以编写一个更像 C 语言的main()函数,它返回一个通常用作进程“状态代码”的整数值。

在此示例中,返回类型注释main()表示此函数可以不返回任何内容(void),也可以返回错误。返回类型注释中的感叹号是 Zig 一个有趣且强大的功能。总而言之,如果您编写了一个函数,并且该函数主体内部的某些内容可能会返回错误,那么您就必须:

  • 要么在函数的返回类型中添加感叹号,并明确表示该函数可能会返回错误。
  • 在函数内部明确处理此错误。

_在大多数编程语言中,我们通常通过try catch_模式来处理错误。Zig 确实同时包含trycatch关键字。但它们的工作方式可能与你在其他语言中习惯的方式略有不同。

如果我们查看main()下面的函数,您会发现第五行确实有一个关键字。但是这段代码中try没有关键字。在 Zig 中,我们使用关键字来执行可能返回错误的表达式,在本例中,该表达式就是该表达式。catch``try``stdout.print()

本质上,try关键字 执行表达式stdout.print()。如果此表达式返回有效值,则try关键字什么也不做,只是将值传递下去,就像这个关键字从未存在过一样try。但是,如果表达式返回错误,则try关键字将解包错误值,然后从函数中返回此错误,并将当前堆栈跟踪打印到stderr

如果你之前学过高级语言,这可能听起来很奇怪。因为在高级语言(例如 Python)中,如果某个地方发生错误,这个错误会自动返回,即使你不想停止程序的执行,程序的执行也会自动停止。你必须面对这个错误。

const std = @import("std");

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

您可能还注意到,此代码示例中的main()函数使用了pub关键字 。它将该main()函数标记为此模块的_公共函数_。默认情况下,Zig 模块中的每个函数都是此 Zig 模块的私有函数,并且只能在模块内部调用。除非您使用关键字 明确将此函数标记为公共函数pub

仔细想想,Zig 中的这个关键字的作用与 C/C++ 中的关键字作用pub本质上相反。通过将函数设置为“public”,您可以允许其他 Zig 模块访问和调用该函数。调用方 Zig 模块使用内置函数导入另一个模块,这使得被导入模块的所有公共函数对调用方 Zig 模块可见。static``@import()

1.2.4编译源代码

build-exe您可以通过运行编译器中的命令将 Zig 模块编译为二进制可执行文件zig。只需在命令后列出所有要构建的 Zig 模块build-exe,并用空格分隔即可。在下面的示例中,我们正在编译模块main.zig

zig build-exe src/main.zig

由于我们正在构建一个可执行文件,编译器会在命令后列出的任何文件中zig查找声明的函数。如果编译器在某个地方找不到声明的函数,则会引发编译错误,警告此错误。main()``build-exe``main()

编译zig器还提供了build-libbuild-obj命令,其工作方式与 命令完全相同build-exe。唯一的区别是,它们分别将 Zig 模块编译为可移植的 C ABI 库或目标文件。

就该命令而言build-exe,编译器会在项目的根目录中创建一个二进制可执行文件zig。如果我们现在使用一个简单的命令查看当前目录的内容,ls就可以看到main编译器创建的二进制文件。

ls
build.zig  build.zig.zon  main  src

如果我执行这个二进制可执行文件,我会在终端中收到“Hello World”消息,正如我们所期望的。

./main
Hello, world!

1.2.5同时编译和执行

在上一节中,我介绍了zig build-exe将 Zig 模块编译为可执行文件的命令。然而,这意味着,为了执行该可执行文件,我们必须运行两个不同的命令。首先运行该zig build-exe命令,然后调用编译器创建的可执行文件。

但是如果我们想在一个命令中同时执行这两个步骤怎么办?我们可以通过使用zig run命令来实现。

zig run src/main.zig
Hello, world!

1.2.6 Windows 用户重要提示

首先,这是 Windows 特有的,因此不适用于其他操作系统,例如 Linux 和 macOS。总之,如果您有一段 Zig 代码,其中包含一些全局变量,这些变量的初始化依赖于运行时资源,那么在 Windows 上尝试编译这段 Zig 代码时可能会遇到一些麻烦。

一个例子是访问stdout(即系统的_标准输出_std.io.getStdOut()),这通常在 Zig 中使用表达式来完成。如果使用此表达式在 Zig 模块中实例化全局变量,那么 Zig 代码的编译很可能会在 Windows 上失败,并出现“无法评估 comptime 表达式”的错误消息。

编译过程中出现这种失败是因为Zig中的所有全局变量都是在_编译时_初始化的。然而,在Windows上,访问(或打开文件)之类的操作依赖于仅在_运行_stdout时可用的资源(您将在第3.1.1节中了解有关编译时与运行时的更多信息)。

例如,如果您尝试在 Windows 上编译此代码示例,则可能会收到下面显示的错误消息:

const std = @import("std");
// ERROR! Compile-time error that emerges from
// this next line, on the `stdout` object
const stdout = std.io.getStdOut().writer();

pub fn main() !void {
    _ = try stdout.write("Hello\n");
}
t.zig:2107:28: error: unable to evaluate comptime expression
    break :blk asm {
               ^~~

为了避免在 Windows 上出现此问题,我们需要强制zig编译器仅在运行时实例化此stdout对象,而不是在编译时实例化它。我们可以通过简单地将表达式移到函数体中来实现这一点。

这解决了问题,因为Zig中函数体内部的所有表达式都只在运行时求值,除非你comptime显式使用关键字来改变这种行为。你将在第12.1节comptime中了解有关此关键字的更多信息。

const std = @import("std");
pub fn main() !void {
    // SUCCESS: Stdout initialized at runtime.
    const stdout = std.io.getStdOut().writer();
    _ = try stdout.write("Hello\n");
}
Hello

您可以在官方 Zig 存储库中打开的几个 GitHub 问题中阅读有关此 Windows 特定限制的更多详细信息。更具体地说,是问题 17186 7和 19864 8

1.2.7编译整个项目

正如我在第 1.2.1 节中所描述的,随着项目规模和复杂性的增长,我们通常更喜欢使用某种“构建系统”将项目的编译和构建过程组织成构建脚本。

换句话说,随着项目规模和复杂度的增长,build-exebuild-libbuild-obj命令变得越来越难以直接使用。因为那时,我们开始同时列出多个模块。我们还开始添加内置编译标志来根据需求定制构建过程等等。手动编写必要的命令变得非常繁琐。

在 C/C++ 项目中,程序员通常选择使用 CMake、NinjaMakefileconfigure脚本来组织此过程。然而,在 Zig 中,我们拥有语言本身的原生构建系统。因此,我们可以在 Zig 中编写构建脚本来编译和构建 Zig 项目。然后,我们需要做的就是调用命令zig build来构建我们的项目。

因此,当你执行该zig build命令时,编译器将在当前目录中zig搜索名为 Zig 的模块,该模块应该是你的构建脚本,其中包含编译和构建项目所需的代码。如果编译器在你的目录中找到了这个文件,那么它实际上会在这个文件上执行一个命令,编译并执行这个构建脚本,进而编译并构建你的整个项目。build.zig``build.zig``zig run``build.zig

zig build

执行此“build project”命令后,zig-out将在项目目录的根目录中创建一个目录,您可以在其中找到根据您在中指定的构建命令从 Zig 模块创建的二进制可执行文件和库build.zig。我们将在本书后面详细讨论 Zig 中的构建系统。

在下面的示例中,我正在执行hello_world编译器在命令后生成的名为的二进制可执行文件zig build

./zig-out/bin/hello_world
Hello, world!

1.3如何学习Zig?

学习 Zig 的最佳策略是什么?首先,这本书当然会在你学习 Zig 的过程中提供很大帮助。但如果你想真正精通 Zig,你还需要一些额外的资源。

作为第一个提示,您可以加入 Zig 程序员社区,以便在需要时获得一些帮助:

现在,学习 Zig 的最佳方法之一就是阅读 Zig 代码。尝试经常阅读 Zig 代码,事情就会变得更加清晰。AC/C++ 程序员可能也会给你同样的建议。因为这个策略真的有效!

那么,在哪里可以找到 Zig 代码来阅读呢?我个人认为,阅读 Zig 代码的最佳方法是阅读 Zig 标准库的源代码。Zig 标准库位于Zig 官方 GitHub 仓库的第 9 个lib/std文件夹下。访问此文件夹,即可开始探索 Zig 模块。

另外,一个很好的选择是从其他大型 Zig 代码库中读取代码,例如:

  1. Javascript 运行时Bun 10
  2. 游戏引擎Mach 11
  3. Zig 12中的LLama 2 LLM 模型实现
  4. 金融交易tigerbeetle数据库13 .
  5. 命令行参数解析器zig-clap14
  6. UIcapy框架15 .
  7. Zig 的语言协议实现,zls16
  8. 事件循环库libxev17

所有这些资源都可以在 GitHub 上找到,这很棒​​,因为我们可以利用 GitHub 的搜索栏来查找符合我们描述的 Zig 代码。例如,lang:Zig当您搜索特定模式时,您可以随时在 GitHub 的搜索栏中输入。这将把搜索范围限制在 Zig 模块上。

另外,一个很好的选择是查阅在线资源和文档。以下是我个人经常使用的资源列表,用于每天学习这门语言:

学习 Zig,或者坦白说,学习任何你想学的语言,另一个好策略就是通过做练习来练习。例如,Zig 社区里有一个著名的代码库,叫做Ziglings 18,里面有 100 多个你可以解决的小练习。这个代码库里存放着用 Zig 编写的、目前有问题的小程序,你的责任就是修复这些程序,让它们重新运行。

一位名为 The Primeagen 的著名科技 YouTuber也在 YouTube 上发布了一些视频,其中他解答了 Ziglings 的这些练习。第一个视频名为“尝试 Zig 第一部分” 19

另一个不错的选择是解决《代码降临》练习20。有些人已经花时间学习和解决了这些练习,并且他们也把解决方案发布在了 GitHub 上。所以,如果你在解决练习时需要一些资源来比较,可以看看这两个仓库:

1.4在Zig中创建新对象(即标识符)

让我们进一步讨论一下 Zig 中的对象。曾经使用过其他编程语言的读者可能会通过不同的名称来了解这个概念,例如“变量”或“标识符”。在本书中,我选择使用“对象”一词来指代这个概念。

要在 Zig 中创建新对象(或新的“标识符”),我们使用关键字constvar。这些关键字指定要创建的对象是否可变。如果使用const,则创建的对象是常量(或不可变)对象,这意味着一旦声明了此对象,就不能再更改存储在此对象中的值。

另一方面,如果使用var,则表示您正在创建一个变量(或可变)对象。您可以根据需要多次更改此对象的值。在 Zig 中使用 关键字与在 Rust 中var使用 关键字类似。let mut

1.4.1常量对象与变量对象

在下面的代码示例中,我们创建了一个名为 的新常量对象age。该对象存储一个表示某人年龄的数字。然而,此代码示例无法成功编译。因为在下一行代码中,我们尝试将对象的值更改age为 25。

编译zig器检测到我们正在尝试更改常量对象/标识符的值,因此,编译器将引发编译错误,警告我们该错误。

const age = 24;
// The line below is not valid!
age = 25;
t.zig:10:5: error: cannot assign to constant
    age = 25;
      ~~^~~

因此,如果要更改对象的值,则需要将不可变(或“常量”)对象转换为可变(或“变量”)对象。您可以使用var关键字来实现这一点。此关键字代表“变量”,当您将此关键字应用于某个对象时,您就是在告诉 Zig 编译器,与该对象关联的值可能会在某个时候发生变化。

因此,如果我们回到前面的示例,并将age对象的声明更改为使用var关键字,那么程序就能成功编译。因为现在,zig编译器检测到我们正在更改允许此行为的对象的值,因为它是一个“变量对象”。

但是,如果您查看下面的示例,您会注意到我们不仅age用关键字声明了对象,而且这次还用typevar明确地注释了对象的数据类型。基本思想是,当我们使用变量/可变对象时,Zig 编译器要求我们更明确地说明我们想要什么,更清楚地说明我们的代码的作用。这意味着我们需要更明确地说明我们想要在对象中使用的数据类型。age``u8

因此,如果将对象转换为变量/可变对象,请务必记住在代码中显式注释对象的类型。否则,Zig 编译器可能会引发编译错误,要求您将对象转换回对象const,或者赋予对象一个“显式类型”。

var age: u8 = 24;
age = 25;

1.4.2不带初始值的声明

默认情况下,在 Zig 中声明新对象时,必须赋予其初始值。换句话说,这意味着我们必须声明并同时初始化在源代码中创建的每个对象。

另一方面,你实际上可以在源代码中声明一个新对象,而不赋予它显式的值。但我们需要为此使用一个特殊的关键字,即undefined关键字。

需要强调的是,你应该undefined尽可能避免使用 this 关键字。因为当你使用 this 关键字时,你的对象处于未初始化状态。因此,如果出于某种原因,你的代码在未初始化的情况下使用了该对象,那么你的程序中肯定会出现未定义的行为和重大错误。

在下面的示例中,我age再次声明了该对象。但这次,我没有赋予它初始值。该变量仅在第二行代码中初始化,我将数字 25 存储在该对象中。

var age: u8 = undefined;
age = 25;

undefined记住这些要点,只需记住在代码中尽可能避免使用关键字。务必声明并初始化对象。因为这会大大提高程序的安全性。但如果您确实需要声明一个对象而不进行初始化……那么undefined在 Zig 中,关键字是实现此目的的方法。

1.4.3不存在未使用的对象

在 Zig 中声明的每个对象(常量或变量)都必须以某种方式使用。您可以将此对象作为函数参数提供给函数调用,或者,您可以在另一个表达式中使用它来计算另一个对象的值,或者,您可以调用属于此特定对象的方法。

无论你以何种方式使用它,只要你使用它就行。如果你试图违反此规则,例如,如果你试图声明一个对象但不使用它,zig编译器将不会编译你的 Zig 源代码,并且会发出一条错误消息,警告你代码中存在未使用的对象。

让我们用一个例子来证明这一点。在下面的源代码中,我们声明了一个名为的常量对象age。如果你尝试用下面这行代码编译一个简单的 Zig 程序,编译器将返回一个错误,如下所示:

const age = 15;
t.zig:4:11: error: unused local constant
    const age = 15;
          ^~~

每次在 Zig 中声明一个新对象时,您有两个选择:

  1. 您要么使用这个对象的值;
  2. 或者你明确地丢弃该对象的值;

要显式地丢弃任何对象(常量或变量)的值,您只需将该对象分配给 Zig 中的特殊字符,即下划线 ( _)。当您将对象分配给下划线时(如下例所示),zig编译器将自动丢弃此特定对象的值。

您可以在下面的示例中看到,这一次,编译器没有抱怨任何“未使用的常量”,并成功编译了我们的源代码。

// It compiles!
const age = 15;
_ = age;

现在,请记住,每次将特定对象赋值给下划线时,该对象实际上都会被销毁。它会被编译器丢弃。这意味着你不能再在代码中使用该对象。它已经不存在了。

因此,如果您尝试在下面的示例中使用该常量age,在我们丢弃它之后,您将从编译器收到一条响亮的错误消息(谈论“无意义的丢弃”),警告您有关此错误。

// It does not compile.
const age = 15;
_ = age;
// Using a discarded value!
std.debug.print("{d}\n", .{age + 2});
t.zig:7:5: error: pointless discard
    of local constant

同样的规则也适用于变量对象。每个变量对象也必须以某种方式使用。如果你将一个变量对象赋值给下划线,这个对象也会被丢弃,你将无法再使用它。

1.4.4必须改变每个变量对象

你在源代码中创建的每个变量对象都必须在某个时刻被修改。换句话说,如果你使用关键字 将一个对象声明为变量对象,var并且在未来某个时刻没有更改该对象的值,zig编译器就会检测到这种情况,并抛出一个错误,警告你这个错误。

其背后的概念是,您在 Zig 中创建的每个对象最好都是常量对象,除非您确实需要一个在程序执行期间其值会发生变化的对象。

因此,如果我尝试声明如下所示的变量对象where_i_live,并且我没有以某种方式更改该对象的值,则zig编译器会引发一条错误消息,并显示“变量永远不会变异”。

var where_i_live = "Belo Horizonte";
_ = where_i_live;
t.zig:7:5: error: local variable is never mutated
t.zig:7:5: note: consider using 'const'

1.5原始数据类型

Zig 有许多不同的原始数据类型可供使用。您可以在官方语言参考第 21页查看可用数据类型的完整列表。

但这里有一个简短的列表:

  • 无符号整数:u8,8位整数;u16,16位整数;u32,32位整数;u64,64位整数;u128,128位整数。
  • 有符号整数:i8,8位整数;i16,16位整数;i32,32位整数;i64,64位整数;i128,128位整数。
  • 浮点数:f16,16位浮点数;f32,32位浮点数;f64,64位浮点数;f128,128位浮点数;
  • 布尔值:bool,表示真值或假值。
  • C ABI 兼容类型:c_long、、、、、、以及许多其他类型c_charc_short``c_ushort``c_int``c_uint
  • 指针大小的整数:isizeusize

1.6数组

在 Zig 中,您可以使用类似于 C 语言的语法创建数组。首先,在括号内指定要创建的数组的大小(即数组中存储的元素数量)。

然后,指定将存储在此数组中的元素的数据类型。Zig 中,数组中存在的所有元素必须具有相同的数据类型。例如,不能在同一个数组中混合使用 类型的元素f32和 类型的元素。i32

之后,只需将要存储在此数组中的值用一对花括号括起来即可。在下面的示例中,我创建了两个包含不同数组的常量对象。第一个对象包含一个包含 4 个整数值的数组,而第二个对象包含一个包含 3 个浮点值的数组。

现在,您应该注意到,在 object 中ls,我没有在括号内明确指定数组的大小。ns我没有使用字面值(例如我在 object 中使用的值 4),而是使用了特殊字符下划线 ( _)。此语法告诉zig编译器用花括号内列出的元素数量来填充此字段。因此,这种语法[_]适合那些懒惰(或聪明)的程序员,他们将计算花括号内元素数量的工作留给了编译器。

const ns = [4]u8{48, 24, 12, 6};
const ls = [_]f64{432.1, 87.2, 900.05};
_ = ns; _ = ls;

值得注意的是,这些是静态数组,这意味着它们的大小无法增长。一旦声明了数组,就无法更改其大小。这在低级语言中很常见。因为低级语言通常希望将内存的完全控制权交给程序员,而数组的扩展方式与内存管理紧密相关。

1.6.1选择数组元素

一种非常常见的操作是选择源代码中数组的特定部分。在 Zig 中,您可以从数组中选择特定元素,只需在对象名称后的括号内提供该特定元素的索引即可。在下面的示例中,我将从ns数组中选择第三个元素。请注意,Zig 是一种基于“零索引”的语言,类似于 C、C++、Rust、Python 和许多其他语言。

const ns = [4]u8{48, 24, 12, 6};
try stdout.print("{d}\n", .{ ns[2] });
12

相反,您也可以使用范围选择器来选择数组的特定切片(或部分)。一些程序员也将这些选择器称为“切片选择器”,它们也存在于 Rust 中,并且具有与 Zig 完全相同的语法。无论如何,范围选择器是 Zig 中的一种特殊表达式,它定义了索引范围,其语法为start..end

在下面示例中,第二行代码中,sl对象存储了数组的一个切片(或一部分)ns。更准确地说,是数组中索引 1 和 2 处的元素ns

const ns = [4]u8{48, 24, 12, 6};
const sl = ns[1..3];
_ = sl;

使用该start..end语法时,范围选择器的“末尾”是不包含的,这意味着末尾的索引不包含在从数组中选择的范围中。因此,该语法start..end实际上意味着start..end - 1

例如,您可以使用语法创建一个从数组的第一个元素到最后一个元素的切片,ar[0..ar.len]换句话说,它是一个访问数组中所有元素的切片。

const ar = [4]u8{48, 24, 12, 6};
const sl = ar[0..ar.len];
_ = sl;

start..您还可以在范围选择器中使用该语法。该语法指示zig编译器选择从索引开始到数组最后一个元素的数组部分start。在下面的示例中,我们选择从索引 1 到数组末尾的范围。

const ns = [4]u8{48, 24, 12, 6};
const sl = ns[1..];
_ = sl;

1.6.2关于切片的更多信息

正如我们之前讨论过的,在 Zig 中,您可以选择现有数组的特定部分。这在 Zig 中称为 切片Sobeston 2024 ),因为当您选择数组的一部分时,您将从该数组创建一个切片对象。

切片对象本质上是一个指针对象,并附带一个长度数字。指针对象指向切片中的第一个元素,长度数字则告诉zig编译器此切片中有多少个元素。

切片可以被认为是一对[*]T(指向数据的指针)和一个usize(元素计数)(Sobeston 2024)。

通过切片内部的指针,您可以访问从原始数组中选择的范围(或部分)内的元素(或值)。但长度值(您可以通过len切片对象的属性访问)才是 Zig 带来的真正重大改进(例如,相对于 C 数组而言)。

因为有了这个长度数字,zig编译器就可以轻松地检查你是否试图访问超出此特定切片范围的索引,或者是否导致了任何缓冲区溢出问题。在下面的示例中,我们访问了len切片的属性sl,它告诉我们该切片包含 2 个元素。

const ns = [4]u8{48, 24, 12, 6};
const sl = ns[1..3];
try stdout.print("{d}\n", .{sl.len});
2

1.6.3数组运算符

Zig 中有两个非常有用的数组运算符:数组连接运算符(++)和数组乘法运算符(**)。顾名思义,它们都是数组运算符。

关于这两个运算符的一个重要细节是,它们只有当两个操作数的大小(或“长度”)在编译时已知时才有效。我们将在第 3.1.1 节中详细讨论“编译时已知”和“运行时已知”之间的区别。但现在,请记住,并非在所有情况下都可以使用这些运算符。

总而言之,该++运算符创建一个新数组,该数组是两个作为操作数的数组的连接。因此,该表达式a ++ b生成一个新数组,其中包含数组ab的所有元素。

const a = [_]u8{1,2,3};
const b = [_]u8{4,5};
const c = a ++ b;
try stdout.print("{any}\n", .{c});
{ 1, 2, 3, 4, 5 }

此运算符对于连接字符串特别有用。Zig 中的字符串在1.8 节++中有深入描述。总而言之,Zig 中的字符串对象本质上是一个字节数组。因此,您可以使用此数组连接运算符有效地将字符串连接在一起。

相反,**运算符用于多次复制数组。换句话说,该表达式创建一个新数组,其中包含重复 3 次a ** 3的数组元素。a

const a = [_]u8{1,2,3};
const c = a ** 2;
try stdout.print("{any}\n", .{c});
{ 1, 2, 3, 1, 2, 3 }

1.6.4切片的运行时与编译时已知长度

我们将在本书中大量讨论编译时已知和运行时已知之间的区别,尤其是在第 3.1.1 节中。但基本思想是,当我们在编译时知道某个事物的所有信息(值、属性和特性)时,该事物就是编译时已知的。相反,运行时已知是指该事物的确切值仅在运行时计算。因此,我们无法在编译时知道该事物的值,只能在运行时知道。

我们在1.6.1 节中了解到,切片是使用_范围选择器_创建的,该选择器表示一个索引范围。当这个“索引范围”(即范围的起始和结束)在编译时已知时,创建的切片对象实际上只是一个指向数组的单项指针。

你现在不需要精确理解这意味着什么。我们将在第六章详细讨论指针。现在,你只需要理解,当索引范围在编译时已知时,创建的切片只是一个指向数组的指针,并附带一个表示切片大小的长度值。

如果你有一个像这样的切片对象,即一个编译时已知范围的切片,你可以对这个切片对象使用常见的指针操作。例如,你可以使用.*方法来取消引用这个切片的指针,就像对普通指针对象执行的操作一样。

const arr1 = [10]u64 {
    1, 2, 3, 4, 5,
    6, 7, 8, 9, 10
};
// This slice has a compile-time known range.
// Because we know both the start and end of the range.
const slice = arr1[1..4];
_ = slice;

另一方面,如果在编译时不知道索引的范围,那么创建的切片对象就不再是指针,因此它不支持指针操作。例如,起始索引可能在编译时已知,但结束索引未知。在这种情况下,切片的范围将变为仅在运行时才可知。

在下面的示例中,我们正在读取一个文件,然后尝试创建一个切片对象,该对象覆盖包含该文件内容的整个缓冲区。这显然是一个运行时已知范围的示例,因为该范围的结束索引在编译时是未知的。

换句话说,范围的结束索引就是数组的大小file_contents。然而,的大小file_contents在编译时是未知的。因为我们不知道这个shop-list.txt文件里面存储了多少字节。而且,由于这是一个文件,明天可能会有人编辑这个文件,添加或删除行。因此,这个文件的大小在每次执行时可能会有很大差异。

现在,如果文件大小在每次运行中都会有所不同,那么我们可以得出结论,file_contents.len下面示例中显示的表达式的值在每次运行中也会有所不同。因此,表达式的值file_contents.len仅在运行时才可知,因此,其范围0..file_contents.len也仅在运行时才可知。

const std = @import("std");
const builtin = @import("builtin");

fn read_file(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();
    return try file.reader().readAllAlloc(
        allocator, std.math.maxInt(usize)
    );
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    const path = "../ZigExamples/file-io/shop-list.txt";
    const file_contents = try read_file(allocator, path);
    const slice = file_contents[0..file_contents.len];
    _ = slice;
}

1.7块和作用域

在 Zig 中,块由一对花括号创建。块只是包含在一对花括号内的一组表达式(或语句)。包含在这对花括号内的所有这些表达式都属于同一作用域。

换句话说,块只是在代码中划定了一个作用域。在同一个块内定义的对象属于同一个作用域,因此可以在该作用域内访问。同时,这些对象在该作用域之外无法访问。所以,你也可以说块用于限制你在源代码中创建的对象的作用域。用通俗的术语来说,块用于指定在源代码中可以访问任何对象的位置。

所以,代码块只是包含在一对花括号内的一组表达式。每个代码块都有其独立的范围,与其他代码块相隔离。函数体就是一个典型的代码块示例。if 语句、for 和 while 循环(以及该语言中任何其他使用花括号的结构)也是代码块的示例。

这意味着,你在源代码中创建的每个 if 语句、for 循环等都有其各自独立的作用域。这就是为什么你无法在外部作用域(即 for 循环之外的作用域)访问你在 for 循环(或 if 语句)内部定义的对象。因为你试图访问的对象所属的作用域与你当前作用域不同。

您可以在块内创建块,并具有多层嵌套。您还可以(如果需要)使用冒号 ( :) 为特定块添加标签。只需label:在打开分隔块的花括号之前写入即可。在 Zig 中标记块时,可以使用break关键字从该块返回一个值,就像它是函数的主体一样。您只需编写break关键字,后跟格式为 的块标签:label,以及定义要返回值的表达式。

就像下面的例子一样,我们y从块中返回对象的值add_one,并将结果保存在x对象内部。

var y: i32 = 123;
const x = add_one: {
    y += 1;
    break :add_one y;
};
if (x == 124 and y == 124) {
    try stdout.print("Hey!", .{});
}
Hey!

1.8字符串在 Zig 中如何工作?

本书将要构建和讨论的第一个项目是 Base64 编码器/解码器(第 4 章)。但为了构建这样一个东西,我们需要更好地理解字符串在 Zig 中的工作方式。因此,让我们来讨论一下 Zig 的这个具体方面。

Zig 中的字符串与 C 语言中的字符串非常相似,但它们有一些额外的注意事项,从而提高了安全性和效率。您也可以说 Zig 只是使用了一种更现代、更安全的方法来管理和使用字符串。

Zig 中的字符串本质上是一个任意字节的数组,或者更具体地说,是一个u8值数组。这与 C 语言中的字符串非常相似,它也被解释为任意字节的数组,或者在 C 语言中,是一个值数组char(在大多数系统中通常表示一个无符号的 8 位整数值)。

现在,由于 Zig 中的字符串是一个数组,因此您可以自动获取嵌入在值本身中的字符串长度(即数组的长度)。这至关重要!因为现在,Zig 编译器可以使用嵌入在字符串中的长度值来检查代码中是否存在“缓冲区溢出”或“错误的内存访问”问题。

要在 C 语言中实现同样的安全性,你必须做很多看似毫无意义的工作。所以,在 C 语言中实现这种安全性并非自动实现,而且难度更大。例如,如果你想在 C 语言程序中跟踪字符串的长度,那么首先需要循环遍历表示该字符串的字节数组,找到空元素('\0')的位置,从而确定数组的确切结束位置,或者换句话说,找出字节数组包含多少个元素。

为此,您需要在 C 中执行类似这样的操作。在此示例中,存储在对象中的 C 字符串array长为 25 个字节:

#include <stdio.h>
int main() {
    char* array = "An example of string in C";
    int index = 0;
    while (1) {
        if (array[index] == '\0') {
            break;
        }
        index++;
    }
    printf("Number of elements in the array: %d\n", index);
}
Number of elements in the array: 25

在 Zig 中,你不需要做这样的工作。因为字符串的长度始终存在,并且可以在字符串值本身中访问。你可以通过len属性轻松访问字符串的长度。例如,string_object下面的对象长度为 43 个字节:

const std = @import("std");
const stdout = std.io.getStdOut().writer();
pub fn main() !void {
    const string_object = "This is an example of string literal in Zig";
    try stdout.print("{d}\n", .{string_object.len});
}
43

另一点是,Zig 始终假设字符串中的字节序列是 UTF-8 编码的。对于您处理的每个字节序列,这可能并非如此,但 Zig 的真正职责并非修复字符串的编码(您可以使用22来解决这个问题)。如今,现代世界中的大多数文本,尤其是在网络上,都应该采用 UTF-8 编码。因此,如果您的字符串文字不是 UTF-8 编码的,那么您在 Zig 中可能会遇到问题。iconv

以单词“Hello”为例。在UTF-8编码中,该字符序列(H,e,l,l,o)用十进制数序列72、101、108、108、111表示。在十六进制中,该序列为,,,,,0x48。因此,如果我采用这个十六进制值序列,并要求Zig将此字节序列打印为字符序列(即字符串),那么文本“Hello”将打印到终端中:0x65``0x6C``0x6C``0x6F

const std = @import("std");
const stdout = std.io.getStdOut().writer();

pub fn main() !void {
    const bytes = [_]u8{0x48, 0x65, 0x6C, 0x6C, 0x6F};
    try stdout.print("{s}\n", .{bytes});
}
Hello

1.8.1使用切片与标记终止数组

在内存中,Zig 中的所有字符串值始终以相同的方式存储。它们只是存储为任意字节的序列/数组。但是,您可以通过两种不同的方式使用和访问此字节序列。您可以通过以下方式访问此字节序列:

  • 以标记终止的值数组u8
  • 或者作为价值观的一部分u8

1.8.1.1哨兵终止数组

Zig 中的哨兵终止数组在 Zig 23语言参考中进行了描述。总而言之,哨兵终止数组只是一个普通数组,但不同之处在于它们在数组的最后一个索引/元素处包含一个“哨兵值”。使用哨兵终止数组,您可以将数组的长度和哨兵值嵌入到对象的类型本身中。

例如,如果您在代码中写入一个字符串文字值,并要求 Zig 打印该值的数据类型,通常会得到以下格式的数据类型*const [n:0]u8n数据类型中的 表示字符串的大小(即数组的长度)。数据类型部分后面的零n:是标记值本身。

// This is a string literal value:
_ = "A literal value";
try stdout.print("{any}\n", .{@TypeOf("A literal value")});
*const [15:0]u8

因此,使用这种数据类型,*const [n:0]u8本质上就是说你有一个u8长度为的数组n,其中,与数组长度对应的索引处的元素n是数字零。如果你认真思考这个描述,你会发现这只是在 C 语言中描述字符串的一种奇特方式,字符串是一个以空值结尾的字节数组。CNULL语言中的值是数字零。因此,在 C 语言中以空值/零值结尾的数组本质上是 Zig 语言中的标记终止数组,其中数组的标记值是数字零。

因此,Zig 中的字符串文字值只是一个指向以空字符结尾的字节数组的指针(即类似于 C 字符串)。但在 Zig 中,字符串文字值还将字符串的长度以及它们以“空字符结尾”的事实嵌入到值本身的数据类型中。

1.8.1.2切片

您还可以访问和使用将字符串表示为值切片的任意字节序列u8。Zig 标准库中的大多数函数通常以值切片的形式接收字符串作为输入(切片在1.6 节u8中介绍)。

因此,您会看到许多数据类型为[]u8或 的字符串值[]const u8,具体取决于存储此字符串的对象是用 标记为常量const,还是用 标记为变量var。现在,由于本例中的字符串被解释为切片,因此此切片不一定以空值结尾,因为现在标记值不再是必需的。您可以根据需要在切片中包含空值/零值,但没有必要这样做。

// This is a string value being
// interpreted as a slice.
const str: []const u8 = "A string value";
try stdout.print("{any}\n", .{@TypeOf(str)});
[]const u8

1.8.2遍历字符串

如果要查看 Zig 中表示字符串的实际字节数,可以使用for循环遍历字符串中的每个字节,并要求 Zig 将每个字节以十六进制值的形式打印到终端。您可以使用print()带有X格式说明符的语句来执行此操作,就像通常使用C 语言中的printf()函数24一样。

const std = @import("std");
const stdout = std.io.getStdOut().writer();
pub fn main() !void {
    const string_object = "This is an example";
    try stdout.print("Bytes that represents the string object: ", .{});
    for (string_object) |byte| {
        try stdout.print("{X} ", .{byte});
    }
    try stdout.print("\n", .{});
}
Bytes that represents the string object: 54 68 69 
   73 20 69 73 20 61 6E 20 65 78 61 6D 70 6C 65 

1.8.3更好地了解对象类型

现在,我们可以更好地检查 Zig 创建的对象类型。要检查 Zig 中任何对象的类型,可以使用该@TypeOf()函数。如果我们查看下面的对象类型simple_array,您会发现该对象是一个包含 4 个元素的数组。每个元素都是一个 32 位有符号整数,与 Zig 中的数据类型相对应i32。这就是类型对象的含义[4]i32

但是,如果我们仔细观察下面显示的字符串字面值的类型,就会发现它是一个*const指向 16 个元素(或 16 个字节)数组的常量指针(因此有注释)。每个元素都是一个字节(更准确地说,是一个无符号的 8 位整数 - u8),这就是为什么我们有[16:0]u8下面的类型部分。此外,由于数据类型中字符后面的零值,您还可以看到这是一个以空字符结尾的数组:。换句话说,下面显示的字符串字面值长度为 16 个字节。

现在,如果我们创建一个指向该simple_array对象的指针,那么我们将得到一个指向 4 个元素的数组的常量指针(*const [4]i32),这与字符串文字值的类型非常相似。这表明 Zig 中的字符串文字值已经是一个指向以空字符结尾的字节数组的指针。

此外,如果我们看一下string_obj对象的类型,您将看到它是一个切片对象(因此是[]该类型的部分),是一系列常u8量值(因此是const u8该类型的部分)。

const std = @import("std");
pub fn main() !void {
    const simple_array = [_]i32{1, 2, 3, 4};
    const string_obj: []const u8 = "A string object";
    std.debug.print(
        "Type 1: {}\n", .{@TypeOf(simple_array)}
    );
    std.debug.print(
        "Type 2: {}\n", .{@TypeOf("A string literal")}
    );
    std.debug.print(
        "Type 3: {}\n", .{@TypeOf(&simple_array)}
    );
    std.debug.print(
        "Type 4: {}\n", .{@TypeOf(string_obj)}
    );
}
Type 1: [4]i32
Type 2: *const [16:0]u8
Type 3: *const [4]i32
Type 4: []const u8

1.8.4字节与unicode点

需要指出的是,数组中的每个字节不一定代表一个字符。这是由于单个字节和单个 Unicode 点之间的差异造成的。

UTF-8 编码的原理是为字符串中的每个字符分配一个数字(称为 Unicode 点)。例如,字符“H”在 UTF-8 中存储为十进制数 72。这意味着数字 72 是字符“H”的 Unicode 点。UTF-8 编码字符串中可能出现的每个字符都有其自己的 Unicode 点。

例如,带删除线的拉丁大写字母 A(Ⱥ)用数字(或 unicode 点)570 表示。但是,这个十进制数(570)大于单个字节内存储的最大数字 255。换句话说,用单个字节可以表示的最大十进制数是 255。这就是为什么 unicode 点 570 实际上以字节的形式存储在计算机内存中的原因C8 BA

const std = @import("std");
const stdout = std.io.getStdOut().writer();
pub fn main() !void {
    const string_object = "Ⱥ";
    _ = try stdout.write(
        "Bytes that represents the string object: "
    );
    for (string_object) |char| {
        try stdout.print("{X} ", .{char});
    }
}
Bytes that represents the string object: C8 BA 

这意味着要将字符 Ⱥ 存储在 UTF-8 编码的字符串中,我们需要使用两个字节一起来表示数字 570。这就是为什么字节和 unicode 点之间的关系并不总是 1 对 1 的原因。每个 unicode 点都是字符串中的单个字符,但单个字节并不总是对应单个 unicode 点。

所有这些意味着,如果你在 Zig 中循环遍历字符串的元素,你将循环遍历表示该字符串的字节,而不是该字符串的字符。在上面的 Ⱥ 示例中,for 循环需要两次迭代(而不是一次迭代)才能打印出表示这个 Ⱥ 字母的两个字节。

现在,所有英文字母(或者如果你愿意,也可以是 ASCII 字母)都可以用 UTF-8 的一个字节表示。因此,如果你的 UTF-8 字符串只包含英文字母(或 ASCII 字母),那么你很幸运。因为字节数等于该字符串中的字符数。换句话说,在这种特定情况下,字节数和 Unicode 点数的关系是 1:1。

但另一方面,如果您的字符串包含其他类型的字母……例如,您可能正在处理包含中文、日文或拉丁字母的文本数据,那么表示 UTF-8 字符串所需的字节数可能会比该字符串中的字符数高得多。

如果您需要遍历字符串的字符而不是字节,那么您可以使用std.unicode.Utf8View结构来创建一个遍历字符串的 unicode 点的迭代器。

在下面的例子中,我们循环遍历日文字符“アメリカ”。此字符串中的四个字符每个都由三个字节表示。但 for 循环迭代了四次,每个字符/unicode 点迭代一次:

const std = @import("std");
const stdout = std.io.getStdOut().writer();

pub fn main() !void {
    var utf8 = try std.unicode.Utf8View.init("アメリカ");
    var iterator = utf8.iterator();
    while (iterator.nextCodepointSlice()) |codepoint| {
        try stdout.print(
            "got codepoint {}\n",
            .{std.fmt.fmtSliceHexUpper(codepoint)},
        );
    }
}
got codepoint E382A2
got codepoint E383A1
got codepoint E383AA
got codepoint E382AB

1.8.5一些有用的字符串函数

在本节中,我只想快速描述Zig标准库中的一些函数,这些函数在处理字符串时非常有用。最值得注意的是:

  • std.mem.eql():比较两个字符串是否相等。
  • std.mem.splitScalar():根据给定的分隔符值将字符串拆分为子字符串数组。
  • std.mem.splitSequence():根据给定的子字符串分隔符将字符串拆分为子字符串数组。
  • std.mem.startsWith():检查字符串是否以子字符串开头。
  • std.mem.endsWith():检查字符串是否以子字符串结尾。
  • std.mem.trim():从字符串的开头和结尾删除特定值。
  • std.mem.concat():将字符串连接在一起。
  • std.mem.count():统计字符串中子字符串的出现次数。
  • std.mem.replace():替换字符串中出现的子字符串。

请注意,所有这些函数都来自memZig 标准库的模块。该模块包含多个函数和方法,通常可用于处理内存和字节序列。

eql()函数用于检查两个数据数组是否相等。由于字符串只是任意的字节数组,因此我们可以使用此函数比较两个字符串。该函数返回一个布尔值,指示两个字符串是否相等。该函数的第一个参数是被比较数组元素的数据类型。

const name: []const u8 = "Pedro";
try stdout.print(
    "{any}\n", .{std.mem.eql(u8, name, "Pedro")}
);
true

splitScalar()和函数splitSequence()可用于将字符串拆分成多个片段,类似于split()Python 字符串中的 方法。这两种方法的区别在于, 函数splitScalar()使用单个字符作为分隔符来拆分字符串,而 函数则splitSequence()使用字符序列(也称为子字符串)作为分隔符。本书后面会提供一个关于这两个函数的实际示例。

startsWith()和函数endsWith()非常简单。它们返回一个布尔值,指示字符串(或者更准确地说,数据数组)是否以提供的序列开始( startsWith)或结束( )。endsWith

const name: []const u8 = "Pedro";
try stdout.print(
    "{any}\n", .{std.mem.startsWith(u8, name, "Pe")}
);
true

concat()顾名思义,该函数用于将两个或多个字符串连接在一起。由于连接字符串的过程需要分配足够的空间来容纳所有字符串,因此该concat()函数接收一个分配器对象作为输入。

const str1 = "Hello";
const str2 = " you!";
const str3 = try std.mem.concat(
    allocator, u8, &[_][]const u8{ str1, str2 }
);
try stdout.print("{s}\n", .{str3});

可以想象,该replace()函数用于将字符串中的子字符串替换为另一个子字符串。该函数的工作原理与replace()Python 字符串中的方法非常相似。因此,您需要提供一个要搜索的子字符串,每当函数replace()在输入字符串中找到此子字符串时,它都会用您提供的“替换子字符串”替换此子字符串。

在下面的示例中,我们取输入字符串“Hello”,并将其中所有出现的子字符串“el”替换为“34”,并将结果保存在buffer对象中。结果,该replace()函数返回一个usize值,指示执行了多少次替换。

const str1 = "Hello";
var buffer: [5]u8 = undefined;
const nrep = std.mem.replace(
    u8, str1, "el", "34", buffer[0..]
);
try stdout.print("New string: {s}\n", .{buffer});
try stdout.print("N of replacements: {d}\n", .{nrep});
New string: H34lo
N of replacements: 1

1.9 Zig 的安全性

现代低级编程语言的普遍趋势是安全性。随着现代世界与科技和计算机的联系日益紧密,所有这些技术产生的数据已成为我们拥有的最重要(也是最危险)的资产之一。

这或许是现代低级编程语言高度重视安全性(尤其是内存安全性)的主要原因,因为内存损坏仍然是黑客攻击的主要目标。事实上,我们并没有简单的解决方案。目前,我们只有一些技术和策略来缓解这些问题。

正如理查德·费尔德曼在他最近的 GOTO 大会演讲25中所解释的那样,我们还没有找到一种在技术上实现真正安全的方法。换句话说,我们还没有找到一种方法来构建 100% 确定不会被利用的软件。我们可以通过例如确保内存安全来大幅降低软件被利用的风险。但这还不足以达到“真正的安全”境界。

因为即使你用“安全语言”编写程序,黑客仍然可以利用程序所在操作系统的漏洞(例如,运行代码的系统可能存在“后门漏洞”,仍然可能以意想不到的方式影响你的代码),或者,他们还可以利用计算机架构的特性。最近发现的一个漏洞利用 ARM 芯片中的“内存标签”特性来使内存失效,就是一个例子(Kim 等人,2024 年)。

问题是:Zig 和其他语言做了什么来缓解这个问题?以 Rust 为例,Rust 在大多数情况下,通过强制开发人员遵守特定规则,是一种内存安全的语言。换句话说,Rust 的关键特性——借用检查器,强制你在编写 Rust 代码时遵循特定的逻辑,而每次你试图摆脱这种模式时,Rust 编译器都会报错。

相比之下,Zig 语言默认并非内存安全语言。Zig 中有一些内存安全特性是免费的,尤其是在数组和指针对象中。但该语言还提供了一些默认不使用的工具。换句话说,zig编译器并不强制你使用这些工具。

下面列出的工具与内存安全相关。也就是说,它们可以帮助您在 Zig 代码中实现内存安全:

  • defer允许您将释放操作在物理上靠近分配操作。这有助于避免内存泄漏、“释放后使用”以及“重复释放”问题。此外,它还将释放操作在逻辑上与当前作用域的末尾绑定,从而大大减少了对象生命周期相关的心理开销。
  • errdefer帮助您保证您的程序释放分配的内存,即使发生运行时错误。
  • 默认情况下,指针和对象不可为空。这有助于避免因取消引用空指针而可能引起的内存问题。
  • Zig 提供了一些原生类型的分配器(称为“测试分配器”),可以检测内存泄漏和双重释放。这些类型的分配器在单元测试中被广泛使用,因此它们将单元测试转变为一种可以用来检测代码中内存问题的武器。
  • Zig 中的数组和切片的长度嵌入在对象本身中,这使得zig编译器能够非常有效地检测“索引超出范围”类型的错误,并避免缓冲区溢出。

尽管 Zig 提供的这些功能与内存安全问题相关,但该语言也有一些规则可以帮助您实现另一种类型的安全性,这种安全性与程序逻辑安全性更相关。这些规则是:

  • 默认情况下,指针和对象不可为空。这消除了可能破坏程序逻辑的极端情况。
  • switch 语句必须穷尽所有可能的选项。
  • 编译zig器强制您处理程序中所有可能的错误。

1.10 Zig 的其他部分

我们已经学习了很多关于 Zig 的语法,以及一些相当技术性的细节。简单回顾一下:

但就目前而言,这些知识足以让我们继续阅读本书。之后,在接下来的章节中,我们还会进一步讨论 Zig 语法中其他同样重要的部分。例如:


  1. https://ziglang.org/learn/overview/#zig-build-system↩︎
  2. https://zig.news/edyu/zig-package-manager-wtf-is-zon-558e ↩︎
  3. https://medium.com/@edlyuu/zig-package-manager-2-wtf-is-build-zig-zon-and-build-zig-0-11-0-update-5bc46e830fc1 ↩︎
  4. https://github.com/ziglang/zig/blob/master/doc/build.zig.zon.md ↩︎
  5. https://en.wikipedia.org/wiki/List_of_C-family_programming_languages ↩︎
  6. 您可以在文件中看到一个main()返回值的函数示例,https://github.com/pedropark99/zig-book/blob/main/ZigExamples/zig-basics/return-integer.zig ↩︎u8``return-integer.zig
  7. https://github.com/ziglang/zig/issues/17186 ↩︎
  8. https://github.com/ziglang/zig/issues/19864 ↩︎
  9. https://github.com/ziglang/zig/tree/master/lib/std ↩︎
  10. https://github.com/oven-sh/bun↩︎
  11. https://github.com/hexops/mach ↩︎
  12. https://github.com/cgbur/llama2.zig/tree/main ↩︎
  13. https://github.com/tigerbeetle/tigerbeetle ↩︎
  14. https://github.com/Hejsil/zig-clap ↩︎
  15. https://github.com/capy-ui/capy ↩︎
  16. https://github.com/zigtools/zls ↩︎
  17. https://github.com/mitchellh/libxev ↩︎
  18. https://ziglings.org。↩︎
  19. https://www.youtube.com/watch?v=OPuztQfM3Fg&t=2524s&ab_channel=TheVimeagen↩︎
  20. https://adventofcode.com/ ↩︎
  21. https://ziglang.org/documentation/master/#Primitive-Types↩︎
  22. https://www.gnu.org/software/libiconv/ ↩︎
  23. https://ziglang.org/documentation/master/#Sentinel-Termminate-Arrays↩︎
  24. https://cplusplus.com/reference/cstdio/printf/ ↩︎
  25. https://www.youtube.com/watch?v=jIZpKpLCOiU&ab_channel=GOTOConferences ↩︎
  26. 实际上,许多现有的 Rust 代码仍然是内存不安全的,因为它们通过 FFI(外部函数接口unsafe)与外部库通信,这会通过关键字禁用借用检查器功能。↩︎