结构体
在 Zig 中,类型是“一等公民”!
结构体是一种高级数据结构,用于将多个相关数据组织成一个单一的实体。
基本语法
结构体的组成:
- 首部关键字
struct
- 与变量定义相同的结构体名称
- 多个字段
- 方法
- 多个声明
以下是一个简短的结构体声明:
const Circle = struct {
radius: u8,
const PI: f16 = 3.14;
pub fn init(radius: u8) Circle {
return Circle{ .radius = radius };
}
fn area(self: *Circle) f16 {
return @as(f16, @floatFromInt(self.radius * self.radius)) * PI;
}
};
const std = @import("std");
const Circle = struct {
radius: u8,
const PI: f16 = 3.14;
pub fn init(radius: u8) Circle {
return Circle{ .radius = radius };
}
fn area(self: *Circle) f16 {
return @as(f16, @floatFromInt(self.radius * self.radius)) * PI;
}
};
pub fn main() void {
const radius: u8 = 5;
var circle = Circle.init(radius);
std.debug.print("The area of a circle with radius {} is {d:.2}\n", .{ radius, circle.area() });
}
上面的代码展示了以下内容:
- 定义了一个结构体
Circle
,用于表示一个圆 - 包含
radius
字段 - 一个常量声明
PI
- 包含两个方法
init
和area
🅿️ 提示
值得注意的是,结构体的方法除了可以使用 .
语法调用外,与普通函数并无本质区别。这意味着你可以在任何可以使用普通函数的地方使用结构体的方法。
自引用
常见的自引用方式是将结构体自身的指针作为函数的第一个参数,例如:
const TT = struct {
pub fn print(self: *TT) void {
_ = self; // _ 表示不使用变量
std.debug.print("Hello, world!\n", .{});
}
};
const std = @import("std");
const TT = struct {
pub fn print(self: *TT) void {
_ = self; // _ 表示不使用变量
std.debug.print("Hello, world!\n", .{});
}
};
pub fn main() void {
var tmp: TT = .{};
tmp.print();
}
在实际使用中,匿名结构体如何实现自引用是一个常见问题。
答案是使用 @This
。这是 Zig 专门为匿名结构体和文件级别的类型声明(可以参考命名空间章节)提供的解决方案。
它会返回当前包裹它的容器的类型。
例如:
fn List(comptime T: type) type {
return struct {
const Self = @This();
items: []T,
fn length(self: Self) usize {
return self.items.len;
}
};
}
const std = @import("std");
fn List(comptime T: type) type {
return struct {
const Self = @This();
items: []T,
fn length(self: Self) usize {
return self.items.len;
}
};
}
pub fn main() void {
const int_list = List(u8);
var arr: [5]u8 = .{
1, 2, 3, 4, 5,
};
var list: int_list = .{
.items = &arr,
};
std.debug.print("list len is {}\n", .{list.length()});
}
更复杂的例子
下面是一个日常会用到的结构体例子,关于系统账号管理的使用:
const User = struct {
userName: []u8,
password: []u8,
email: []u8,
active: bool,
pub const writer = "zig-course";
pub fn init(userName: []u8, password: []u8, email: []u8, active: bool) User {
return User{
.userName = userName,
.password = password,
.email = email,
.active = active,
};
}
pub fn print(self: *User) void {
std.debug.print(
\\username: {s}
\\password: {s}
\\email: {s}
\\active: {}
\\
, .{
self.userName,
self.password,
self.email,
self.active,
});
}
};
const std = @import("std");
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const User = struct {
userName: []u8,
password: []u8,
email: []u8,
active: bool,
pub const writer = "zig-course";
pub fn init(userName: []u8, password: []u8, email: []u8, active: bool) User {
return User{
.userName = userName,
.password = password,
.email = email,
.active = active,
};
}
pub fn print(self: *User) void {
std.debug.print(
\\username: {s}
\\password: {s}
\\email: {s}
\\active: {}
\\
, .{
self.userName,
self.password,
self.email,
self.active,
});
}
};
const name = "xiaoming";
const passwd = "123456";
const mail = "123456@qq.com";
pub fn main() !void {
// 我们在这里使用了内存分配器的知识,如果你需要的话,可以提前跳到内存管理进行学习!
const allocator = gpa.allocator();
defer {
const deinit_status = gpa.deinit();
if (deinit_status == .leak) std.testing.expect(false) catch @panic("TEST FAIL");
}
const username = try allocator.alloc(u8, 20);
defer allocator.free(username);
// @memset 是一个内存初始化函数,它会将一段内存初始化为 0
@memset(username, 0);
// @memcpy 是一个内存拷贝函数,它会将一个内存区域的内容拷贝到另一个内存区域
@memcpy(username[0..name.len], name);
const password = try allocator.alloc(u8, 20);
defer allocator.free(password);
@memset(password, 0);
@memcpy(password[0..passwd.len], passwd);
const email = try allocator.alloc(u8, 20);
defer allocator.free(email);
@memset(email, 0);
@memcpy(email[0..mail.len], mail);
var user = User.init(username, password, email, true);
user.print();
}
在以上代码中,我们使用了内存分配功能,并结合了切片、多行字符串以及 defer
语法(在当前作用域结束时执行语句)的应用。
自动推断
Zig 在使用结构体时支持省略类型声明,此时 Zig 会利用结果位置语义进行类型推断,例如:
const Point = struct { x: i32, y: i32 };
const pt: Point = .{
.x = 13,
.y = 67,
};
这种基于结果位置语义的推导也适用于结构体自身的方法以及其内部定义的其他类型(此处不指结构体字段)。
泛型实现
得益于“类型是 Zig 的一等公民”这一特性,我们可以轻松实现泛型。
此处仅简单提及该特性,后续我们将专门讲解泛型这一强大工具!
以下是一个链表的类型实现:
fn LinkedList(comptime T: type) type {
return struct {
pub const Node = struct {
// 这里我们提前使用了可选类型,如有需要可以提前跳到可选类型部分学习!
prev: ?*Node,
next: ?*Node,
data: T,
};
first: ?*Node,
last: ?*Node,
len: usize,
};
}
🅿️ 提示
当然,这种操作不局限于变量声明,在函数中也可以使用(当编译器无法完成推断时,会给出包含完整堆栈跟踪的错误提示)!
字段默认值
结构体允许为字段设置默认值,只需在定义结构体时声明即可:
const Foo = struct {
a: i32 = 1234,
b: i32,
};
const x = Foo{
.b = 5,
};
然而,仅此还不够。在实际使用中,我们可能只初始化部分字段,而其他字段使用默认值。如果对结构体字段的默认值没有不变性要求,那么这种默认值方案已经足够使用。
但如果要求结构体字段的值具有默认不变性(即要么全部使用默认值,要么全部由使用者手动赋值),则可以采用以下方案:
const Threshold = struct {
minimum: f32,
maximum: f32,
// 选择声明一个默认值
const default: Threshold = .{
.minimum = 0.25,
.maximum = 0.75,
};
};
pub fn main() !void {
const std = @import("std");
// 初始化时直接使用默认值
const threshold: Threshold = .default;
std.debug.print("minimum is %d, maximum is %d", .{ threshold.minimum, threshold.maximum });
}
空结构体
你还可以使用空结构体,具体如下:
const Empty = struct {};
const std = @import("std");
const Empty = struct {};
pub fn main() void {
std.debug.print("{}\n", .{@sizeOf(Empty)});
}
🅿️ 提示
熟悉 Go 语言的读者可能对此很熟悉,在 Go 中空结构体常用于在 chan
中传递实体,其内存大小为 0。 而在 C++ 中,空结构体的内存大小通常为 1 字节。
通过字段获取基指针(基于字段的指针)
为了获得最佳性能,结构体字段的内存布局顺序由编译器决定。然而,我们仍然可以通过结构体字段的指针来获取其基指针!
const Point = struct {
x: f32,
y: f32,
};
fn setYBasedOnX(x: *f32, y: f32) void {
const point: Point = @fieldParentPtr("x", x);
point.y = y;
}
这里使用了内建函数 @fieldParentPtr
,它会根据给定的字段指针,返回对应的结构体基指针。
元组
元组实际上是不指定字段名的(匿名)结构体。
由于没有指定字段名,Zig 会使用从 0 开始的整数依次为字段命名。但整数并不是有效的标识符,因此在使用 .
语法访问字段时,需要将数字写在 @""
中。
// 我们定义了一个元组类型
const Tuple = struct { u8, u8 };
// 直接使用字面量来定义一个元组
const values = .{
@as(u32, 1234),
@as(f64, 12.34),
true,
"hi",
};
// 值得注意的是,values的类型和Tuple仅仅是结构相似,但不是同一类型!
// 因为values的类型是由编译器在编译期间自行推导出来的。
const hi = values.@"3"; // "hi"
当然,上述语法较为繁琐,因此 Zig 提供了类似数组的语法来访问元组,例如 values[3]
的值就是 "hi"。
🅿️ 提示
元组还有一个与数组相同的 len
字段,并且支持 ++
和 **
运算符,以及内联 for。
🅿️ 提示
元组也支持解构语法!
const print = @import("std").debug.print;
var x: u32 = undefined;
var y: u32 = undefined;
var z: u32 = undefined;
const tuple = .{ 1, 2, 3 };
x, y, z = tuple;
print("tuple: x = {}, y = {}, z = {}\n", .{ x, y, z });
高级特性
以下特性可能对初学者来说较为陌生。如果你从未听说过这些概念,则目前无需深入了解,待需要时再来学习即可!
Zig 并不保证结构体字段的顺序和结构体大小,但保证其符合 ABI 对齐要求。
extern
extern
关键字用于修饰结构体,使其内存布局保证匹配对应目标的 C ABI。
该关键字适用于嵌入式系统或裸机编程,其他情况下建议使用 packed
或普通结构体。
packed
packed
关键字修饰的结构体与普通结构体不同,它保证了以下内存布局特性:
- 字段严格按照声明的顺序排列
- 字段之间不会存在位填充(即不进行内存对齐)
- Zig 支持任意位宽的整数(通常不足 8 位的仍会占用 8 位),但在
packed
结构体中,字段将只占用其声明的位宽。 bool
类型的字段,仅有一位- 枚举类型只占用其整数标志位的位宽
- 联合类型只占用其最大成员的位宽
- 根据目标平台的字节序,非 ABI 字段会被尽量压缩,以占用尽可能小的 ABI 对齐整数的位宽。
以上特性在使用时有许多值得注意的有趣之处。
- Zig 允许我们获取字段指针。如果字段存在位偏移,那么该字段的指针将无法直接赋值给普通指针(因为位偏移也是指针对齐信息的一部分)。这种情况可以通过
@bitOffsetOf
和@offsetOf
来观察:
示例
const std = @import("std");
const expect = std.testing.expect;
const BitField = packed struct {
a: u3,
b: u3,
c: u2,
};
pub fn main() !void {
// @bitOffsetOf 用于获取位域的偏移量(即偏移几位)
try expect(@bitOffsetOf(BitField, "a") == 0);
try expect(@bitOffsetOf(BitField, "b") == 3);
try expect(@bitOffsetOf(BitField, "c") == 6);
// @offsetOf 用于获取字段的偏移量(即偏移几个字节)
try expect(@offsetOf(BitField, "a") == 0);
try expect(@offsetOf(BitField, "b") == 0);
try expect(@offsetOf(BitField, "c") == 0);
}
示例
const std = @import("std");
// 这里获取目标架构是字节排序方式,大端和小端
const native_endian = @import("builtin").target.cpu.arch.endian();
const expect = std.testing.expect;
const Full = packed struct {
number: u16,
};
const Divided = packed struct {
half1: u8,
quarter3: u4,
quarter4: u4,
};
fn doTheTest() !void {
try expect(@sizeOf(Full) == 2);
try expect(@sizeOf(Divided) == 2);
const full = Full{ .number = 0x1234 };
const divided: Divided = @bitCast(full);
try expect(divided.half1 == 0x34);
try expect(divided.quarter3 == 0x2);
try expect(divided.quarter4 == 0x1);
const ordered: [2]u8 = @bitCast(full);
switch (native_endian) {
.big => {
try expect(ordered[0] == 0x12);
try expect(ordered[1] == 0x34);
},
.little => {
try expect(ordered[0] == 0x34);
try expect(ordered[1] == 0x12);
},
}
}
pub fn main() !void {
try doTheTest();
try comptime doTheTest();
}
- 还可以对
packed
结构体的指针设置内存对齐,以访问对应的字段:
示例
const std = @import("std");
const expect = std.testing.expect;
const S = packed struct {
a: u32,
b: u32,
};
test "overaligned pointer to packed struct" {
var foo: S align(4) = .{ .a = 1, .b = 2 };
const ptr: *align(4) S = &foo;
const ptr_to_b: *u32 = &ptr.b;
try expect(ptr_to_b.* == 2);
}
packed struct
保证了字段的顺序以及字段间没有额外的填充。然而,结构体本身可能仍然存在额外的填充:
示例
const std = @import("std");
const Foo = packed struct {
x: i32,
y: [*]i32, // 一个多项指针
};
pub fn main() !void {
std.debug.print("{any}\n", .{@sizeOf(Foo)});
std.debug.print("{any}\n", .{@bitSizeOf(Foo) / 8});
std.debug.print("{any}\n", .{@bitOffsetOf(Foo, "x") / 8});
std.debug.print("{any}\n", .{@bitOffsetOf(Foo, "y") / 8});
}
输出为:
16
12
0
4
在 64 位系统上,Foo 的内存布局是:
| 4(i32) | 8(pointer) | 4(padding) |
额外的讨论信息:github issue #20265
命名规则
由于在 Zig 中许多结构是匿名的(例如一个源文件可以被视为一个匿名结构体),因此 Zig 遵循一套命名规则:
- 如果一个结构体位于变量的初始化表达式中,它将以该变量命名(实际上是声明结构体类型)。
- 如果一个结构体位于
return
表达式中,它将以返回的函数命名,并序列化参数。 - 其他情况下,结构体将获得一个类似
filename.funcname.__struct_ID
的名称。 - 如果该结构体在另一个结构体中声明,它将以父结构体和前面规则推断出的名称命名,并用点分隔。
上述规则可能有些抽象,下面通过几个示例来演示:
const std = @import("std");
pub fn main() void {
const Foo = struct {};
std.debug.print("variable: {s}\n", .{@typeName(Foo)});
std.debug.print("anonymous: {s}\n", .{@typeName(struct {})});
std.debug.print("function: {s}\n", .{@typeName(List(i32))});
}
fn List(comptime T: type) type {
return struct {
x: T,
};
}
variable: struct_name.main.Foo
anonymous: struct_name.main__struct_3509
function: struct_name.List(i32)