原文地址:https://www.openmymind.net/learning_zig/language_overview_1
Zig 是一种强类型编译语言。它支持泛型,具有强大的编译时元编程功能,并且不包含垃圾收集器。许多人认为 Zig 是 C 的现代替代品。因此,该语言的语法与 C 类似,比较明显的就是以分号结尾的语句和以花括号分隔的块。
Zig 代码如下所示:
const std = @import("std");
// 如果 `main` 不是 `pub` (public),此代码将无法编译
pub fn main() void {
const user = User{
.power = 9001,
.name = "Goku",
};
std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});
}
pub const User = struct {
power: u64,
name: []const u8,
};
如果将上述内容保存到 learning.zig
文件,并运行 zig run learning.zig
,会得到以下输出:Goku's power is 9001
。
这是一个简单的示例,即使你是第一次看到 Zig,大概率能够看懂这段代码。尽管如此,下面的内容我们还是来逐行分析它。
请参阅安装 Zig 部分,以便快速启动并运行它。
很少有程序是在没有标准库或外部库的情况下以单个文件编写的。我们的第一个程序也不例外,它使用 Zig 的标准库来进行打印输出。 Zig 的模块系统非常简单,只依赖于 @import
函数和 pub
关键字(使代码可以在当前文件外部访问)。
以
@
开头的函数是内置函数。它们是由编译器提供的,而不是标准库提供的。
我们通过指定模块名称来引用它。 Zig 的标准库以 std
作为模块名。要引用特定文件,需要使用相对路径。例如,将 User
结构移动到它自己的文件中,比如 models/user.zig
:
// models/user.zig
pub const User = struct {
power: u64,
name: []const u8,
};
在这种情况下,可以用如下方式引用它:
// main.zig
const User = @import("models/user.zig").User;
如果我们的
User
结构未标记为pub
我们会收到以下错误:'User' is not marked 'pub'
。
models/user.zig
可以导出不止一项内容。例如,再导出一个常量:
// models/user.zig
pub const MAX_POWER = 100_000;
pub const User = struct {
power: u64,
name: []const u8,
};
这时,可以这样导入两者:
const user = @import("models/user.zig");
const User = user.User;
const MAX_POWER = user.MAX_POWER;
此时,你可能会有更多的困惑。在上面的代码片段中,user
是什么?我们还没有看到它,如果使用 var
来代替 const
会有什么不同呢?或者你可能想知道如何使用第三方库。这些都是好问题,但要回答这些问题,需要掌握更多 Zig 的知识点。因此,我们现在只需要掌握以下内容:
- 如何导入 Zig 标准库
- 如何导入其他文件
- 如何导出变量、函数定义
下面这行 Zig 代码是一个注释:
// 如果 `main` 不是 `pub` (public),此代码将无法编译
Zig 没有像 C 语言中类似 /* ... */
的多行注释。
基于注释的文档自动生成功能正在试验中。如果你看过 Zig 的标准库文档,你就会看到它的实际应用。//!
被称为顶级文档注释,可以放在文件的顶部。三斜线注释 (///
) 被称为文档注释,可以放在特定位置,如声明之前。如果在错误的地方使用这两种文档注释,编译器都会出错。
下面这行 Zig 代码是程序的入口函数 main
:
pub fn main() void
每个可执行文件都需要一个名为 main
的函数:它是程序的入口点。如果我们将 main
重命名为其他名字,例如 doIt
,并尝试运行 zig run learning.zig
,我们会得到下面的错误:'learning' has no member named 'main'
。
忽略 main
作为程序入口的特殊作用,它只是一个非常基本的函数:不带参数,不返回任何东西(void)。下面的函数会稍微有趣一些:
const std = @import("std");
pub fn main() void {
const sum = add(8999, 2);
std.debug.print("8999 + 2 = {d}\n", .{sum});
}
fn add(a: i64, b: i64) i64 {
return a + b;
}
C 和 C++ 程序员会注意到 Zig 不需要提前声明,即在定义之前就可以调用 add
函数。
接下来要注意的是 i64
类型:64 位有符号整数。其他一些数字类型有: u8
、 i8
、 u16
、 i16
、 u32
、 i32
、 u47
、 i47
、 u64
、 i64
、 f32
和 f64
。
包含 u47
和 i47
并不是为了测试你是否还清醒; Zig 支持任意位宽度的整数。虽然你可能不会经常使用这些,但它们可以派上用场。经常使用的一种类型是 usize
,它是一个无符号指针大小的整数,通常是表示某事物长度、大小的类型。
除了
f32
和f64
之外,Zig 还支持f16
、f80
和f128
浮点类型。
虽然没有充分的理由这样做,但如果我们将 add
的实现更改为:
fn add(a: i64, b: i64) i64 {
a += b;
return a;
}
a += b
这一行会报下面的错误:不能给常量赋值
。这是一个重要的教训,我们稍后将更详细地回顾:函数参数是常量。
为了提高可读性,Zig 中不支持函数重载(用不同的参数类型或参数个数定义的同名函数)。暂时来说,以上就是我们需要了解的有关函数的全部内容。
下面这行代码创建了一个 User
结构体:
pub const User = struct {
power: u64,
name: []const u8,
};
由于我们的程序是单个文件,因此
User
仅在定义它的文件中使用,因此我们不需要将其设为pub
。但这样一来,我们就看不到如何将声明暴露给其他文件了。
结构字段以逗号终止,并且可以指定默认值:
pub const User = struct {
power: u64 = 0,
name: []const u8,
};
当我们创建一个结构体时,必须对每个字段赋值。例如,在一开始的定义中 power
没有默认值,因此下面这行代码将报错:missing struct field: power
。
const user = User{.name = "Goku"};
但是,使用默认值定义后,上面的代码可以正常编译。
结构体可以有方法,也可以包含声明(包括其他结构),甚至可能包含零个字段,此时的作用更像是命名空间。
pub const User = struct {
power: u64 = 0,
name: []const u8,
pub const SUPER_POWER = 9000;
pub fn diagnose(user: User) void {
if (user.power >= SUPER_POWER) {
std.debug.print("it's over {d}!!!", .{SUPER_POWER});
}
}
};
方法只是普通函数,只是说可以用 struct.method()
方式调用。以下两种方法等价:
// 调用 user 的 diagnose
user.diagnose();
// 上面代码等价于:
User.diagnose(user);
大多数时候你将使用struct.method()
语法,但方法作为普通函数的语法糖在某些场景下可以派上用场。
if
语句是我们看到的第一个控制流。这很简单,对吧?我们将在下一部分中更详细地探讨这一点。
diagnose
在定义 User
类型中,接受 User
作为其第一个参数。因此,我们可以使用struct.method()
的语法来调用它。但结构内的函数不必遵循这种模式。一个常见的例子是用于结构体初始化的 init
函数:
pub const User = struct {
power: u64 = 0,
name: []const u8,
pub fn init(name: []const u8, power: u64) User {
return User{
.name = name,
.power = power,
};
}
}
init
的命名方式仅仅是一种约定,在某些情况下,open
或其他名称可能更有意义。如果你和我一样,不是 C++ 程序员,可能对 .$field = $value,
这种初始化字段的语法感到奇怪,但你很快就会习惯它。
当我们创建 "Goku"
时,我们将 user
变量声明为 const
:
const user = User{
.power = 9001,
.name = "Goku",
};
这意味着我们无法修改 user
的值。如果要修改变量,应使用 var
声明它。另外,你可能已经注意到 user
的类型是根据赋值对象推导出来的。我们也可以这样明确地声明:
const user: User = User{
.power = 9001,
.name = "Goku",
};
在有些情况下我们必须显式声明变量类型,但大多数时候,去掉显式的类型会让代码可读性更好。类型推导也可以这么使用。下面这段代码和上面的两个片段是等价的:
const user: User = .{
.power = 9001,
.name = "Goku",
};
不过这种用法并不常见。比较常见的一种情况是从函数返回结构体时会用到。这里的类型可以从函数的返回类型中推断出来。我们的 init
函数可能会这样写:
pub fn init(name: []const u8, power: u64) User {
// instead of return User{...}
return .{
.name = name,
.power = power,
};
}
就像我们迄今为止已经探索过的大多数东西一样,今后在讨论 Zig 语言的其他部分时,我们会再次讨论结构体。不过,在大多数情况下,它们都是简单明了的。
我们可以略过代码的最后一行,但鉴于我们的代码片段包含两个字符串 "Goku"
和 {s}'s power is {d}\n
,你可能会对 Zig 中的字符串感到好奇。为了更好地理解字符串,我们先来了解一下数组和切片。
数组的大小是固定的,其长度在编译时已知。长度是类型的一部分,因此 4 个有符号整数的数组 [4]i32
与 5 个有符号整数的数组 [5]i32
是不同的类型。
数组长度可以从初始化中推断出来。在以下代码中,所有三个变量的类型均为 [5]i32
:
const a = [5]i32{1, 2, 3, 4, 5};
// 我们已经在结构体中使用过 .{...} 语法,
// 它也适用于数组
const b: [5]i32 = .{1, 2, 3, 4, 5};
// 使用 _ 让编译器推导长度
const c = [_]i32{1, 2, 3, 4, 5};
另一方面,切片是指向数组的指针,外加一个在运行时确定的长度。我们将在后面的部分中讨论指针,但你可以将切片视为数组的视图。
如果你熟悉 Go,你可能已经注意到 Zig 中的切片有点不同:没有容量,只有指针和长度。
const a = [_]i32{1, 2, 3, 4, 5};
const b = a[1..4];
在上述代码中, b
是一个长度为 3 的切片,并且是一个指向 a
的指针。但是因为我们使用编译时已知的值来对数组进行切片(即 1
和 4
)所以长度 3
在编译时也是已知。 Zig 编译器能够分析出来这些信息,因此 b
不是一个切片,而是一个指向长度为 3 的整数数组的指针。具体来说,它的类型是 *const [3]i32
。所以这个切片的示例被 Zig 编译器的强大推导能力挫败了。
在实际代码中,切片的使用可能会多于数组。无论好坏,程序的运行时信息往往多于编译时信息。不过,在下面这个例子中,我们必须欺骗 Zig 编译器才能得到我们想要的示例:
const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
b
现在是一个切片了。具体来说,它的类型是 []const i32
。你可以看到,切片的长度并不是类型的一部分,因为长度是运行时属性,而类型总是在编译时就完全已知。在创建切片时,我们可以省略上界,创建一个到要切分的对象(数组或切片)末尾的切片,例如 const c = b[2..]
。
如果我们将
end
声明为const
那么它将成为编译时已知值,这将导致b
是一个指向数组的指针,而不是切片。我觉得这有点令人困惑,但它并不是经常出现的东西,而且也不太难掌握。我很想在这一点上跳过它,但无法找到一种诚实的方法来避免这个细节。
学习 Zig 让我了解到,类型具有很强的描述性。它不仅仅是一个整数或布尔值,甚至是一个有符号的 32 位整数数组。类型还包含其他重要信息。我们已经讨论过长度是数组类型的一部分,许多示例也说明了可变性(const-ness)也是数组类型的一部分。例如,在上一个示例中,b 的类型是 []const i32
。你可以通过下面的代码来验证这一点:
const std = @import("std");
pub fn main() void {
const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
std.debug.print("{any}", .{@TypeOf(b)});
}
如果我们尝试写入 b
,例如 b[2] = 5
,我们会收到编译时错误:cannot assign to constant.
。这就是因为 b
类型是 const
导致。
为了解决这个问题,你可能会想要进行以下更改:
// 将 const 替换为 var
var b = a[1..end];
但你会得到同样的错误,为什么?作为提示,b
的类型是什么,或者更通俗地说,b
是什么?切片是指向数组(部分)的长度和指针。切片的类型总是从它所切分的对象派生出来的。无论 b
是否声明为 const
,都是一个 [5]const i32
的切片,因此 b 必须是 []const i32
类型。如果我们想写入 b
,就需要将 a
从 const
变为 var
。
const std = @import("std");
pub fn main() void {
var a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
b[2] = 99;
}
这是有效的,因为我们的切片不再是 []const i32
而是 []i32
。你可能想知道为什么当 b
仍然是 const
时,这段代码可以执行。这时因为 b
的可变性是指 b
本身,而不是 b
指向的数据。好吧,我不确定这是一个很好的解释,但对我来说,这段代码突出了差异:
const std = @import("std");
pub fn main() void {
var a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 3;
end += 1;
const b = a[1..end];
b = b[1..];
}
上述代码不会编译;正如编译器告诉我们的,我们不能给常量赋值。但如果将代码改成 var b = a[1..end]
,那么代码就是正确的了,因为 b
本身不再是常量。
在了解 Zig 语言的其他方面(尤其是字符串)的同时,我们还将发现更多有关数组和切片的知识。
我希望我能说,Zig 里有字符串类型,而且非常棒。遗憾的是,它没有。最简单来说,字符串是字节(u8)的序列(即数组或切片)。实际上,我们可以从 name
字段的定义中看到这一点:name: []const u8
.
按照惯例,这类字符串大多数都是用 UTF-8 编码,因为 Zig 源代码本身就是 UTF-8 编码的。但这并不是强制的,而且代表 ASCII 或 UTF-8 字符串的 []const u8
与代表任意二进制数据的 []const u8
实际上并没有什么区别。怎么可能有区别呢,它们是相同的类型。
根据我们所学的数组和切片知识,你可以正确地猜测 []const u8
是对常量字节数组的切片(其中字节是一个无符号 8 位整数)。但我们的代码中没有任何地方对数组进行切分,甚至没有数组,对吧?我们所做的只是将 "Goku"
赋值给 user.name
。这是怎么做到的呢?
你在源代码中看到的字符串字面量有一个编译时已知的长度。编译器知道 "Goku"
的长度是 4,所以你会认为 "Goku"
最好用数组来表示,比如 [4]const u8
。但是字符串字面形式有几个特殊的属性。它们被存储在二进制文件的一个特殊位置,并且会去重。因此,指向字符串字面量的变量将是指向这个特殊位置的指针。也就是说,"Goku"
的类型更接近于 *const [4]u8
,是一个指向 4 常量字节数组的指针。
还有更多。字符串字面量以空值结束。也就是说,它们的末尾总是有一个 \0
。在内存中,"Goku"
实际上是这样的:{'G', 'o', 'k', 'u', 0}
,所以你可能会认为它的类型是 *const [5]u8
。但这样做充其量只是模棱两可,更糟糕的是会带来危险(你可能会覆盖空结束符)。相反,Zig 有一种独特的语法来表示以空结尾的数组。"Goku"
的类型是 *const[4:0]u8
,即 4 字节以空结尾的数组指针。当我们讨论字符串时,我们关注的是以空结尾的字节数组(因为在 C 语言中字符串通常就是这样表示的),语法更通用:[LENGTH:SENTINEL]
,其中 SENTINEL
是数组末尾的特殊值。因此,虽然我想不出为什么需要它,但下面的语法是完全正确的:
const std = @import("std");
pub fn main() void {
// an array of 3 booleans with false as the sentinel value
const a = [3:false]bool{false, true, false};
// This line is more advanced, and is not going to get explained!
std.debug.print("{any}\n", .{std.mem.asBytes(&a).*});
}
上面代码会输出:{ 0, 1, 0, 0}
。
我一直在犹豫是否要加入这个示例,因为最后一行非常高级,我不打算解释它。从另一个角度看,如果你愿意的话,这也是一个可以运行的示例,你可以用它来更好地研究我们到目前为止讨论过的一些问题。
如果我的解释还可以接受,那么你可能还有一点不清楚。如果 "Goku"
是一个 *const [4:0]u8
,那么我们为什么能将它赋值给一个 []const u8
值呢?答案很简单:Zig 会自动进行类型转化。它会在几种不同的类型之间进行类型转化,但最明显的是字符串。这意味着,如果函数有一个 []const u8
参数,或者结构体有一个 []const u8
字段,就可以使用字符串字面形式。由于以空结尾的字符串是数组,而且数组的长度是已知的,因此这种转化代价比较低,即不需要遍历字符串来查找空结束符。
因此,在谈论字符串时,我们通常指的是 []const u8
。必要时,我们会明确说明一个以空结尾的字符串,它可以被自动转化为一个 []const u8
。但请记住,[]const u8
也用于表示任意二进制数据,因此,Zig 并不像高级编程语言那样有字符串的概念。此外,Zig 的标准库只有一个非常基本的 unicode 模块。
当然,在实际程序中,大多数字符串(以及更通用的数组)在编译时都是未知的。最典型的例子就是用户输入,程序编译时并不知道用户输入。这一点我们将在讨论内存时再次讨论。但简而言之,对于这种在编译时不能确定值的数据(长度当然也就无从得知),我们将在运行时动态分配内存。我们的字符串变量(仍然是 []const u8
类型)将是指向动态分配的内存的切片。
在我们未解释的最后一行代码中,涉及的知识远比表面看到的多:
std.debug.print("{s}'s power is {d}\n", .{user.name, user.power});
我们只是略微浏览了一下,但它确实提供了一个机会来强调 Zig 的一些更强大的功能。即使你还没有掌握,至少也应该了解这些功能。
首先是 Zig 的编译时执行(compile-time execution)概念。编译时执行是 Zig 元编程功能的核心,顾名思义,就是在编译时而不是运行时运行代码。在本指南中,我们将对编译时可能实现的功能进行浅显介绍,更多高级功能读者可以参考其他资料。
你可能想知道上面这行代码中需要编译时执行的是什么。print
函数的定义要求我们的第一个参数(字符串格式)是编译时已知的:
// 注意变量"fmt"前的"comptime"
pub fn print(comptime fmt: []const u8, args: anytype) void {
原因是 print
会进行额外的编译时检查,而这在大多数其他语言中是不会出现的。什么样的检查呢?假设你把格式改为 it's over {d}/n
,但保留了两个参数。你会得到一个编译时错误:unused argument in 'it's over {d}'
。它还会进行类型检查:将格式字符串改为{s}'s power is {s}\n
,你会这个错误invalid format string 's' for type 'u64'
。如果在编译时不知道字符串的格式,就不可能在编译时进行这些检查。因此,需要一个编译时已知的值。
comptime
会对编码产生直接影响的地方是整数和浮点字面的默认类型,即特殊的 comptime_int
和 comptime_float
。这行代码是无效的:var i = 0
。comptime
代码只能使用编译时已知的数据,对于整数和浮点数,这类数据由特殊的 comptime_int
和 comptime_float
类型标识。这种类型的值可以在编译时执行。但你可能不会把大部分时间花在编写用于编译时执行的代码上,因此它并不是一个特别有用的默认值。你需要做的是给变量一个显式类型:
var i: usize = 0;
var j: f64 = 0;
注意,如果我们使用
const
,就不会出现这个错误,因为错误的关键在于comptime_int
必须是常量。
在以后的章节中,我们将在探索泛型时进一步研究 comptime
。
我们这行代码的另一个特别之处在于奇怪的 .{user.name, user.power}
,根据上述 print
的定义,我们知道它映射到 anytype
类型的变量。这种类型不应与 Java 的 Object 或 Go 的 any(又名 interface{})混淆。相反,在编译时,Zig 会为传递给它的所有类型专门创建一个单独的 print
函数。
这就引出了一个问题:我们传递给它的是什么?我们以前在让编译器推断结构类型时见过 .{...}
符号。这与此类似:它创建了一个匿名结构字面。请看这段代码
pub fn main() void {
std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});
}
会输出:
struct{comptime year: comptime_int = 2023, comptime month: comptime_int = 8}
在这里,我们给匿名结构的字段取名为 year
和 month
。在原始代码中,我们没有这样做。在这种情况下,字段名会自动生成 0、1、2 等。虽然它们都是匿名结构字面形式的示例,但没有字段名称的结构通常被称为“元组”(tuple)。print
函数希望接收一个元组,并使用字符串格式中的序号位置来获取适当的参数。
Zig 没有函数重载,也没有可变函数(vardiadic,具有任意数量参数的函数)。但它的编译器能根据传入的类型创建专门的函数,包括编译器自己推导和创建的类型。