4

使用 Zig 开发 simargs 经验总结

 1 year ago
source link: https://liujiacai.net/blog/2022/12/13/argparser-in-zig/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

这篇文章主要想来分享一下,开发 simargs 过程中学习到的经验,便于自己查漏补缺。如果读者对 Zig 感兴趣, 欢迎加入 ZigCC 大家庭,分享 Zig 使用心得。

为什么重新造轮子

首先介绍下项目背景,Zig 社区其实有不少类似工具,但笔者都不是很满意,比如下面两个 star 比较多的:

  • Hejsil/zig-clap,功能丰富,类似 Rust 生态里面的 clap。 但是使用方式笔者不是很喜欢,它是让用户提供 help 信息,然后去利用 comptime 去解析 help,得到需要解析的字段。
  • MasterQ32/zig-args 与 zig-clap 相同,充分利用 comptime 特性。不同的是,它的 输入参数是 struct ,通过解析这个结构体来生成需要解析的字段,示例:

      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 字段的赋值是比较直接的,比如:

foo.field = 123;

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

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

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

    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 展开后的代码逻辑如下:

// 假设 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 中出处可见,比如初始化一个字符串链表:

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

对应 Rust 来说,是

Vec::<String>::new()

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

面向接口编程

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

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

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 就是利用这个特性来实现的“接口”抽象:

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

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 来解决,用法示例:

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

如何得到 mutable 的指针变量

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

    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 是目前写 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 生态有一丝丝的帮助,那自然是再好不过的了。😇


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK