1.1 语法8: 控制流
与大多数语言一样, 在 Zig 中,控制流是通过关键字完成的。if
、else if
和 else
都有支持:
// std.mem.eql 将逐字节进行比较,对于字符串来说它是大小写敏感的。
if (std.mem.eql(u8, method, "GET") or std.mem.eql(u8, method, "HEAD")) {
// 处理 GET 请求
} else if (std.mem.eql(u8, method, "POST")) {
// 处理 POST 请求
} else {
// ...
}
虽然没有三元运算符,但可以使用 if/else 来代替:
const super = if (power > 9000) true else false;
switch
语句类似于if/else if/else
,但具有穷举的优点。也就是说,如果没有涵盖所有情况,编译时就会出错。下面这段代码将无法编译:
fn anniversaryName(years_married: u16) []const u8 {
switch (years_married) {
1 => return "paper",
2 => return "cotton",
3 => return "leather",
4 => return "flower",
5 => return "wood",
6 => return "sugar",
}
}
如果修正这个错误呢?我们可以使用 else
修正:
// ...
6 => return "sugar",
else => return "no more gifts for you",
在进行匹配时,我们可以合并多个 case
或使用范围;在进行处理时,可以使用代码块来处理复杂的情况:
fn arrivalTimeDesc(minutes: u16, is_late: bool) []const u8 {
switch (minutes) {
0 => return "arrived",
1, 2 => return "soon",
3...5 => return "no more than 5 minutes",
else => {
if (!is_late) {
return "sorry, it'll be a while";
}
// todo, something is very wrong
return "never";
},
}
}
Zig 的 for
循环用于遍历数组、切片和范围。例如,我们可以这样写:
fn contains(haystack: []const u32, needle: u32) bool {
for (haystack) |value| {
if (needle == value) {
return true;
}
}
return false;
}
for
循环也可以同时处理多个序列,只要这些序列的长度相同。下面是其大致实现:
pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
// if they aren't the same length, they can't be equal
if (a.len != b.len) return false;
for (a, b) |a_elem, b_elem| {
if (a_elem != b_elem) return false;
}
return true;
}
上面这个代码段中,注意 if
检查的设计,这是一个必要的防护措施。如果我们去掉它,并传递不同长度的参数,就会出现运行时 panic
。注意一定要记住:for
在作用于多个序列上时,要求其长度相等。
for
循环也可以遍历范围,例如:
for (0..10) |i| {
std.debug.print("{d}\n", .{i});
}
与一个(或多个)序列组合使用时,它的作用就真正体现出来了:
fn indexOf(haystack: []const u32, needle: u32) ?usize {
for (haystack, 0..) |value, i| {
if (needle == value) {
return i;
}
}
return null;
}
因为 while
比较简单,形式如下:while (condition) { }
,这有利于更好地控制迭代。例如,在计算字符串中转义序列的数量时,我们需要将迭代器递增 2 以避免重复计算 \\
:
var i: usize = 0;
var escape_count: usize = 0;
while (i < src.len) {
if (src[i] == '\\') {
i += 2;
escape_count += 1;
} else {
i += 1;
}
}
while
可以包含 else
子句,当条件为假时执行 else
子句。它还可以接受在每次迭代后要执行的语句。在 for
支持遍历多个序列之前,这一功能很常用。上述语句可写成
var i: usize = 0;
var escape_count: usize = 0;
// 改写后的
while (i < src.len) : (i += 1) {
if (src[i] == '\\') {
// +1 here, and +1 above == +2
// 这里 +1,上面也 +1,相当于 +2
i += 1;
escape_count += 1;
}
}
Zig 也支持 break
和 continue
关键字,用于跳出最内层循环或跳转到下一次迭代。
代码块可以附带标签(label),break
和 continue
可以作用在特定标签上。举例说明:
outer: for (1..10) |i| {
for (i..10) |j| {
if (i * j > (i+i + j+j)) continue :outer;
std.debug.print("{d} + {d} >= {d} * {d}\n", .{i+i, j+j, i, j});
}
}
break
还有另一个有趣的行为,即从代码块中返回值:
const personality_analysis = blk: {
if (tea_vote > coffee_vote) break :blk "sane";
if (tea_vote == coffee_vote) break :blk "whatever";
if (tea_vote < coffee_vote) break :blk "dangerous";
};
像这样有返回值的的块,必须以分号结束。
1.2 语法9:枚举
枚举是带有标签的整数常量。它们的定义很像结构体:
// 可以是 "pub" 的
const Status = enum {
ok,
bad,
unknown,
};
与结构体一样,枚举可以包含其他定义,包括函数,这些函数可以选择性地将枚举作为第一个参数:
const Stage = enum {
validate,
awaiting_confirmation,
confirmed,
completed,
err,
fn isComplete(self: Stage) bool {
return self == .confirmed or self == .err;
}
};
回想一下,结构类型可以使用 .{...}
符号根据其赋值或返回类型来推断。在上面,我们看到枚举类型是根据与 self
的比较推导出来的,而 self
的类型是 Stage
。我们本可以明确地写成:return self == Stage.confirmed
或 self == Stage.err
。但是,在处理枚举时,你经常会看到通过 .$value
这种省略具体类型的情况。
switch
的穷举性质使它能与枚举很好地搭配,因为它能确保你处理了所有可能的情况。不过在使用 switch
的 else
子句时要小心,因为它会匹配任何新添加的枚举值,而这可能不是我们想要的行为。
1.3 语法10:带标签的联合 Tagged Union
联合定义了一个值可以具有的一系列类型。例如,这个 Number
可以是整数、浮点数或 nan(非数字):
const std = @import("std");
pub fn main() void {
const n = Number{.int = 32};
std.debug.print("{d}\n", .{n.int});
}
const Number = union {
int: i64,
float: f64,
nan: void,
};
一个联合一次只能设置一个字段;试图访问一个未设置的字段是错误的。既然我们已经设置了 int
字段,如果我们试图访问 n.float
,就会出错。组合的一个字段 nan
是 void
类型。我们该如何设置它的值呢?使用 {}
:
const n = Number{.nan = {}};
使用联合的一个难题是要知道设置的是哪个字段。这就是带标签的联合发挥作用的地方。带标签的联合将枚举与联合定义在一起,可用于 switch
语句中。请看下面这个例子:
pub fn main() void {
const ts = Timestamp{.unix = 1693278411};
std.debug.print("{d}\n", .{ts.seconds()});
}
const TimestampType = enum {
unix,
datetime,
};
const Timestamp = union(TimestampType) {
unix: i32,
datetime: DateTime,
const DateTime = struct {
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
};
fn seconds(self: Timestamp) u16 {
switch (self) {
.datetime => |dt| return dt.second,
.unix => |ts| {
const seconds_since_midnight: i32 = @rem(ts, 86400);
return @intCast(@rem(seconds_since_midnight, 60));
},
}
}
};
请注意, switch
中的每个分支捕获了字段的类型值。也就是说,dt
是 Timestamp.DateTime
类型,而 ts
是 i32
类型。这也是我们第一次看到嵌套在其他类型中的结构。DateTime
本可以在联合之外定义。我们还看到了两个新的内置函数:@rem
用于获取余数,@intCast
用于将结果转换为 u16
(@intCast
从返回值类型中推断出我们需要 u16
)。
从上面的示例中我们可以看出,带标签的联合的使用有点像接口,只要我们提前知道所有可能的实现,我们就能够将其转化带标签的联合这种形式。
最后,带标签的联合中的枚举类型可以自动推导出来。我们可以直接这样做:
const Timestamp = union(enum) {
unix: i32,
datetime: DateTime,
...
这里 Zig 会根据带标签的联合,自动创建一个隐式枚举。
1.4 语法11:可选类型 Optional
在类型前加上问号 ?
,任何值都可以声明为可选类型。可选类型既可以是 null
,也可以是已定义类型的值:
var home: ?[]const u8 = null;
var name: ?[]const u8 = "Leto";
.?
用于访问可选类型后面的值:
std.debug.print("{s}\n", .{name.?});
但如果在 null
上使用 .?
,运行时就会 panic
。if
语句可以安全地取出可选类型背后的值:
if (home) |h| {
// h is a []const u8
// we have a home value
} else {
// we don't have a home value
}
orelse
可用于提取可选类型的值或执行代码。这通常用于指定默认值或从函数中返回:
const h = home orelse "unknown"
// 或直接返回函数
const h = home orelse return;
可选类型还可以与 while
整合,经常用于创建迭代器。我们这里忽略迭代器的细节,但希望这段伪代码能说明问题:
while (rows.next()) |row| {
// do something with our row
}
1.5 语法12:未定义的值 Undefined
通常这样做的一个地方是创建数组,其值将由某个函数来填充:
var pseudo_uuid: [16]u8 = undefined;
std.crypto.random.bytes(&pseudo_uuid);
上述代码仍然创建了一个 16 字节的数组,但它的每个元素都没有被赋值。
1.6 语法13:错误 Errors
Zig 中错误处理功能十分简单、实用。这一切都从错误集(error sets)开始,错误集的使用方式类似于枚举:
// 与第 1 部分中的结构一样,OpenError 也可以标记为 "pub"。
// 使其可以在其定义的文件之外访问
const OpenError = error {
AccessDenied,
NotFound,
};
任意函数(包括 main
)都可以返回这个错误:
pub fn main() void {
return OpenError.AccessDenied;
}
const OpenError = error {
AccessDenied,
NotFound,
};
如果你尝试运行这个程序,你会得到一个错误:expected type 'void', found 'error{AccessDenied,NotFound}'
。因为我们定义了返回类型为 void
的 main
函数,但我们却返回了另一种东西(很明显,它是一个错误,而不是 void
)。要解决这个问题,我们需要更改函数的返回类型。
pub fn main() OpenError!void {
return OpenError.AccessDenied;
}
这就是所谓的错误联合类型,它表示我们的函数既可以返回 OpenError
错误,也可以返回 void
(也就是什么都没有)。
Zig 能够为我们隐式创建错误集。我们可以这样做,而不需要提前声明:
pub fn main() !void {
return error.AccessDenied;
}
错误联合类型的真正价值在于 Zig 语言提供了 catch
和 try
来处理它们。返回错误联合类型的函数调用时,可以包含一个 catch
子句。例如,一个 http 服务器库的代码可能如下所示:
action(req, res) catch |err| {
if (err == error.BrokenPipe or err == error.ConnectionResetByPeer) {
return;
} else if (err == error.BodyTooBig) {
res.status = 431;
res.body = "Request body is too big";
} else {
res.status = 500;
res.body = "Internal Server Error";
// todo: log err
}
};
switch
的版本更符合惯用法:
action(req, res) catch |err| switch (err) {
error.BrokenPipe, error.ConnectionResetByPeer) => return,
error.BodyTooBig => {
res.status = 431;
res.body = "Request body is too big";
},
else => {
res.status = 500;
res.body = "Internal Server Error";
}
};
老实说,你在 catch
中最有可能做的事情就是把错误信息给调用者:
action(req, res) catch |err| return err;
Zig 提供了 try
关键字用于处理这种情况。上述代码的另一种写法如下:
try action(req, res);
鉴于必须处理错误,这一点尤其有用。多数情况下的做法就是使用 try
或 catch
。
大多数情况下,你都会使用 try
和 catch
,但 if
和 while
也支持错误联合类型,这与可选类型很相似。在 while
的情况下,如果条件返回错误,则执行 else
子句。
有一种特殊的 anyerror
类型可以容纳任何错误。anyerror
主要用在可以是任意错误类型的函数参数或结构体字段中(想象一下日志库)。
使用此类函数有多种方法,但最简洁的方法是使用 try
来解除错误,然后使用 orelse
来解除可选类型。下面是一个大致的模式:
const std = @import("std");
pub fn main() void {
// This is the line you want to focus on
const save = (try Save.loadLast()) orelse Save.blank();
std.debug.print("{any}\n", .{save});
}
pub const Save = struct {
lives: u8,
level: u16,
pub fn loadLast() !?Save {
//todo
return null;
}
pub fn blank() Save {
return .{
.lives = 3,
.level = 1,
};
}
};
1.7 总结
虽然我们还未涉及 Zig 语言中更高级的功能,但我们通过两篇文章也覆盖了常见的语法场景。它们将作为一个基础,让我们能够探索更复杂的话题,而不用被语法所困扰。