乐于分享
好东西不私藏

Zig @src():源码位置反射的极致用法( 第55篇 )

Zig @src():源码位置反射的极致用法( 第55篇 )

大家好,我是Zachel,欢迎来到 Zig 源码学习系列第55篇!

我最近又把Zig 0.16的源码翻了个底朝天,这次挖到了一个看起来平平无奇,却能玩出无数花活的内置函数——@src()。我第一次看到它在标准库和编译器源码里的各种用法时真的惊呆了,原来编译期的源码位置反射,能做到这么温柔又强大,直接把我以前写C/C++时打日志、做断言、抛错误的各种痛点全解决了!

1. 先聊聊:我们为什么这么需要源码位置?

姐妹们写代码的时候,肯定都遇到过这些场景:调试bug时打日志,想知道是哪一行打的;断言失败了,想精准定位到出错的代码行;线上服务报错了,想快速定位到触发错误的位置;甚至写测试框架时,要标记哪个用例在哪一行失败了。

以前写C的时候,我们只能靠__FILE__、__LINE__、__func__这些宏,可宏的坑真的太多了:嵌套宏里行号会错乱,没法封装成类型安全的函数,想捕获调用者的位置必须写一堆黑魔法宏,稍不注意就出问题。C++20加了std::source_location,又有编译器兼容问题,用起来束手束脚。

而Zig的@src(),直接把源码位置反射做到了极致——它是编译器内置函数,零运行时开销,编译期就能用,还能靠Zig的默认参数特性,一行代码就优雅捕获调用者的位置,完全没有宏的那些破事,谁用谁上头。

2. 源码深度扒皮:@src()在编译器里到底是怎么实现的?

我们先从最基础的定义看起,@src()的返回值类型,定义在lib/std/builtin.zig里,这是Zig和编译器同步的内置类型定义,0.16版本的源码是这样的:

// lib/std/builtin.zig/// 这个结构体和编译器实现严格同步,@src()会返回它的实例pub const SourceLocation = struct {/// 编译时指定的模块名,不是文件路径module: [:0]const u8,/// 相对于模块根目录的源文件路径    file: [:0]const u8,/// 所在函数的名称,顶级作用域则为空字符串    fn_name: [:0]const u8,/// 1-based的行号    line: u32,/// 1-based的列号    column: u32,};

姐妹们快看,这个结构体把我们需要的源码信息全给齐了,而且所有字段都是编译期已知的常量,这是它所有魔法的基础。

接下来就是核心实现了,@src()作为编译器内置函数,它的处理逻辑在Zig编译器的语义分析核心src/Sema.zig里,对应的处理函数是semaBuiltinSrc。我把0.16版本里的核心逻辑精简出来,给大家逐行讲解:

// src/Sema.zig 语义分析核心,处理@src()内置函数fn semaBuiltinSrc(    sema: *Sema,    block: *Block,    inst: Air.Inst.Index,    call: *ast.CallExpr,) CompileError!Air.Inst.Ref {// 1. 先做参数检查:@src()是无参函数,传参数直接报错if (call.args.len != 0) {return sema.err(call, "@src() takes no arguments", .{});    }// 2. 从当前AST节点直接获取源码位置// 编译器在解析源码的时候,已经把每个节点的位置信息存在AST里了const src_loc = sema.srcLoc(call.ast_node);// 3. 获取当前所在的函数名,如果是顶级作用域就返回空字符串const fn_name = if (block.scope.cur_fn) |func|        func.owner_decl.name()else"";// 4. 构造编译期的SourceLocation常量实例const ty = try sema.getBuiltinType(call.ast_node, .SourceLocation);const result = try sema.addConstant(.{        .ty = ty,        .val = try sema.arena.create(Value.fromSourceLocation(            sema.arena,            src_loc.module,            src_loc.file,            fn_name,            src_loc.line,            src_loc.column,        )),    });// 5. 直接返回编译期常量,完全不生成任何运行时代码return result.ref;}

