首页
文章
分类
标签
系列
关于
Zig基础学习
发布于: 2026-8-9 14:41:07   更新于: 2026-8-9 14:41:07   收录于: Zig , 编程语言 , 系统编程
文章字数: 6975   阅读时间: 14 分钟   阅读量:

Zig 学习笔记(v0.16 入门篇)

适用环境:Windows 11 + Zig 0.16

笔记风格:零基础友好,通俗解释 + 精确概念 + 避坑指南

目录

  1. 什么是 Zig
  2. 环境安装
  3. Hello World
  4. 变量与常量
  5. 标识符命名规则
  6. 解构赋值
  7. 块(Block)与作用域
  8. 变量遮蔽(Shadowing)
  9. 容器(Container)与块的区别
  10. 注释
  11. 线程局部存储(threadlocal)
  12. 延迟执行(defer 与 errdefer)
  13. 下一步学什么

一、什么是 Zig

一句话概括:Zig 是一门系统级编程语言,目标是用更简单的方式写出性能高、可靠性强的软件。

  • 没有隐藏控制流:没有宏、没有异常、没有函数重载,代码所见即所得
  • 编译期计算能力强大:可以在编译时执行代码,生成高效的程序
  • 与 C 语言无缝互操作:可以直接包含 C 头文件、调用 C 函数
  • 没有垃圾回收:手动管理内存,但提供了更安全的机制

适合做什么:系统工具、嵌入式开发、游戏引擎、性能敏感的应用、替代 C/C++ 的项目。

二、环境安装

使用 Scoop 安装(推荐)

1
scoop install zig

验证安装

1
zig version

我的输出示例(你的版本号可能不同):

0.16.0

常用 Zig 命令速查

