Skip to content

【Zig 日报】我是如何爱上 Zig 的诊断模式的 #277

@jiacai2050

Description

@jiacai2050

我坦白,我真的很喜欢 Zig。

我玩 Zig 已经两年左右了,并且我终于非常接近发布至少一个使用它的项目。

但起初有一件事让我觉得很奇怪——我已经习惯了通过返回联合类型(比如 Rust 中的 Result)来处理错误。但是 Zig 的错误联合不携带有效载荷,只有没有错误上下文的错误代码。

所以我尝试推出自己的错误联合。毕竟 Zig 有标签联合(就像 Rust 中的枚举),这有多难?

总而言之:我现在看到了 Zig 中替代错误联合有效载荷的智慧,即诊断模式。

诊断模式

如今推荐的习惯用法,诊断模式,建议你的错误返回函数应该接受一个额外的参数,该参数是指向用于填充错误上下文的结构的指针。

假设我们正在编写一个 JSON 解析器,我们可能想要存储遇到错误时的位置。我们将像这样处理来自此类解析器的错误:

const Diagnostic = struct {
    position: usize = 0,
};

test "good json for normal zig errors" {
    var diagnostic: Diagnostic = .{};
    _ = try parseJson(std.testing.allocator, "[1, 2]", &diagnostic);
}

test "bad json for normal zig errors" {
    var diagnostic: Diagnostic = .{};
    const result = parseJson(std.testing.allocator, "[1, 2, invalid]", &diagnostic);
    // we must check there was an error before we can use the diagnostic
    try std.testing.expectEqual(result, error.InvalidToken);
    try std.testing.expectEqual(diagnostic.position, 7);
}

有些人不喜欢它,因为参数与返回值之间的脱节使得很容易忘记使用该模式,或者只是忘记填充诊断信息。Zig 标准库历史上也经常忘记返回错误上下文,可能就是因为这个原因(如果 Zig 足够旧/稳定以至于有“历史”)。

那么,我们可以在不添加参数的情况下获得错误上下文吗?我们想这样做吗?

我们可以“只是”使用 Zig 的标签联合语言支持来制作我们自己的联合,其中包含一个(或多个)错误状态,然后返回它。但是这样你就会错过使用 Zig 的内置 trycatcherrdefer 来处理错误。

在具有更多隐藏控制流的语言中,例如 Rust,您可以扩展 ? 运算符,以便使用用户定义的类型进行符合人体工程学的错误处理。

在实践中,我认为缺少 try/catch 最终是可行的,但稍后会详细介绍 errdefer

开始了:

pub fn Result(comptime R: type, comptime E: type) type {
    return union(enum) {
        ok: R,
        err: E,
    };
}

fn parseJsonResult(alloc: std.mem.Allocator, json_src: []const u8) Result(JsonValue, Diagnostic) {
  //...
}

test "good json for our result type" {
    const result = parseJsonResult(std.testing.allocator, "[1, 2]");
    try std.testing.expect(result == .ok);
}

test "bad json for custom result type" {
    const result = parseJsonResult(std.testing.allocator, "[invalid]");
    try std.testing.expect(result == .err);
    try std.testing.expectEqual(result.err.position, 1);
}

因此,惯用的 trycatch 变得更加麻烦。

fn useParseJson(alloc: std.mem.Allocator, diagnostic: ?*Diagnostic) void {
  const json = try parseJson(alloc, "[]", diagnostic);
}

fn useParseJsonResult(alloc: std.mem.Allocator) Result(void, Diagnostic) {
  // you can also use a switch for no extra variable but imo its ugly
  const result = parseJsonResult(alloc, "invalid_json");
  const json = if (result == .ok) result.ok else return result;
  return Result(void, Diagnostic){.ok={}};
}

errdefer 更糟糕,但未来可能会变得更好。

请记住,在某些情况下,errdefer 非常重要,例如,当您分配了一些东西以返回给调用者拥有时,但现在他们只收到错误,并且您需要销毁部分成功的结果,以免泄漏资源。

fn errdeferParseJson(alloc: std.mem.Allocator, diagnostic: ?*Diagnostic) std.ArrayList([]const u8) {
  const result = std.ArrayList([]const u8).init(alloc);
  errdefer result.deinit();
  const json = try parseJson(alloc, "[]", diagnostic);
  // ...use json to build the result somehow
  return result;
}

fn errdeferParseJsonResult(alloc: std.mem.Allocator) Result(std.ArrayList([]const u8), Diagnostic) {
  var ok = std.ArrayList([]const u8).init(alloc);
  var result = Result(std.ArrayList([]const u8), Diagnostic) = .{.err = .{}};
  defer if (result == .err) ok.deinit();

  const json = switch (parseJsonResult(alloc, "[]")) {
    .ok => |o| o,
    .err => |e| {
      result = .{.err = e },
      return result;
    },
  };

  result = Result(std.ArrayList([]const u8), Diagnostic) = .{.ok = ok};
  return result;
}

在您返回错误的每个地方,您必须先手动设置结果变量,然后再返回它,以确保我们的 errdefer 替代品(defer if (result == .err) ...)生效。

这很冒险。随着代码库的变化,有人可能会完全忘记这个未强制执行的约定并直接返回错误,从而使 Result 处于意外的有效状态并导致泄漏。

而且这有很多样板代码。也许诊断模式并没有那么糟糕?

未来用于 errdefer 手动实现的错误联合

将来,我们可能会获得一种语言内置功能来直接访问返回值。这可能具有允许我们在 defer 语句期间访问返回值的副作用,从而无需手动确保我们始终返回正确的变量。

我们最终会得到更好的:

fn errdeferFutureParseJsonResult(alloc: std.mem.Allocator) Result(std.ArrayList([]const u8), Diagnostic) {
  var result = std.ArrayList([]const u8).init(alloc);
  defer if (@return() == .err) result.deinit();

  const json = switch (parseJsonResult(alloc, "[]")) {
    .ok => |o| o,
    .err => |e| return .{.err = e },
  };

  return Result(std.ArrayList([]const u8), Diagnostic){.ok = result};
}

我会说这突然变得非常合理。

哪个更好?

现在我们已经彻底比较了这两种选择的代码,哪个胜出?

在我看来,诊断模式出奇地简单,样板代码很少,并且与当今的语言很好地集成在一起。

Result 类型的主要优点是它始终要求在错误情况下进行完全初始化,这使其不易忘记设置错误上下文。

诊断模式的另一个优点是不太明显,那就是它非常适合导出的函数。

外部联合不能被自动标记,因此在 C API 中返回它们需要一些工作。但是只需在 Diagnostic 结构上添加一个外部标签,您就可以非常直接地导出我们已经使用诊断模式编写的任何函数。

最近我在将我正在进行的项目从我的手动结果模式转换为诊断模式时感受到了这一点,并且导出的 C API 变得更加简单。

特别是对于 Zig 及其强大的 C 互操作性,我发现自己经常导出精心设计的 C API,因此我发现这很有用。

如果该语言进行一些更改,结果模式可能会很好地适应,但在尝试了两种模式之后,我认为它不值得付出复杂性。

我现在对诊断模式感到更加满意。

How I learned to love Zig's diagnostic pattern | Mike Belousov's Website

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:

  1. 供稿,分享自己使用 Zig 的心得
  2. 改进 ZigCC 组织下的开源项目
  3. 加入微信群Telegram 群组

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions