第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.zig 和 root.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,你可以直接在这里列出它,轻松将其引入你的项目。
- 它的作用类似于 JavaScript 的
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。- 赋值:使用
const或var关键字创建新对象(变量)。例如const std = ...创建了一个常量对象。 - 函数声明:使用
fn关键字。add函数接受两个i32类型的参数a和b,并返回一个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();
}
-
返回类型
!void:main函数的返回类型前有一个感叹号(!)。这意味着该函数可能返回一个错误。- Zig 的
main函数可以返回void(空)、u8(通常作为状态码)或一个错误联合(Error Union)。 - 感叹号的作用:如果你在函数体内可能产生错误,你必须要么在返回类型中标记
!,要么在函数内部显式处理该错误。
-
错误处理与
try:- 第 5 行使用了
try关键字:try stdout.print(...)。 - Zig 的
try与其他语言的try-catch不同。它是一个语法糖:如果表达式返回有效值,try什么都不做;如果返回错误,try会立即将该错误从当前函数返回。 - 这与高级语言(如 Python)的自动异常抛出不同。在 Zig 中,你必须显式决定如何处理每一个可能的错误。
- 第 5 行使用了
-
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,还需要结合其他资源:
-
阅读源码:这是学习 Zig 最有效的方法之一。
- Zig 标准库:位于 Zig 仓库的
lib/std目录。 - 优秀开源项目:
- Bun (JavaScript 运行时)
- Mach (游戏引擎)
- TigerBeetle (金融数据库)
- ZLS (语言服务器)
- Zig 标准库:位于 Zig 仓库的
-
实战练习:
- Ziglings:包含 100 多个有“缺陷”的小程序,你需要修复它们。这是熟悉语法的绝佳途径。
- Advent of Code:尝试用 Zig 解决算法题。
-
社区交流:
- Ziggit (官方论坛)
- Reddit /r/Zig
- Zig Discord
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 中非常强大的概念。它可以看作是指向数组某个部分的动态窗口。
切片由两部分组成:
- 指针:指向数据的起始位置。
- 长度 (
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 数组)。
通常,字符串以两种形式存在:
-
字符串字面量 (String Literals):
- 例如
"Hello"。 - 类型是 哨兵终止的指针:
*const [N:0]u8。 - 这意味着它是一个指向长度为 N 的常量数组的指针,且数组以
0(null) 结尾(兼容 C 字符串)。
- 例如
-
字符串切片 (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 默认不是内存安全的,但它提供了大量工具来辅助编写安全代码:
defer:确保资源(如内存、文件句柄)在作用域结束时被释放/关闭,避免泄漏。errdefer:仅在发生错误时执行清理操作。- 可选类型 (Optionals):指针默认不可为 null。必须显式使用
?T才能容纳 null,这消除了空指针解引用崩溃的隐患。 - 边界检查:调试模式下(Debug Mode),数组和切片的访问会自动进行边界检查。
- 测试分配器:Zig 提供了能够检测内存泄漏和双重释放的分配器 (
std.testing.allocator),让单元测试成为内存检测的利器。
Zig 的哲学是:不隐藏控制流,不隐藏内存分配。它让你完全掌控内存,同时提供工具帮你把控风险。
下章预告:我们将深入探讨 Zig 的控制流(if, for, switch)、结构体(Structs)以及如何实现面向对象编程模式。