Chapter 03Data Fundamentals

数据基础

概述

控制流只有在它操作的数据有用时才有效,因此本章将Zig的核心集合类型——数组、切片和哨兵终止字符串——基于实际使用,同时保持值语义的显式性。参见#Arrays#Slices作为参考。

我们还使指针、可选类型和友好对齐的转换变得常规,展示如何在保留边界检查和对可变性的清晰性的同时安全地重新解释内存。参见#Pointers#alignCast获取详细信息。

Zig的类型系统类别

在深入探讨特定的集合类型之前,了解数组、切片和指针在Zig类型系统中的位置是有帮助的。Zig中的每个类型都属于一个类别,每个类别提供特定的操作:

graph TB subgraph "Type Categories" PRIMITIVE["Primitive Types<br/>bool, u8, i32, f64, void, ..."] POINTER["Pointer Types<br/>*T, [*]T, []T, [:0]T"] AGGREGATE["Aggregate Types<br/>struct, array, tuple"] FUNCTION["Function Types<br/>fn(...) ReturnType"] SPECIAL["Special Types<br/>anytype, type, comptime_int"] end subgraph "Common Type Operations" ABISIZE["abiSize()<br/>Byte size in memory"] ABIALIGN["abiAlignment()<br/>Required alignment"] HASRUNTIME["hasRuntimeBits()<br/>Has runtime storage?"] ELEMTYPE["elemType()<br/>Element type (arrays/slices)"] end PRIMITIVE --> ABISIZE POINTER --> ABISIZE AGGREGATE --> ABISIZE PRIMITIVE --> ABIALIGN POINTER --> ABIALIGN AGGREGATE --> ABIALIGN POINTER --> ELEMTYPE AGGREGATE --> ELEMTYPE

本章的关键见解:

  • 数组是具有编译时已知长度的聚合类型——它们的大小是element_size * length
  • 切片是存储指针和运行时长度的指针类型——始终是2 × 指针大小
  • 指针有多种形状(单项目*T、多项目[*]T、切片[]T),具有不同的安全保证
  • 所有类型都暴露它们的大小和对齐方式,这会影响结构体布局和内存分配

这种类型感知设计让编译器能够在切片上强制执行边界检查,同时在你明确选择退出安全性时允许在多项目指针上进行指针算术。

