最近几周业余时间一直在开发一个小工具:
这篇文章主要想来分享一下,开发 simargs 过程中学习到的经验,便于自己查漏补缺。如果读者对 Zig 感兴趣, 欢迎加入 ZigCC 大家庭,分享 Zig 使用心得。
为什么重新造轮子
首先介绍下项目背景,Zig 社区其实有不少类似工具,但笔者都不是很满意,比如下面两个 star 比较多的:
- Hejsil/zig-clap,功能丰富,类似 Rust 生态里面的 clap。 但是使用方式笔者不是很喜欢,它是让用户提供 help 信息,然后去利用 comptime 去解析 help,得到需要解析的字段。
MasterQ32/zig-args 与 zig-clap 相同,充分利用 comptime 特性。不同的是,它的 输入参数是
struct
,通过解析这个结构体来生成需要解析的字段,示例:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
const options = argsParser.parseForCurrentProcess(struct { // This declares long options for double hyphen output: ?[]const u8 = null, @"with-offset": bool = false, mode: enum { default, special, slow, fast } = .default, // This declares short-hand options for single hyphen pub const shorthands = .{ .S = "intermix-source", }; }, argsAllocator, .print) catch return 1; defer options.deinit(); std.debug.print("executable name: {?s}\n", .{options.executable_name}); std.debug.print("parsed options:\n", .{}); inline for (std.meta.fields(@TypeOf(options.options))) |fld| { std.debug.print("\t{s} = {any}\n", .{ fld.name, @field(options.options, fld.name), }); }
个人比较倾向于 zig-args
的方式,感觉更优雅。但看了它的实现后,就放弃了。它的 parse 逻辑太复杂了,感觉非常不好维护。
相比之下,zig-clap 的实现就漂亮许多,它内部通过维护一个状态机来解析命令行参数,根据前置状态与当前参数,决定发生的动作与下一状态, 基于状态机的编程对于这种条件比较多的场景非常适合,关于状态机编程,记得最早还是看得是下面这篇博客,推荐给大家:
simargs 算是对这两个项目的结合:具有以下特点:
- 基于 struct 配置
- 充分利用 comptime 特性
- 基于状态机实现,保证解析逻辑的简洁
项目背景就介绍到这里,下面主要阐述 Zig 编程经验的总结。
如何动态对 struct 字段赋值
一般来说,对一个 struct 字段的赋值是比较直接的,比如:
|
|
开发 simargs 遇到的第一个问题就是如何动态赋值,struct 的字段名是通过解析命令行参数得到的。
在 Zig 中有 @field
这个特殊操作符来支持动态读取、设置字段内容,但要求参数是 comptime 的
|
|
而命令行参数是运行时得到的,怎么解决这个矛盾呢?答案是 inline for
:
|
|
inline for
想对比 for
,会对循环的内容进行 unroll,即展开,相当于手动写了多个 if 判断。
而且由于 fields
方法返回的是 Type.StructField
类型,它里面有个 type
字段,这就要求
需要在 comptime 时确定它的值,因此上面的 inline for
展开后的代码逻辑如下:
|
|
这就解决了动态对 struct 字符赋值的问题。
如何初始化值未知的 struct
一般来说,对于一个 struct
,初始化时需要对所有字段进行赋值,但对于用户输入的 T
,其值
是动态在运行时确认的,那么该如何正常的初始化这个值呢?答案是:undefined。
在 Zig 中, undefined
专门用来处理当前值未知情况下的赋值问题,在使用这个值前必须进行正确的
赋值,否则会出现 Undefined Behavior。
Zig 比较贴心,在 Debug 编译模式下,会对 undefined
的变量写入 0xaa
,如果使用了这个值,
编译器会报错。
需要明确一点,程序中无法判断一个值是否为 undefined
,上面也说了,读取 undefined
变量
属于 UB 行为,因此需要用额外字段来标明某个变量是否被初始化,simargs 中就是采用这种方式来处理
『必要参数』没有被赋值的情况。
基于类型的编程
在 Zig 中类型是一等成员,可以当作普通数据来处理,语言内置了如下三个操作符
@typeInfo(comptime T: type) std.builtin.Type
获取一个类型的具体信息,类似于其他语言的反射。Type 是个枚举值,通过模式匹配来确定具体类型, 在 simargs 中大量使用。@Type(comptime info: std.builtin.Type) type
@typeInfo
的逆向操作,把 Type 声明的类型转化为抽象的 type。在 simargs 中没有用到, 但是 zig-clap 中有用到,因为它需要根据 help 信息动态的创建一个 type 出来。@TypeOf(...) type
获取一个值的类型,simargs 中大量使用。
类型是一等成员的例子在 Zig 中出处可见,比如初始化一个字符串链表:
|
|
对应 Rust 来说,是
|
|
可以看到,在 Rust 中需要用特殊的语法形式来传入类型信息,但在 Zig 中则不需要这种特殊处理。
面向接口编程
在其他编程语言中,一般会提供行为的抽象,比如 Java 中的 interface,Rust 中的 trait。 但是 Zig 中没有直接提供这方面的支持,社区内一直有相关 issue 讨论,例如:
- vtable abstraction of some kind. traits? oop? polymorphism? interfaces? #130
- comptime interfaces · Issue #1268 · ziglang/zig
但也不是说完全不能实现,在 Zig 中,函数的参数类型可以是 anytype
,这意味着这个参数的具体类型
会延迟到调用处确定,和 Rust 中泛型类似,示例:
|
|
标准库中的 Writer 就是利用这个特性来实现的“接口”抽象:
|
|
但是 anytype 用法也比较局限,只能用在函数参数,不能用做 struct 的字段,这样就意味着我们无法 保存一个“接口”在 struct 中。
在最早实现 simargs 时,用的是 argsWithAllocator
来解析命令行参数,它会返回一个 ArgIterator
,
但由于是具体类型,所以在测试时不是很方便,目前在 simargs 中的做法是调用 std.process.argsAlloc
得到
一数组 args: [][:0]u8
,这样在测试时,直接构造出这个数组即可。最后在 deinit
时,根据
运行方式来决定是否调用 argsFree
|
|
如果测试的话,就忽略 argsFree
,由测试本身来做 free。
目前这种做法本质上还是属于静态派发,对于 simargs 来说是够用了,更多如何实现动态派发,可以参考:
如何调试 comptime 代码
一般调试代码时,可以通过 std.debug.print
来解决,但是对于 comptime 代码块来说这并不有效,
Zig 提供了 @compileLog
来解决打印问题,但对于 []const u8
打印的是 ASCII 码,并不是很
直观,可以通过 std.fmt.comptimePrint
来解决,用法示例:
|
|
如何得到 mutable 的指针变量
在使用 for 循环一个切片/数组时,捕获的值是 by-value 的拷贝,如果想在循环内部修改这个值,可以用下面这种方式:
|
|
另外有一点需要注意,在 Zig 0.10 之后,直接对 struct 字面量取地址,得到的是不可变的指针,想要得到可变指标,需要先把字面量用 var
赋值,参考:
expectEqual
在写测试时,一般会用到 expectEqual
来进行断言判断,但目前 Zig 中有一个“bug”,导致 expectEqual 时,
只会对 struct 的切片进行指针判断,而不是内容判断。
这个时候,就只能对 struct 的字段进行一一判断,对于字符串来说,需要手动调用 expectEqualStrings
。相关 issue:
文档
截止到目前,Zig 的文档基本上属于残废状态,因此在开发时,不可避免的要去看标准库的源码实现, 这一点读者要明确,好在标准库的质量比较高,配合 ZLS 阅读还算方便。
一般我常用的搜索方式就是 pub fn
看看某个文件暴露的方法有哪些,函数的用户一般可以在单测里找到。
开发时常用到的包有:
- std.mem 切片相关操作
- std.fmt 格式转化,字符串拼接
一个小技巧,对于返回 []u8
的函数,一般会有另一个返回 [:0]u8
,方便与 C 进行交互。比如:
std.fmt.allocPrint
,std.fmt.allocPrintZ
std.mem.join
,std.mem.joinZ
ZLS
ZLS 是目前写 Zig 的唯一选择,虽然也有 ctags 的讨论,但 isaachier/ztags 已经被原作者归档起来了。
相比隔壁的 rust-analyzer,ZLS 的稳定性、实用性要差些,经常出现编辑后,一些函数没法跳转的情况。 这种情况下只能重启解决,,不过好在 rust-analyzer 核心开发者 matklad 最近也开始写 Zig 了, 并且已经开始给 ZLS 贡献代码,相信 ZLS 会越来越好用。
Zig-mode
如果使用 Emacs 来写 Zig,zig-mode 是必不可少的,但是它现在实现 format-on-save
的方式
有些问题,会造成卡顿等问题,对应 PR 已经有了,但是还没合并到主分支,可以先手动更新:
总结
总体来说,使用 Zig 开发的体验还是可以的,各种工具都有,虽然都不是很完美,但不影响使用, 遇到问题要有耐心来 debug。
通过 simargs 这个项目,笔者对 Zig 的 comptime 的特性有了进一步理解,光是这一点就觉得 这几个周末的时间没有虚度,如果这个项目对 Zig 生态有一丝丝的帮助,那自然是再好不过的了。😇