结构体
在 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 Empty = struct {};
const std = @import("std");
const Empty = struct {};
pub fn main() void {
std.debug.print("{}\n", .{@sizeOf(Empty)});
}
🅿️ 提示
使用 Go 的朋友对这个可能很熟悉,在 Go 中经常用空结构体做实体在 chan 中传递,它的内存大小为 0 !
通过字段获取基指针
为了获得最佳的性能,结构体字段的顺序是由编译器决定的,但是,我们可以仍然可以通过结构体字段的指针来获取到基指针!
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。
高级特性
以下特性如果你连名字都没有听说过,那就代表你目前无需了解以下部分,待需要时再来学习即可!
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)