学习目标

  • 区分数组值语义与切片视图,包括用于安全回退的零长度惯用法。
  • 导航指针形状(*T[*]T?*T)并在不牺牲安全工具的情况下解包可选类型(参见#Optionals)。
  • 在与其他API互操作时应用哨兵终止字符串和对齐感知转换(@alignCast@bitCast@intCast)(参见#Sentinel-Terminated-Pointers#Explicit-Casts)。

在内存中结构化集合

数组拥有存储而切片借用它,因此编译器在长度、可变性和生命周期方面强制执行不同的保证;掌握它们的相互作用使迭代可预测,并将大多数边界检查移到调试构建中。

数组作为拥有的存储

数组在其类型中携带长度,按值复制,并为你提供一个可变基线,从中可以切出只读和读写切片。

Zig
const std = @import("std");

/// 打印切片的详细信息,包括标签、长度和首元素
/// 如果切片为空,显示 -1 作为首元素的值
fn describe(label: []const u8, data: []const i32) void {
    // 获取首元素,如果切片为空则返回 -1
    const head = if (data.len > 0) data[0] else -1;
    std.debug.print("{s}: len={} head={d}\n", .{ label, data.len, head });
}

/// 演示 Zig 中数组和切片的基础知识,包括:
/// - 数组声明和初始化
/// - 从不同可变性的数组创建切片
/// - 通过直接索引和切片修改数组
/// - 数组复制行为(值语义)
/// - 创建空切片和零长度切片
pub fn main() !void {
    // 声明可推断大小的可变数组
    var values = [_]i32{ 3, 5, 8, 13 };
    // 使用匿名结构语法声明显式大小的常量数组
    const owned: [4]i32 = .{ 1, 2, 3, 4 };

    // 创建覆盖整个数组的可变切片
    var mutable_slice: []i32 = values[0..];
    // 创建前两个元素的不可变切片
    const prefix: []const i32 = values[0..2];
    // 创建零长度切片(空但有效)
    const empty = values[0..0];

    // 通过索引直接修改数组
    values[1] = 99;
    // 通过可变切片修改数组
    mutable_slice[0] = -3;

    std.debug.print("array len={} allows mutation\n", .{values.len});
    describe("mutable_slice", mutable_slice);
    describe("prefix", prefix);
    // 演示切片修改会影响底层数组
    std.debug.print("values[0] after slice write = {d}\n", .{values[0]});
    std.debug.print("empty slice len={} is zero-length\n", .{empty.len});

    // 在 Zig 中数组按值复制
    var copy = owned;
    copy[0] = -1;
    // 显示修改副本不会影响原始数组
    std.debug.print("copy[0]={d} owned[0]={d}\n", .{ copy[0], owned[0] });

    // Create a slice from an empty array literal using address-of operator
    // 从空数组字面量创建切片使用取地址运算符
    const zero: []const i32 = &[_]i32{};
    std.debug.print("zero slice len={} from literal\n", .{zero.len});
}
运行
Shell
$ zig run arrays_and_slices.zig
输出
Shell
array len=4 allows mutation
mutable_slice: len=4 head=-3
prefix: len=2 head=-3
values[0] after slice write = -3
empty slice len=0 is zero-length
copy[0]=-1 owned[0]=1
zero slice len=0 from literal

可变切片和原始数组共享存储,而[]const前缀阻止写入——这是一个有意的边界,强制只读消费者保持诚实。

内存布局:数组与切片

理解数组和切片在内存中的布局方式阐明了为什么"数组拥有存储而切片借用它"以及为什么数组到切片的强制转换是一个廉价操作:

graph TB subgraph "Array in Memory" ARRAY_DECL["const values: [4]i32 = .{1, 2, 3, 4}"] ARRAY_MEM["Memory Layout (16 bytes)\n\nstack frame\n| 1 | 2 | 3 | 4 |"] ARRAY_DECL --> ARRAY_MEM end subgraph "Slice in Memory" SLICE_DECL["const slice: []const i32 = &values"] SLICE_MEM["Memory Layout (16 bytes on 64-bit)\n\nstack frame\n| ptr | len=4 |"] POINTS["ptr points to array data"] SLICE_DECL --> SLICE_MEM SLICE_MEM --> POINTS end POINTS -.->|"references"| ARRAY_MEM subgraph "Key Differences" DIFF1["Array: Stores data inline<br/>Size = elem_size × length"] DIFF2["Slice: Stores pointer + length<br/>Size = 2 × pointer_size (16 bytes on 64-bit)"] DIFF3["Coercion: &array → slice<br/>Just creates {ptr, len} pair"] end

为什么这很重要:

  • 数组具有值语义:分配数组会复制所有元素
  • 切片具有引用语义:分配切片只复制指针和长度
  • 数组到切片的强制转换(&array)是廉价的——它不复制数据,只是创建一个描述符
  • 切片是"胖指针":它们携带运行时长度信息,启用边界检查

这就是为什么函数通常接受切片作为参数——它们可以与数组、切片以及两者的部分一起工作,而无需复制底层数据。

实践中的字符串和哨兵

哨兵终止数组在不牺牲切片安全性的情况下桥接到C API;你可以使用std.mem.span重新解释字节流,并在保留哨兵约定时仍然可以改变底层缓冲区。

Zig
const std = @import("std");

/// 演示Zig中的哨兵终止字符串和数组,包括:
/// - 零终止字符串字面量([:0]const u8)
/// - 多项哨兵指针([*:0]const u8)
/// - 哨兵终止数组([N:0]T)
/// - 哨兵切片与常规切片之间的转换
/// - 通过哨兵指针进行修改
pub fn main() !void {
    // Zig中的字符串字面量默认以零字节哨兵终止
    // [:0]const u8表示在末尾有哨兵值0的切片
    const literal: [:0]const u8 = "data fundamentals";

    // 将哨兵切片转换为多项哨兵指针
    // [*:0]const u8与C风格空终止字符串兼容
    const c_ptr: [*:0]const u8 = literal;

    // std.mem.span将哨兵终止指针转换回切片
    // 它扫描直到找到哨兵值(0)以确定长度
    const bytes = std.mem.span(c_ptr);
    std.debug.print("literal len={} contents=\"{s}\"\n", .{ bytes.len, bytes });

    // 声明具有显式大小和哨兵值的哨兵终止数组
    // [6:0]u8表示6个元素的数组加上位置6的哨兵0字节
    var label: [6:0]u8 = .{ 'l', 'a', 'b', 'e', 'l', 0 };

    // 从数组创建可变哨兵切片
    // [0..:0]语法从索引0到末尾创建切片,带有哨兵0
    var sentinel_view: [:0]u8 = label[0.. :0];

    // 通过哨兵切片修改第一个元素
    sentinel_view[0] = 'L';

    // 从前4个元素创建常规(非哨兵)切片
    // 这会放弃哨兵保证但提供有界切片
    const trimmed: []const u8 = sentinel_view[0..4];
    std.debug.print("trimmed slice len={} -> {s}\n", .{ trimmed.len, trimmed });

    // 将哨兵切片转换为多项哨兵指针
    // 这允许unchecked索引,同时保留哨兵信息
    const tail: [*:0]u8 = sentinel_view;

    // 通过多项哨兵指针修改索引4处的元素
    // 不会发生边界检查,但哨兵保证仍然有效
    tail[4] = 'X';

    // 演示通过指针的修改影响了原始数组
    // std.mem.span使用哨兵重建完整切片
    std.debug.print("full label after mutation: {s}\n", .{std.mem.span(tail)});
}
运行
Shell
$ zig run sentinel_strings.zig
输出
Shell
literal len=17 contents="data fundamentals"
trimmed slice len=4 -> Labe
full label after mutation: LabeX

哨兵切片保持尾随零完整,因此即使在局部突变后,为FFI获取[*:0]u8仍然有效,而普通切片在Zig内部提供符合人体工程学的迭代(参见#Type-Coercion)。

std.mem.span将哨兵指针转换为普通切片而不克隆数据,当你在返回指针API之前暂时需要边界检查或切片助手时,这使其成为理想选择。

不可变和可变视图

当调用者只检查数据时,优先使用[]const T——Zig很乐意将可变切片强制转换为const视图,为你提供API清晰度,并从一开始就防止意外写入编译。

指针模式和转换工作流

当你共享存储、与外部布局互操作或超出切片边界时,指针就会出现;通过依赖可选包装器和显式转换,你可以保持意图清晰,并在假设被打破时触发安全检查。

指针形状参考

Zig提供多种指针类型,每种都有不同的安全保证和用例。理解何时使用每种形状对于编写安全、高效的代码至关重要:

graph TB subgraph "Pointer Shapes" SINGLE["*T<br/>Single-Item Pointer"] MANY["[*]T<br/>Many-Item Pointer"] SLICE["[]T<br/>Slice"] OPTIONAL["?*T<br/>Optional Pointer"] SENTINEL_PTR["[*:0]T<br/>Sentinel Many-Item"] SENTINEL_SLICE["[:0]T<br/>Sentinel Slice"] end subgraph "Characteristics" SINGLE --> S_BOUNDS["✓ Bounds: Single element<br/>✓ Safety: Dereference checked<br/>📍 Use: Function parameters, references"] MANY --> M_BOUNDS["⚠ Bounds: Unknown length<br/>✗ Safety: No bounds checking<br/>📍 Use: C interop, tight loops"] SLICE --> SL_BOUNDS["✓ Bounds: Runtime length<br/>✓ Safety: Bounds checked<br/>📍 Use: Most Zig code, iteration"] OPTIONAL --> O_BOUNDS["✓ Bounds: May be null<br/>✓ Safety: Must unwrap first<br/>📍 Use: Optional references"] SENTINEL_PTR --> SP_BOUNDS["✓ Bounds: Until sentinel<br/>~ Safety: Sentinel must exist<br/>📍 Use: C strings, null-terminated"] SENTINEL_SLICE --> SS_BOUNDS["✓ Bounds: Length + sentinel<br/>✓ Safety: Both length and sentinel<br/>📍 Use: Zig ↔ C string bridge"] end

比较表格:

形状示例长度已知?边界检查?常见用途
*T*i32单个元素是(隐式)引用单个项目
[*]T[*]i32未知C数组,指针算术
[]T[]i32运行时(在切片中)主要的Zig集合类型
?*T?*i32单个(如果非空)是 + null检查可选引用
[*:0]T[*:0]u8直到哨兵哨兵必须存在C字符串(char*
[:0]T[:0]u8运行时 + 哨兵是 + 哨兵保证用于C API的Zig字符串

指南:

  • 默认使用切片 ([]T) 用于所有Zig代码——它们提供安全性和便利性
  • 使用单项目指针 (*T) 当你需要改变单个值或通过引用传递时
  • 避免多项目指针 ([*]T) 除非与C接口交互或在性能关键的内循环中
  • 使用可选指针 (?*T) 当null是有意义的状态时,不用于错误处理
  • 使用哨兵类型 ([*:0]T, [:0]T) 在C边界处,在内部转换为切片

用于共享可变性的可选指针

可选单项目指针暴露可变性而无需猜测生命周期——仅在存在时捕获它们,通过解引用进行突变,并在指针不存在时优雅地回退。

Zig
const std = @import("std");

/// 表示具有数值读数的传感器设备的简单结构
const Sensor = struct {
    reading: i32,
};

/// 将传感器的读数值打印到调试输出
/// 接受指向传感器的单个指针并显示其当前读数
fn report(label: []const u8, ptr: *Sensor) void {
    std.debug.print("{s} -> reading {d}\n", .{ label, ptr.reading });
}

/// 演示Zig中的指针基础、可选指针和多项目指针
/// 本示例涵盖:
/// - 单项目指针(*T)和指针解引用
/// - 指针别名和通过别名进行修改
/// - 用于表示可空引用的可选指针(?*T)
/// - 使用if语句解包可选指针
/// - 用于未检查多元素访问的多项目指针([*]T)
/// - 通过.ptr属性将切片转换为多项目指针
pub fn main() !void {
    // 在栈上创建传感器实例
    var sensor = Sensor{ .reading = 41 };

    // 创建传感器的单项目指针别名
    // &操作符获取传感器的地址
    var alias: *Sensor = &sensor;

    // 通过指针别名修改传感器
    // Zig自动解引用指针字段
    alias.reading += 1;

    report("alias", alias);

    // 声明初始化为null的可选指针
    // ?*T表示可能持有或不持有有效地址的指针
    var maybe_alias: ?*Sensor = null;

    // 尝试解包可选指针
    // 此分支不会执行,因为maybe_alias为null
    if (maybe_alias) |pointer| {
        std.debug.print("unexpected pointer: {d}\n", .{pointer.reading});
    } else {
        std.debug.print("optional pointer empty\n", .{});
    }

    // 将有效地址赋值给可选指针
    maybe_alias = &sensor;

    // 解包并使用可选指针
    // |pointer|捕获语法提取非空值
    if (maybe_alias) |pointer| {
        pointer.reading += 10;
        std.debug.print("optional pointer mutated to {d}\n", .{sensor.reading});
    }

    // 创建数组及其切片视图
    var samples = [_]i32{ 5, 7, 9, 11 };
    const view: []i32 = samples[0..];

    // 从切片中提取多项目指针
    // 多项目指针([*]T)允许不带长度跟踪的未检查索引
    const many: [*]i32 = view.ptr;

    // 通过多项目指针修改底层数组
    // 此时不执行边界检查
    many[2] = 42;

    std.debug.print("slice view len={}\n", .{view.len});
    // 验证通过多项目指针的修改影响了原始数组
    std.debug.print("samples[2] via many pointer = {d}\n", .{samples[2]});
}
运行
Shell
$ zig run pointers_and_optionals.zig
输出
Shell
alias -> reading 42
optional pointer empty
optional pointer mutated to 52
slice view len=4
samples[2] via many pointer = 42

?*Sensor门将突变保持在模式匹配之后,而多项目指针([*]i32)通过丢弃边界检查来记录别名风险——这是为紧密循环和FFI保留的故意权衡。

对齐和重新解释数据

当你必须重新解释原始字节时,使用转换内置函数来提升对齐方式,更改指针元素类型,并保持整数/浮点数转换的显式性,以便调试构建可以捕获未定义的假设(参见#bitCast)。

Zig
const std = @import("std");

/// 演示Zig中的内存对齐概念和各种类型转换操作
/// 本示例涵盖:
/// - 使用align()属性的内存对齐保证
/// - 使用@alignCast的指针转换和对齐调整
/// - 使用@ptrCast进行内存重新解释的类型转换
/// - 使用@bitCast的位级重新解释
/// - 使用@truncate截断整数
/// - 使用@intCast扩展整数
/// - 使用@floatCast浮点精度转换
pub fn main() !void {
    // 创建对齐到u64边界的字节数组,用小端字节初始化
    // 表示前4个字节中的0x11223344
    var raw align(@alignOf(u64)) = [_]u8{ 0x44, 0x33, 0x22, 0x11, 0, 0, 0, 0 };

    // 获取指向首字节的指针,带有显式u64对齐
    const base: *align(@alignOf(u64)) u8 = &raw[0];

    // 使用@alignCast调整对齐约束从u64到u32
    // 这是安全的,因为u64对齐(8字节)满足u32对齐(4字节)
    const aligned_bytes = @as(*align(@alignOf(u32)) const u8, @alignCast(base));

    // 将字节指针重新解释为u32指针,将4字节读取为单个整数
    const word_ptr = @as(*const u32, @ptrCast(aligned_bytes));

    // 解引用以获取32位值(小端:0x11223344)
    const number = word_ptr.*;
    std.debug.print("32-bit value = 0x{X:0>8}\n", .{number});

    // 替代方法:使用@bitCast直接重新解释前4字节
    // 这会创建一个副本,不需要指针操作
    const from_bytes = @as(u32, @bitCast(raw[0..4].*));
    std.debug.print("bitcast copy = 0x{X:0>8}\n", .{from_bytes});

    // 演示@truncate:提取最低有效8位(0x44)
    const small: u8 = @as(u8, @truncate(number));

    // 演示@intCast:将无符号u32扩展为有符号i64,无数据丢失
    const widened: i64 = @as(i64, @intCast(number));
    std.debug.print("truncate -> 0x{X:0>2}, widen -> {d}\n", .{ small, widened });

    // 演示@floatCast:将f64精度降低到f32
    // 对于无法在f32中精确表示的值,可能会导致精度损失
    const ratio64: f64 = 1.875;
    const ratio32: f32 = @as(f32, @floatCast(ratio64));
    std.debug.print("floatCast ratio -> {}\n", .{ratio32});
}
运行
Shell
$ zig run alignment_and_casts.zig
输出
Shell
32-bit value = 0x11223344
bitcast copy = 0x11223344
truncate -> 0x44, widen -> 287454020
floatCast ratio -> 1.875

通过链接@alignCast@ptrCast@bitCast,你可以显式地断言布局关系,而后续的@truncate/@intCast转换在跨API缩小或扩大时保持整数宽度的诚实性。

注意事项

  • 哨兵终止指针非常适合C桥接,但在Zig内部优先使用切片,以便边界检查保持可用并且API暴露长度。
  • 使用@alignCast升级指针对齐方式在Debug模式下如果地址未对齐仍然会陷入陷阱——在提升之前证明前提条件。
  • 多项目指针([*]T)丢弃边界检查;谨慎使用它们并记录安全切片会强制执行的不变量。

练习

  • 扩展arrays_and_slices.zig以从运行时数组创建零长度可变切片,然后通过std.ArrayList追加以观察切片视图如何保持有效。
  • 修改sentinel_strings.zig以接受用户提供的[:0]u8,并通过返回错误联合来防止缺少哨兵的输入。
  • 通过添加一个在截断前拒绝低字节为零的值的分支来增强alignment_and_casts.zig,展示@intCast如何依赖于调用者提供的范围保证。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.