整个实现真的太干净了!我第一次看到这里的时候,真的被Zig的设计惊艳到了——编译器遇到@src()的时候,根本不会生成任何运行时代码,而是在语义分析阶段,直接从当前AST节点里把源码信息全拿出来,打包成一个编译期常量返回。

也就是说,你写的@src(),在编译完成后,就已经是一个写死的结构体常量了,零开销,零运行时计算,哪怕在release-fast模式下,也能完整保留你需要的位置信息,完全不影响性能。

3. 核心知识点拆解:@src()的强大,藏在这些细节里

很多姐妹刚用@src()的时候,只觉得它能打印个行号,其实它的核心魔力,都藏在这几个细节里:

① 词法位置捕获,默认参数的温柔设计

@src()拿到的是它在代码里的词法位置,也就是你写它的那一行的位置。但Zig有个超级温柔的设计:当@src()作为函数的默认参数时,它会在调用者的上下文里求值,而不是函数定义的上下文里

这是什么意思呢?就是你把@src()写在函数参数的默认值里,调用这个函数的时候,@src()拿到的是你调用函数的那一行的位置,而不是函数定义里的位置!这就是它能优雅捕获调用者位置的核心,也是我第一次看到的时候直接哇出来的设计。

② 100%编译期可用,全场景覆盖

@src()的返回值是完全编译期已知的,所以你可以把它用在任何需要comptime值的地方:@compileError里、comptime代码块里、泛型参数里,甚至用它来做编译期的逻辑判断、生成唯一ID。C的__LINE__虽然也能在编译期用,但根本没法这么灵活地封装成函数,只能靠宏硬凑。

③ 类型安全,无宏的任何坑

它是正经的编译器内置函数,不是文本替换的宏,不会出现C里宏嵌套导致行号错乱、符号冲突的问题。返回的是有明确字段的结构体,类型安全,随便嵌套调用、跨文件调用都不会出问题,完全不用像写C宏那样小心翼翼地加括号、处理符号冲突。

4. 上手就会:从基础到黑科技的3个可运行示例

给姐妹们准备了3个完整可运行的示例,从基础用法到进阶黑科技,复制到本地就能直接编译运行(基于Zig 0.16版本)。

示例1:基础用法,打印当前源码位置

