使用 Zig 开发 simargs 经验总结

发布: 2022-12-13   上次更新: 2022-12-17   分类: 编程语言   标签: zig

文章目录

最近几周业余时间一直在开发一个小工具:

这篇文章主要想来分享一下,开发 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 字段的赋值是比较直接的,比如:

1
foo.field = 123;

开发 simargs 遇到的第一个问题就是如何动态赋值,struct 的字段名是通过解析命令行参数得到的。 在 Zig 中有 @field 这个特殊操作符来支持动态读取、设置字段内容,但要求参数是 comptime 的

1
@field(lhs: anytype, comptime field_name: []const u8) (field)

而命令行参数是运行时得到的,怎么解决这个矛盾呢?答案是 inline for

1
2
3
4
5
6
    inline for (std.meta.fields(T)) |field| {
        if (std.mem.eql(u8, field.name, long_name)) {
            @field(opt, field.name) = ....;
            break;
        }
    }

inline for 想对比 for ,会对循环的内容进行 unroll,即展开,相当于手动写了多个 if 判断。 而且由于 fields 方法返回的是 Type.StructField 类型,它里面有个 type 字段,这就要求 需要在 comptime 时确定它的值,因此上面的 inline for 展开后的代码逻辑如下:

1
2
3
4
5
6
7
8
// 假设 T 有 A/B 两个字段
if (std.mem.eql(u8, "A", long_name)) {
    @field(opt, "A") = ....
}

if (std.mem.eql(u8, "B", long_name)) {
    @field(opt, "B") = ....
}

这就解决了动态对 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 中出处可见,比如初始化一个字符串链表:

1
std.ArrayList([]const u8).init(allocator);

对应 Rust 来说,是

1
Vec::<String>::new()

可以看到,在 Rust 中需要用特殊的语法形式来传入类型信息,但在 Zig 中则不需要这种特殊处理。

面向接口编程

在其他编程语言中,一般会提供行为的抽象,比如 Java 中的 interface,Rust 中的 trait。 但是 Zig 中没有直接提供这方面的支持,社区内一直有相关 issue 讨论,例如:

但也不是说完全不能实现,在 Zig 中,函数的参数类型可以是 anytype ,这意味着这个参数的具体类型 会延迟到调用处确定,和 Rust 中泛型类似,示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
test "anytype demo" {
    const a = addFortyTwo(@as(u32, 1));
    try std.testing.expectEqual(u32, @TypeOf(a));
    const b = addFortyTwo(@as(f32, 1));
    try std.testing.expectEqual(f32, @TypeOf(b));
}

fn addFortyTwo(x: anytype) @TypeOf(x) {
    return x + 42;
}

标准库中的 Writer 就是利用这个特性来实现的“接口”抽象:

1
2
3
pub fn bufferedWriter(underlying_stream: anytype) BufferedWriter(4096, @TypeOf(underlying_stream)) {
    return .{ .unbuffered_writer = underlying_stream };
}

但是 anytype 用法也比较局限,只能用在函数参数,不能用做 struct 的字段,这样就意味着我们无法 保存一个“接口”在 struct 中。

在最早实现 simargs 时,用的是 argsWithAllocator 来解析命令行参数,它会返回一个 ArgIterator , 但由于是具体类型,所以在测试时不是很方便,目前在 simargs 中的做法是调用 std.process.argsAlloc 得到 一数组 args: [][:0]u8 ,这样在测试时,直接构造出这个数组即可。最后在 deinit 时,根据 运行方式来决定是否调用 argsFree

1
2
3
if (!@import("builtin").is_test) {
    std.process.argsFree(self.allocator, self.raw_args);
}

如果测试的话,就忽略 argsFree ,由测试本身来做 free。

目前这种做法本质上还是属于静态派发,对于 simargs 来说是够用了,更多如何实现动态派发,可以参考:

如何调试 comptime 代码

一般调试代码时,可以通过 std.debug.print 来解决,但是对于 comptime 代码块来说这并不有效, Zig 提供了 @compileLog 来解决打印问题,但对于 []const u8 打印的是 ASCII 码,并不是很 直观,可以通过 std.fmt.comptimePrint 来解决,用法示例:

1
@compileLog(comptime std.fmt.comptimePrint("field name:{s}\n", .{field.name}));

如何得到 mutable 的指针变量

在使用 for 循环一个切片/数组时,捕获的值是 by-value 的拷贝,如果想在循环内部修改这个值,可以用下面这种方式:

1
2
3
4
5
    var arr = [_]i32{ 1, 2, 3 };

    for (arr) |*item| {
        std.debug.print("{any}\n", .{@TypeOf(item)});
    }

另外有一点需要注意,在 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 生态有一丝丝的帮助,那自然是再好不过的了。😇

扩展阅读

评论

欢迎读者通过邮件与我交流,也可以在 MastodonTwitter 上关注我。