第1章 Zig 介绍 - Zig 语言入门

第1章 Zig 介绍 - Zig 语言入门

本章将带你走进 Zig 的世界。Zig 是一门非常年轻且处于积极开发中的语言,充满着原始的魅力和待探索的处女地。本书旨在记录我的学习历程,希望能助你探索 Zig 这个激动人心的世界。

阅读本书时,假设你已具备一定的编程经验(如 Python 或 JavaScript)。当然,如果你有 C、C++ 或 Rust 等低级语言的背景,上手速度会更快。

什么是 Zig?

Zig 是一门现代、低级、通用的系统编程语言。许多开发者将其视为 C 语言的现代化改进版。

在我看来,Zig 完美诠释了“少即是多”的理念。它并非通过堆砌新特性来标榜现代,而是通过剔除 C 和 C++ 中那些令人头疼的行为和特性来实现核心改进。换句话说,Zig 致力于通过简化语言,提供更一致、更健壮的行为来提升开发体验。这使得在 Zig 中分析、编写和调试应用程序比 C 或 C++ 更加轻松简单。

Zig 官网的一句标语完美诠释了这一哲学:

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

这句话对 C++ 程序员来说尤为扎心。C++ 是一门庞大的语言,特性繁杂,且存在多种截然不同的“风格”。这些因素导致 C++ 极其复杂,学习曲线陡峭。Zig 则反其道而行之,它追求极致的简单,更接近 C 和 Go 这类简洁的语言。

对 C 程序员而言,这句话同样重要。虽然 C 本身很简单,但阅读和理解 C 代码有时依然困难重重。例如,预处理器宏(Preprocessor Macros)常常是混乱之源,有时会让调试变得异常痛苦。宏本质上是嵌入在 C 中的第二门语言,它模糊了代码的真实面貌。使用宏时,你往往无法 100% 确定编译器最终接收到的代码是什么。

Zig 没有宏。 你写的代码就是编译器编译的代码。Zig 没有幕后的隐藏控制流,标准库中的函数或操作符也不会在背地里进行隐式内存分配。

通过简化语言,Zig 变得更清晰、更易读写,同时也更健壮,在边缘情况下的行为更加一致。再次强调:少即是多。

Zig 的 Hello World

让我们通过一个微型的 “Hello World” 程序开启 Zig 之旅。要在计算机上启动一个新的 Zig 项目,只需使用 zig 编译器的 init 命令。

首先创建一个新目录,然后在其中初始化项目:

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

理解项目文件

运行 init 命令后,当前目录下会生成几个新文件。首先是 src(源码)目录,其中包含 main.zigroot.zig。每个 .zig 文件都是一个独立的 Zig 模块(Module),本质上就是包含 Zig 代码的文本文件。

按照惯例:

  • main.zig:如果你正在构建可执行程序(Executable),这里通常包含程序的入口点 main() 函数。
  • root.zig:如果你正在构建(Library),通常会删除 main.zig 并从 root.zig 开始。它是库的根源文件。

目录结构如下:

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

1 directory, 4 files

此外,根目录下还有两个构建相关的文件:

  • build.zig:这是一个用 Zig 编写的构建脚本。当你运行 zig build 命令时,编译器会执行此脚本。它包含了构建整个项目所需的所有步骤。

    • 在 C/C++ 中,随着项目规模扩大,我们通常需要 CMake、Make 或 Ninja 等独立的构建系统工具。而在 Zig 中,构建系统内置于语言本身。你只需要 Zig 编译器即可构建复杂的项目,无需额外安装和管理第三方构建工具。
  • build.zig.zon:这是一个类似 JSON 的配置文件,用于描述项目元数据以及声明外部依赖。

    • 它的作用类似于 JavaScript 的 package.json、Python 的 Pipfile 或 Rust 的 Cargo.toml
    • 如果某个外部 Zig 库托管在 GitHub 上且包含有效的 build.zig.zon,你可以直接在这里列出它,轻松将其引入你的项目。

root.zig 文件分析

让我们看看 root.zig。你会发现 Zig 的语法深受 C 语言家族影响,例如代码行以分号(;)结尾。

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

export fn add(a: i32, b: i32) i32 {
    return a + b;
}
  • @import("std"):这是一个内置函数,用于导入其他模块。它类似于 C/C++ 的 #include 或 Python/JS 的 import。这里我们导入了 Zig 的标准库 std
  • 赋值:使用 constvar 关键字创建新对象(变量)。例如 const std = ... 创建了一个常量对象。
  • 函数声明:使用 fn 关键字。add 函数接受两个 i32 类型的参数 ab,并返回一个 i32 结果。
  • 类型注解:Zig 是强类型语言。函数参数和返回值必须显式指定类型。语法为 name: type,这与 Rust 类似。
  • export 关键字:类似于 C 中的 extern。它将函数暴露给外部,使其在库的公共 API 中可用。如果你编写的是供他人使用的库,必须使用 export 暴露公共函数。