conststd = @import("std");pub fn main()void{// 直接调用@src(),拿到当前行的源码位置const loc = @src();std.debug.print("=== 当前源码位置信息 ===\n", .{});std.debug.print("模块:{s}\n", .{loc.module});std.debug.print("文件:{s}\n", .{loc.file});std.debug.print("函数:{s}\n", .{loc.fn_name});std.debug.print("行号:{d}\n", .{loc.line});std.debug.print("列号:{d}\n", .{loc.column});}

运行结果会精准打印出你写这段代码的文件、行号、函数名,哪怕开了最高优化级别,也能正常输出,完全没有性能损耗。

示例2:进阶用法,捕获调用者位置的日志函数

这是我们日常开发里最常用的用法,用默认参数实现类型安全的日志函数,自动捕获调用者的位置,再也不用写宏了:

conststd = @import("std");// 自定义日志函数,@src()作为默认参数,自动捕获调用者位置fn log(    comptime level: []const u8,    comptime format: []const u8,    args: anytype,// 核心:@src()作为默认参数,在调用者上下文求值    comptime loc: std.builtin.SourceLocation = @src(),)void{std.debug.print("[{s}] [{s}:{d}] {s}: " ++ format ++ "\n", .{        level, loc.file, loc.line, loc.fn_name,    } ++ args);}pub fn main()void{log("INFO""程序启动成功", .{});const user_count: u32 = 100;log("DEBUG""当前用户数量:{}", .{user_count});// 嵌套函数里调用也能正确捕获位置const nested = struct {        fn doSomething() void {log("WARN""这是嵌套函数里的警告", .{});        }    };    nested.doSomething();}

运行结果里,每一行日志的位置,都是你调用log()函数的那一行,而不是log函数内部的位置!再也不用像C那样,为了捕获调用者位置写一堆丑陋的宏了。

示例3:黑科技用法,编译期断言+唯一ID生成

@src()在编译期的玩法才是真的离谱,给大家看一个编译期断言+唯一ID生成的例子:

conststd = @import("std");// 编译期断言,失败时输出精准的源码位置comptime fn compileAssert(    comptime cond: bool,    comptime msg: []const u8,    comptime loc: std.builtin.SourceLocation = @src(),)void{if (!cond) {// 编译期就把位置信息打进报错里,超级精准        @compileError(std.fmt.comptimePrint("编译断言失败:{s}\n位置:{s}:{d} (函数:{s})",            .{ msg, loc.file, loc.line, loc.fn_name }        ));    }}// 用@src()生成编译期唯一ID,同一行永远生成同一个ID,不同行绝对不重复comptime fn uniqueId(comptime loc: std.builtin.SourceLocation = @src()) u64 {    comptime var hash: u64 = 0;inlinefor(loc.file) |c| hash = hash * 31 + c;inlinefor(loc.module) |c| hash = hash * 31 + c;    hash = hash * 31 + loc.line;    hash = hash * 31 + loc.column;return hash;}// 编译期断言测试,打开注释会直接在编译期报错,带精准位置comptime {    compileAssert(1 + 1 == 2"数学不成立了?");// compileAssert(2 + 2 == 5, "2+2居然不等于5");}pub fn main()void{const id1 = uniqueId();const id2 = uniqueId();std.debug.print("唯一ID1: 0x{x}\n", .{id1});std.debug.print("唯一ID2: 0x{x}\n", .{id2});// 两个ID一定不一样,因为它们在不同的行}

这个例子里,编译期断言失败的时候,会直接在编译阶段就给你精准的报错位置,比C的静态断言好用100倍!而uniqueId()函数,能靠@src()生成全局唯一的编译期ID,做事件追踪、埋点、调试标记的时候超级好用。

5. 横向对比&源码彩蛋:这个设计到底赢在哪?

横向对比其他语言

语言
源码位置方案
核心痛点
C
FILE

/__LINE__宏
必须用宏,文本替换易出错,无法优雅封装
C++
std::source_location
C++20才支持,编译器兼容差,编译期灵活性不足
Rust
file!()/line!()宏
必须用宏才能捕获调用者位置,无法在普通函数里实现
Zig
@src()
内置函数,零开销,编译期全场景可用,默认参数优雅捕获调用者位置

源码里的小彩蛋

姐妹们天天用的std.debug.assert,它的底层就是靠@src()实现的!在lib/std/debug.zig里,0.16版本的源码就是这么简单:

// lib/std/debug.zigpub fn assert(ok: bool, comptime loc: SourceLocation = @src())void{if (!ok) {        panic("assertion failure", .{}, loc);    }}

我的天,就这么短短几行,就实现了C里要靠复杂宏才能实现的断言位置捕获,而且类型安全,不会有宏的任何坑,这个设计真的太温柔了!

不光是assert,Zig的内置测试框架、@panic实现、人性化编译错误提示,全都是靠@src()来实现精准的位置定位的,它真的是Zig里无处不在的隐形功臣。

6. 小结

@src()的灵魂,是用零开销的编译期反射,把源码位置的控制权完全交给开发者,用最优雅的方式解决了调试、日志、断言、错误上报里最头疼的位置捕获问题,温柔又强大。

好了,第55篇到此结束。

下篇预告:下篇我们会继续深挖Zig编译期的黑魔法,聊聊为什么Zig源码里到处都是@compileError,看看它是怎么把编译期错误提示做到又毒舌又人性化的。

如果你也被Zig的@src()这个设计惊艳到,或者用它玩出了什么好玩的花活,欢迎评论区贴出你的代码,我们一起扒源码~

Zachel | Zig进阶系列第55篇 我们下篇见!