命令用途
zig build构建项目(配合 build.zig
zig build-obj编译成目标文件(.obj
zig build test运行测试
zig init初始化一个项目模板
zig fmt自动格式化代码
zig cc充当 C 编译器
zig c++充当 C++ 编译器
zig fetch下载依赖包
zig zen打印 Zig 的“禅意”宣言(好玩的彩蛋)

💡 小技巧:运行 zig --help 可以查看所有命令。

三、Hello World

1
2
3
4
5
const std = @import("std");

pub fn main() !void {
    std.debug.print("Hello, World!\n", .{});
}

逐行拆解

代码通俗解释
const std = @import("std");导入 Zig 标准库,起个名字叫 std(就像 Python 的 import
pub fn main() !void定义程序的入口函数,!void 表示“可能出错,但不返回具体值”
std.debug.print(...)在控制台打印文本
"Hello, World!\n"要打印的字符串,\n 是换行符
.{ }空元组,表示“没有额外的参数要填充”

运行方式

1
zig run main.zig

四、变量与常量

4.1 核心概念

把变量想象成贴了标签的盒子

  • 盒子放在内存里,标签是变量名
  • 盒子里可以放数据
  • 有的盒子允许换内容(var),有的不允许(const

4.2 可变变量(var)

变量(Variable):具有内存地址的存储单元,可读且可写。

语法

1
var 变量名: 类型 = 初始值;

示例

1
2
var age: u32 = 18;   // u32 表示“无符号32位整数”
age = 19;            // ✅ 可以修改

4.3 不可变常量(const)

常量(Constant):在 Zig 中不一定是内存位置,更像是“值的别名”,编译器会尽可能在编译时直接算出结果。

语法

1
const 常量名: 类型 = 初始值;

示例

1
2
const PI: f64 = 3.14159;  // f64 是双精度浮点数
PI = 3.14;                 // ❌ 编译错误!常量不能修改

4.4 未初始化的变量(undefined)

有时候你需要先声明变量、稍后再赋值,可以用 undefined 占位:

1
2
var x: u32 = undefined;  // x 暂时未初始化
x = 42;                  // 之后再赋值

⚠️ 严重警告:读取未初始化的变量是未定义行为(UB)!

🔴 特别警示:undefinedbool 和指针的危险

1
2
3
4
5
6
var b: bool = undefined;
// 此时 b 的内存中可能是 0、1,也可能是 3、5 或其他任意比特位

if (b) {  // ❌ 当 b 的值不是 0 或 1 时,直接触发 CPU 异常(SigTrap)!
    // 程序已经崩溃了,不会执行到这里
}

💀 避坑铁律bool 和指针类型的变量,绝对不要在赋值前读取。undefined 只应作为“占位符”——但在真正使用前,必须赋予有效值。

正确做法

1
2
3
var b: bool = undefined;  // 先占位
b = true;                 // 使用前赋值
if (b) { ... }            // ✅ 安全

4.5 类型推断

⚠️ 重要澄清

1
2
3
var x = 10;        // ⚠️ 类型推断为 i32(默认整数类型),值是编译期常量
const y = 10;      // ✅ 类型为 comptime_int(真正的编译期整数,无固定位宽)
const z: u32 = 10; // 显式指定类型为 u32
写法实际类型说明
var x = 10;i32赋值给 var 时,comptime_int降级为默认整数 i32
const y = 10;comptime_int无固定位宽的编译期整数,可参与任意精度运算
const z: u8 = 10;u8显式类型注解决定了最终类型

📌 核心认知comptime_int 只有在 const 且未显式指定类型时才“存活”。一旦赋给 var 或参与运行时运算,就会具体化为固定位宽类型。

🔧 调试技巧:查看变量的实际类型

1
2
3
4
5
6
7
8
const x = 10;
const y: u32 = 10;
const z = @as(i16, 20);

// 这些会在编译时输出类型信息,并中断编译(仅用于调试!)
@compileLog(@TypeOf(x));  // 输出:comptime_int
@compileLog(@TypeOf(y));  // 输出:u32
@compileLog(@TypeOf(z));  // 输出:i16

⚠️ 注意@compileLog 会让编译失败并打印信息,仅用于调试!调试完后要删除或注释掉。

五、标识符命名规则

一句话:给变量、函数、类型起名字的规则。

5.1 基础硬性规则(必须遵守)

  1. 开头:字母a-zA-Z)或下划线_
  2. 后续:字母、数字、下划线
  3. 不能与关键字同名(如 varconstifwhile 等)
1
2
3
4
const valid1 = 1;      // ✅ 字母开头
const _valid2 = 2;     // ✅ 下划线开头
const valid3_test = 3; // ✅ 包含下划线
const var = 4;         // ❌ 编译错误:var 是关键字,不能当名字

5.2 Zig 的“逃生舱”:@"" 转义(特殊情况)

如果你必须使用关键字(比如调用 C 库里的 if 函数,或 JSON 字段名叫 type),Zig 提供了 @"" 语法强制转义:

1
2
3
4
const @"if" = 10;                    // ✅ 允许("if" 本来不能当名字)
const @"my var" = 20;                // ✅ 允许(甚至可以有空格)
const @"变量" = 30;                  // ✅ 允许(中文也行)
const obj = struct { @"type": u32 }; // ✅ 结构体字段名叫 type

📌 注意:使用转义标识符时,调用处也要带 @""

5.3 命名风格惯例(区分硬性 vs 软性)

类别强制还是惯例要求
类型(struct/enum/union)强制必须大写字母开头(PascalCase),否则编译报错
变量(var/const)建议snake_case(全小写+下划线),编译器不检查
函数(fn)建议snake_case,编译器不检查
1
2
3
4
5
6
const MyStruct = struct {};   // ✅ 类型必须大写开头
const myStruct = struct {};   // ❌ 编译错误!

var my_var: i32 = 1;          // ✅ 社区推荐 snake_case
var myVar: i32 = 1;           // ✅ 也能编译,但不符合惯例
const MY_CONST = 100;         // ✅ 全大写是惯例,非强制

六、解构赋值

6.1 这是什么?

一句话:把一个元组、数组或向量“拆开”,分别赋值给多个变量。

就像拆快递包裹:一个包裹里有三样东西(位置1、2、3),你可以把它们分别拿出来放到三个盒子里。

6.2 支持的数据类型

类型写法示例
元组(Tuple).{a, b, c}const t = .{1, 2, 3};
数组(Array)[_]T{a, b, c}const arr = [_]u32{4, 5, 6};
向量(Vector)@Vector(N, T)const v: @Vector(3, u32) = .{7, 8, 9};

6.3 基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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("x={}, y={}, z={}\n", .{x, y, z});  // x=1, y=2, z=3

// 数组解构
const array = [_]u32{ 4, 5, 6 };
x, y, z = array;
print("x={}, y={}, z={}\n", .{x, y, z});  // x=4, y=5, z=6

// 向量解构
const vector: @Vector(3, u32) = .{ 7, 8, 9 };
x, y, z = vector;
print("x={}, y={}, z={}\n", .{x, y, z});  // x=7, y=8, z=9

6.4 左侧可以怎么写?

左侧的列表由逗号分隔,每个位置支持以下写法:

写法含义
x使用已存在的变量 x(必须提前声明)
var a声明一个新的可变变量 a
const b声明一个新的不可变变量 b
_丢弃这个位置的值(不要了)

混合示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var x: u32 = undefined;      // 已存在的变量
const tuple = .{ 1, 2, 3 };

// x 用已有的,y 新声明(可变),z 新声明(不可变)
x, var y: u32, const z = tuple;

print("x={}, y={}, z={}\n", .{x, y, z});  // x=1, y=2, z=3

y = 100;   // ✅ y 是 var,可以改
// z = 200;  // ❌ z 是 const,不能改

// 丢弃第一和第三个值,只取第二个
_, x, _ = tuple;
print("x={}", .{x});  // x=2

6.5 ⚠️ 重要限制

解构赋值只能在块({ })内使用,不能在容器顶层(如 struct 内部)使用!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ✅ 正确:在函数体内(块内)
fn example() void {
    const t = .{1, 2, 3};
    var a, var b, var c = t;
}

// ❌ 错误:在结构体顶层(容器内)
const MyStruct = struct {
    const t = .{1, 2, 3};
    var a, var b, var c = t;  // 编译错误!
};

6.6 进阶:容器内的 comptime

虽然容器不能包含运行时语句,但可以包含 comptime——因为它在编译期执行,本质上是一种“声明期的计算工具”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const MyStruct = struct {
    // ❌ 运行时的语句不能放在容器内
    // var a, var b = .{1, 2};

    // ✅ 编译期的代码块是允许的
    comptime {
        const assert = @import("std").debug.assert;
        assert(@sizeOf(u32) == 4);  // 编译期断言
        @compileLog("MyStruct 已编译");  // 编译期打印
    }
};

💡 理解要点comptime 块就像“在编译时运行的函数”,它不产生运行时指令,只做类型检查、代码生成或编译期校验——所以容器允许它存在。

6.7 编译期解构

comptime 前缀,让它在编译时执行:

1
2
comptime var a, var b, const c = .{ 10, 20, 30 };
// a、b 变成 comptime var,c 变成 comptime const

一句话记忆:解构就是“拆包”——把右边的元组/数组/向量拆开,分别放进左边的变量里。

七、块(Block)与作用域

7.1 什么是块?

一句话{ } 花括号包裹的代码区域就是块。块用来限定变量的可见范围

7.2 作用域规则

块内声明的变量,块外不能访问:

1
2
3
4
5
{
    var x: i32 = 1;
    _ = &x;
}
// x += 1;  // ❌ 编译错误:x 已经“过期”了,访问不到

生活类比:就像房间里摆的东西,出了房间门就看不见也摸不着了。

7.3 块也是表达式(可以返回值)

块可以带一个标签(名字),然后用 break :标签 值 从块中返回数据:

1
2
3
4
5
6
7
var y: i32 = 123;

const x = blk: {      // blk 是这个块的标签(可以起任何名字)
    y += 1;
    break :blk y;     // 返回 y 的值,赋值给 x
};
// 执行后:y = 124,x = 124

标签名可以随意起result:compute:init: 都可以。

7.4 空块

{ } 空块等效于 void{},表示“什么都没有”。

一句话记忆:块 = {} 包围的代码区域,限制变量作用域,还能当表达式返回值。

八、变量遮蔽(Shadowing)

8.1 什么是变量遮蔽?

在内部作用域中,声明一个与外部作用域同名的变量,导致外部变量被“挡住”了——这就叫遮蔽。

其他语言(比如 JavaScript)允许

1
2
3
4
5
6
let x = 10;
{
  let x = 20; // ✅ 允许,内部 x 遮蔽了外部的 x
  console.log(x); // 输出 20
}
console.log(x); // 输出 10

8.2 Zig 的态度:坚决禁止!

1
2
3
4
const x = 10;
{
    const x = 20;  // ❌ 编译错误:变量 'x' 遮蔽了外部同名变量
}

8.3 为什么禁止?

  1. 防止写错变量:不小心操作了错误的同名变量,这种 Bug 很难追踪
  2. 代码更容易读懂:变量名在作用域内是唯一的,不迷路
  3. 强制清晰命名:逼你给不同用途的变量取不同的名字

8.4 唯一的例外

两个完全不重叠的作用域,可以使用同名变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ✅ 允许:两个不同函数,作用域不交叉
fn foo() void {
    const x = 10;
}
fn bar() void {
    const x = 20;  // 和 foo 的 x 不冲突
}

// ✅ 允许:两个先后执行的块,不重叠
{
    const temp = 1;
}
{
    const temp = 2;  // 第一个 temp 已经失效了,可以重用
}

一句话记忆:Zig 禁止变量遮蔽——同一作用域内,名字不能重复用。

九、容器(Container)与块的区别

这是初学者最容易混淆的概念,务必仔细看!

9.1 一句话区分

块(Block)容器(Container)
是什么执行代码的包裹体存放定义的“收纳盒”
放什么动作(语句)东西(声明)
能不能放 if/for✅ 能❌ 不能

9.2 什么是容器?

容器是 Zig 中存放声明的地方,包括:

  • struct(结构体)
  • enum(枚举)
  • union(联合)
  • opaque(不透明类型)
  • 整个 .zig 源文件

容器只能放声明(变量声明、常量声明、函数声明),不能放语句ifforwhile、赋值操作等)。

9.3 对比示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ===== 容器(Container)=====
const MyStruct = struct {
    // ✅ 允许:声明(常量、变量、函数)
    const PI = 3.14;
    var count: u32 = 0;

    fn getCount() u32 {
        return count;
    }

    // ❌ 错误:容器里不能放语句!
    // if (true) {}
    // for (0..10) |i| {}
    // count = 100;
};

// ===== 块(Block)=====
fn example() void {
    // ✅ 函数体本身就是一个块,可以放任何语句
    const PI = 3.14;              // 声明
    var sum: u32 = 0;             // 声明

    for (0..10) |i| {             // ✅ 语句:循环
        sum += i;
    }

    if (sum > 10) {               // ✅ 语句:条件判断
        sum = 10;
    }
}

9.4 📌 重要推论

Zig 源文件的顶层也是容器,所以:

  • ✅ 文件顶层可以放:constvarfnstructenumunionimport
  • ❌ 文件顶层不能放:ifforwhilereturn、赋值语句
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// ===== 文件顶层(也是一个容器)=====
const std = @import("std");      // ✅ 允许:导入声明
const PI = 3.14;                 // ✅ 允许:常量声明
var count: u32 = 0;              // ✅ 允许:变量声明

fn add(a: u32, b: u32) u32 {     // ✅ 允许:函数声明
    return a + b;
}

// if (true) {}                   // ❌ 错误:文件顶层不能放语句!
// count = 100;                   // ❌ 错误:文件顶层不能放语句!

pub fn main() !void {            // ✅ 函数里面是块,可以放语句
    count = 100;                 // ✅ 允许:在函数体内(块内)
    if (count > 0) {             // ✅ 允许:在函数体内(块内)
        std.debug.print("ok\n", .{});
    }
}

一句话记忆

  • 容器 = 放“有什么”(声明列表)
  • 块 = 放“做什么”(执行指令)
  • 源文件顶层是容器,函数体内部是块

十、注释

10.1 三种注释

符号名称说明
//普通注释给程序员看的备注,编译器忽略
///文档注释给“紧随其后的代码”写说明,用于生成 API 文档
//!顶层文档注释给“整个文件”写说明,放在文件开头

💡 实用提示:这些注释不仅是给人看的,Zig Language Server(zls)会在编辑器悬停提示时直接显示 /////! 的内容,是写库必备的技能。

10.2 示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
//! 顶层文档注释
//! 这个模块实现了时间戳的存储和操作功能
//!
//! 使用示例:
//! const ts = Timestamp.unixEpoch();

/// 存储时间戳的结构体,精度为纳秒
const Timestamp = struct {
    /// 自纪元开始后的秒数
    seconds: i64,  // 普通注释:1970年前的用负数表示

    /// 纳秒数(0 ~ 999,999,999)
    nanos: u32,

    /// 返回 Unix 纪元(1970-01-01 00:00:00 UTC)
    pub fn unixEpoch() Timestamp {
        return Timestamp{
            .seconds = 0,
            .nanos = 0,
        };
    }
};

10.3 ⚠️ 重要限制

//! 必须放在作用域的顶层,否则编译错误:

1
2
3
4
5
//! 顶层文档注释  ✅ 文件开头,正确

const S = struct {
    //! 顶层文档注释  ❌ 编译错误://! 必须在顶层
};

一句话记忆

  • // → 程序员之间的悄悄话
  • /// → 给这个“东西”写说明书
  • //! → 给这个“文件”写说明书

十一、线程局部存储(threadlocal)

11.1 这是什么?

一句话:每个线程都有一份独立的变量副本,互不干扰。

11.2 为什么需要它?

想象一个场景:你开了三个窗口(线程)同时在网站上买东西。每个窗口都有自己的“购物车”,互不影响——这就是线程局部存储。

11.3 用法

var 前面加 threadlocal

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const std = @import("std");
threadlocal var x: i32 = 1234;

fn testTls() void {
    std.debug.print("x = {}\n", .{x});  // 每个线程都从 1234 开始
    x += 1;
    std.debug.print("x = {}\n", .{x});  // 每个线程各自增加
}

fn main() !void {
    const t1 = try std.Thread.spawn(.{}, testTls, .{});
    const t2 = try std.Thread.spawn(.{}, testTls, .{});
    testTls();  // 主线程也执行一次
    t1.join();
    t2.join();
}

输出示例(顺序可能不同):

x = 1234  (主线程)
x = 1235  (主线程)
x = 1234  (线程1)
x = 1235  (线程1)
x = 1234  (线程2)
x = 1235  (线程2)

11.4 ⚠️ 限制

  1. 初始化值必须是编译期常量
1
2
3
// ❌ 编译错误:threadlocal 不能用运行时值初始化
var runtime_val: i32 = 10;
threadlocal var x: i32 = runtime_val;  // 错误!
  1. 单线程模式不支持:如果构建目标为 WebAssembly 或某些嵌入式系统(单线程),threadlocal 不可用,编译时会报错。

11.5 使用场景

  • 线程 ID
  • 数据库连接池的线程局部句柄
  • 线程私有的缓存或缓冲区
  • 不需要锁的线程安全计数器

一句话记忆threadlocal var x 让每个线程都有自己的 x,改自己的不影响别人。

十二、延迟执行(defer 与 errdefer)

12.1 什么是 defer?

一句话defer 后面的代码,会在当前作用域结束前执行——无论是正常退出、return,还是发生错误。

生活类比:就像下班离开办公室前,一定会锁门、关灯。不管你今天工作顺不顺利,这些善后动作都要做。

1
2
3
4
5
6
7
8
9
const std = @import("std");

fn example() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();  // ✅ 函数退出前,必定释放内存

    std.debug.print("工作中...\n", .{});
    // 即使中途 return 或出错,deinit 都会执行
}

12.2 defer 的执行顺序

后进先出(LIFO):像栈一样,后定义的 defer 先执行。

1
2
3
4
5
6
7
8
9
{
    defer std.debug.print("First\n", .{});
    defer std.debug.print("Second\n", .{});
    std.debug.print("Inner\n", .{});
}
// 输出:
// Inner
// Second   ← 后定义先执行
// First    ← 先定义后执行

12.3 errdefer——只处理错误场景

一句话:只有当作用域因错误退出时,errdefer 才会执行。正常退出时它不执行

1
2
3
4
5
6
7
fn openAndRead() !void {
    const file = try std.fs.cwd().openFile("data.txt", .{});
    errdefer file.close();  // 只有出错时才关闭文件

    // 读取操作...
    // 如果成功,文件会在正常流程中关闭;如果出错,errdefer 兜底关闭
}

12.4 对比速查

关键字触发时机典型用途
defer作用域结束时(无论是否出错释放分配器、解锁互斥锁、关闭文件
errdefer作用域因错误退出时出错时关闭文件、回滚事务

一句话记忆defer 保命,errdefer 保错误现场。

十三、下一步学什么

掌握以上内容后,建议继续学习:

  1. 基本类型:整数、浮点数、布尔值、可选类型(?T)、错误联合(!T
  2. 控制流ifwhileforswitch
  3. 函数:参数传递、返回值、comptime 参数
  4. 内存管理:分配器(Allocator)、切片(Slice)、数组
  5. 结构体与方法struct 的字段、方法、默认值
  6. 错误处理trycatcherrdefer(已在第十二节中初步接触)
  7. 编译期编程comptime@ 内置函数、泛型

📌 提前告知:Zig 的“泛型”不像 C++ 的模板或 Java 的泛型。它是通过 anytype + comptime 参数实现的——本质上是在编译期“把类型当作参数传递”。等你学到 comptime 时,会发现它异常强大。

📌 总复习:核心要点速查

知识点一句话总结
var声明可变变量,可以修改
const声明不可变变量,不能修改
undefined占位符,使用前必须赋值;bool/指针尤其危险
类型推断var x = 10i32const y = 10comptime_int
@compileLog(@TypeOf(x))编译期打印变量类型(调试工具)
标识符规则字母/下划线开头,不能是关键字;@"" 可以转义
类型命名必须大写开头(PascalCase),否则报错
变量/函数命名建议 snake_case,但编译器不强制
解构赋值x, var y, const z = tuple; 拆包赋值
解构位置限制只能在块({})内使用
块(Block){} 包裹的作用域,可以返回值
变量遮蔽Zig 禁止遮蔽同名变量
容器(Container)struct/enum/源文件顶层,只能放声明,不能放语句
容器内 comptime 块✅ 允许,因为它在编译期执行
块 vs 容器块放“动作”(语句),容器放“东西”(声明)
注释// 普通,/// 文档,//! 文件文档
threadlocal每个线程有独立副本,互不干扰;初始化必须是编译期常量
defer作用域结束时执行(无论成败)
errdefer仅当作用域因错误退出时执行