main.zig 文件分析

现在来看看 main.zig。这里有一些新元素值得注意。

const std = @import("std");

pub fn main() !void {
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;
    try stdout.print("Hello, {s}!\n", .{"world"});
    try stdout.flush();
}
  1. 返回类型 !void

    • main 函数的返回类型前有一个感叹号(!)。这意味着该函数可能返回一个错误
    • Zig 的 main 函数可以返回 void(空)、u8(通常作为状态码)或一个错误联合(Error Union)。
    • 感叹号的作用:如果你在函数体内可能产生错误,你必须要么在返回类型中标记 !,要么在函数内部显式处理该错误。
  2. 错误处理与 try

    • 第 5 行使用了 try 关键字:try stdout.print(...)
    • Zig 的 try 与其他语言的 try-catch 不同。它是一个语法糖:如果表达式返回有效值,try 什么都不做;如果返回错误,try 会立即将该错误从当前函数返回。
    • 这与高级语言(如 Python)的自动异常抛出不同。在 Zig 中,你必须显式决定如何处理每一个可能的错误。
  3. pub 关键字

    • main 函数被标记为 pub(Public)。这意味着它是模块的公共函数,可以被其他模块访问。
    • 在 Zig 中,函数默认是私有的(Private),仅模块内部可见。这与 C/C++ 中 static 函数的概念相反。

编译与运行

1. 编译为可执行文件

使用 build-exe 命令编译单个 Zig 模块:

zig build-exe src/main.zig

编译器会在当前目录生成一个名为 main(Windows 上是 main.exe)的二进制文件。

./main
# 输出: Hello, world!

2. 直接运行

如果你不想手动编译再运行,可以使用 zig run 命令一步到位:

zig run src/main.zig
# 输出: Hello, world!

3. 构建整个项目(推荐)

随着项目复杂度增加,手动输入 build-exe 命令会变得繁琐。利用 Zig 内置的构建系统,我们可以编写 build.zig 脚本来管理构建过程。

只需运行:

zig build

编译器会查找并执行根目录下的 build.zig。构建产物(可执行文件或库)通常会存放在 zig-out/bin 目录下。

./zig-out/bin/hello_world
# 输出: Hello, world!

Windows 用户的特殊说明

在 Windows 上,某些依赖运行时资源的操作(如访问 stdout)如果在**编译时(Comptime)**执行可能会失败,报错 unable to evaluate comptime expression

这是因为 Zig 试图在编译阶段计算全局变量的初始值。如果在全局作用域初始化依赖运行时的对象(如 std.fs.File.stdout()),就会导致问题。

解决方案:将相关对象的初始化移动到 main 函数(或任何运行时函数)内部即可。

const std = @import("std");

// 错误示范:在全局作用域初始化依赖运行时的对象
// var stdout_writer = std.fs.File.stdout().writer(...);

pub fn main() !void {
    // 正确:在运行时初始化
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    // ...
}

如何高效学习 Zig?

除了本书,要想真正精通 Zig,还需要结合其他资源:

  1. 阅读源码:这是学习 Zig 最有效的方法之一。

    • Zig 标准库:位于 Zig 仓库的 lib/std 目录。
    • 优秀开源项目
  2. 实战练习

    • Ziglings:包含 100 多个有“缺陷”的小程序,你需要修复它们。这是熟悉语法的绝佳途径。
    • Advent of Code:尝试用 Zig 解决算法题。
  3. 社区交流

Zig 基础语法速览

变量与常量

在 Zig 中,我们将变量称为“对象”(Object)或“标识符”。

  • const:声明常量(不可变)。一旦赋值,不可修改。
  • var:声明变量(可变)。类似于 Rust 的 let mut
const age = 24;
// age = 25; // 编译错误!不能修改常量

var height: u8 = 175;
height = 176; // 合法

注意:如果声明了 var 变量却从未修改过它,编译器会报错提示你将其改为 const。Zig 鼓励尽可能使用常量。

必须初始化与 undefined

Zig 禁止声明未初始化的变量。你必须在声明时赋值。

如果你暂时无法赋值,可以使用 undefined 关键字。这表示该变量处于“未定义”状态。

var x: i32 = undefined;
x = 100;

警告:使用 undefined 并不安全。如果在赋值前读取该变量,会导致未定义行为(Undefined Behavior)。应谨慎使用。

禁止未使用的变量

Zig 编译器非常严格:声明的变量必须被使用。如果声明了变量却未被引用,编译将失败。

如果你确实需要声明一个变量但暂时不用(例如为了匹配函数签名),可以使用下划线 _ 将其丢弃:

const unused = 10;
_ = unused; // 显式丢弃,消除编译错误

原始数据类型

  • 整数
    • 无符号:u8, u16, u32, u64, u128
    • 有符号:i8, i16, i32, i64, i128
    • 指针大小:usize, isize (取决于平台架构)
  • 浮点数f16, f32, f64, f128
  • 布尔值bool (true/false)
  • C 兼容类型c_int, c_char, c_long 等。

数组与切片 (Slices)

数组 (Arrays)

数组是固定大小的同类型元素集合。

// 显式指定大小 [4]
const ns = [4]u8{48, 24, 12, 6};

// 让编译器推断大小 [_]
const ls = [_]f64{432.1, 87.2, 900.05};
  • 数组大小是类型的一部分。[4]u8[5]u8 是不同的类型。
  • 数组是静态的,一旦声明,大小不可变。

切片 (Slices)

切片是 Zig 中非常强大的概念。它可以看作是指向数组某个部分的动态窗口

切片由两部分组成:

  1. 指针:指向数据的起始位置。
  2. 长度 (len):元素的数量。

形象理解:切片 = [*]T (指针) + usize (长度)。

const ns = [4]u8{48, 24, 12, 6};

// 创建切片:从索引 1 到 3 (不包含 3)
const sl = ns[1..3]; 

try stdout.print("Length: {d}\n", .{sl.len}); // 输出: 2
try stdout.print("First element: {d}\n", .{sl[0]}); // 输出: 24 (即 ns[1])

范围语法 start..end

  • start..end:包含 start,不包含 end。
  • start..:从 start 到数组末尾。
  • 0..end:从开头到 end。

相比 C 语言的指针,Zig 的切片自带长度信息,这让编译器能够进行边界检查,有效防止缓冲区溢出等内存安全问题。

数组操作符

  • ++ (连接):将两个数组拼接成新数组。
  • ** (重复):将数组重复 N 次。

这两个操作符通常要求数组大小在编译时已知

const a = [_]u8{1, 2};
const b = [_]u8{3, 4};
const c = a ++ b; // {1, 2, 3, 4}
const d = a ** 2; // {1, 2, 1, 2}

Zig 中的字符串

Zig 没有像高级语言那样专门的 String 类型。Zig 中的字符串本质上就是字节数组 (u8 数组)。

通常,字符串以两种形式存在:

  1. 字符串字面量 (String Literals)

    • 例如 "Hello"
    • 类型是 哨兵终止的指针*const [N:0]u8
    • 这意味着它是一个指向长度为 N 的常量数组的指针,且数组以 0 (null) 结尾(兼容 C 字符串)。
  2. 字符串切片 (String Slices)

    • 类型是 []const u8
    • 这是处理字符串最常用的形式。它包含指针和长度,但不一定以 0 结尾。
const s1 = "Hello"; // *const [5:0]u8
const s2: []const u8 = "World"; // 强制转换为切片

字符串与 Unicode

Zig 字符串假定为 UTF-8 编码。这意味着:

  • 长度 (len) 是字节数,不是字符数。
  • 对于 ASCII 字符,1 字节 = 1 字符。
  • 对于非 ASCII 字符(如中文、Emoji),1 个字符可能占用多个字节。
const s = "你好";
// s.len 是 6,因为每个汉字在 UTF-8 中占 3 个字节

如果需要按字符(Unicode 代码点)遍历字符串,可以使用 std.unicode.Utf8View

常用字符串函数

Zig 标准库的 std.mem 模块提供了丰富的字符串处理函数:

  • std.mem.eql(u8, s1, s2):比较字符串是否相等。
  • std.mem.startsWith(u8, s, prefix):检查前缀。
  • std.mem.trim(u8, s, " "):去除首尾空白。
  • std.mem.replace(...):替换子串。

Zig 的安全性

在系统编程领域,内存安全是核心议题。

  • Rust 通过借用检查器(Borrow Checker)在编译期强制保证内存安全。
  • Zig 默认不是内存安全的,但它提供了大量工具来辅助编写安全代码:
  1. defer:确保资源(如内存、文件句柄)在作用域结束时被释放/关闭,避免泄漏。
  2. errdefer:仅在发生错误时执行清理操作。
  3. 可选类型 (Optionals):指针默认不可为 null。必须显式使用 ?T 才能容纳 null,这消除了空指针解引用崩溃的隐患。
  4. 边界检查:调试模式下(Debug Mode),数组和切片的访问会自动进行边界检查。
  5. 测试分配器:Zig 提供了能够检测内存泄漏和双重释放的分配器 (std.testing.allocator),让单元测试成为内存检测的利器。

Zig 的哲学是:不隐藏控制流,不隐藏内存分配。它让你完全掌控内存,同时提供工具帮你把控风险。


下章预告:我们将深入探讨 Zig 的控制流(if, for, switch)、结构体(Structs)以及如何实现面向对象编程模式。


原文出处 : https://pedropark99.github.io/zig-book/