Using Zig to Unit Test a C Application

Michael Lynch

使用 Zig 為 C 應用程式撰寫單元測試

Zig 是一個全新、獨立開發的低階程式語言。它是對 C 語言的現代化重新詮釋,試圖在保留 C 語言效能的同時,納入過去 30 年來工具與語言設計上的進步。

在我用過的所有語言中,Zig 讓呼叫 C 程式碼變得最為容易。Zig 同時也將單元測試視為一等公民功能,而 C 語言顯然並非如此。

Zig 的這兩項特性創造了一個有趣的機會:Zig 讓你能為現有的 C 程式碼加入單元測試。而且你不需要重寫任何 C 程式碼或建置邏輯就能做到。

為了示範如何使用 Zig 來測試現有的 C 程式碼,我為一個我每天都在使用的真實世界 C 應用程式加入了單元測試。

真實世界的 C 應用程式:uStreamer

過去三年來,我一直在開發 TinyPilot,這是一個開源的 KVM over IP 解決方案。TinyPilot 讓你可以將 Raspberry Pi 接到任何電腦上,然後遠端控制該電腦。

為了串流目標電腦的畫面,TinyPilot 使用了 uStreamer,這是一套針對 Raspberry Pi 硬體最佳化的視訊串流工具。

在瀏覽器視窗中顯示 Dell 開機畫面的 TinyPilot 螢幕截圖

TinyPilot 使用以 C 語言撰寫的 uStreamer 應用程式來串流視訊

我使用 uStreamer 已有數年,但我覺得它的程式碼庫難以親近。它是用 C 語言實作的,而且沒有任何自動化測試。

我最擅長透過動手修改程式碼來學習,因此透過 Zig 來操作 uStreamer 的 C 程式碼,感覺是同時深入了解 uStreamer 與 Zig 的好方法。

取得 uStreamer 原始碼

首先,我來取得 uStreamer 的原始碼。截至撰寫本文時,最新的版本是 v5.45,因此我會抓取該版本:

USTREAMER_VERSION='v5.45'
git clone \
  --branch "${USTREAMER_VERSION}" \
  https://github.com/pikvm/ustreamer.git

uStreamer 中最簡單的 C 函式是什麼?

在這次練習中,挑戰在於使用 Zig,因此我希望 C 的部分越簡單越好。

我想在 uStreamer 的 C 程式碼中找到一個極其簡單的函式——可以餵給它一些輸入,然後得到容易檢視的輸出。

瀏覽檔名時,我注意到了 base64.c。這聽起來很有希望。我知道 base64 是一種將任意資料編碼為可列印字串的編碼方式。

舉例來說,如果我從 /dev/random 讀取 10 個位元組到終端機,會得到一些無法列印的字元:

$ head -c 10 /dev/random > /tmp/output && cat /tmp/output
V�1A�����b

如果我將資料以 base64 編碼,就會得到乾淨、可列印的字元:

$ base64 < /tmp/output
Vo8xQbWmnsLQYg==

以下是 uStreamer 的 base64 函式的簽章:

// src/libs/base64.h

void us_base64_encode(const uint8_t *data, size_t size, char **encoded, size_t *allocated);

透過檢視該函式在 base64.c 中的實作,我推斷出 us_base64_encode 的語意如下:

  • data 是要用 base64 編碼方式進行編碼的輸入資料。
  • sizedata 緩衝區的長度(以位元組為單位)。
  • encoded 是指向輸出緩衝區的指標,us_base64_encode 會將 base64 編碼後的字串儲存在其中。
    • us_base64_encode 會為輸出配置記憶體,呼叫者在用完後有責任釋放該記憶體。
    • 嚴格來說,us_base64_encode 允許呼叫者自行為 encoded 配置緩衝區,但為了簡化,我在此忽略該功能。
  • allocated 是一個指標,us_base64_encode 會將其配置給 encoded 的位元組數填入其中。

以下是一個簡單的測試程式,用於從 C 語言呼叫此函式:

// src/test.c

#include <stdio.h>

#include "libs/base64.h"

void main(void) {
  char *input = "hello, world!";
  char *encoded = NULL;
  size_t encoded_bytes = 0;
  us_base64_encode((uint8_t *)input, strlen(input), &encoded, &encoded_bytes);
  printf("input:        %s\n", input);
  printf("output:       %s\n", encoded);
  printf("output bytes: %lu\n", encoded_bytes);
  free(encoded);
}

我將使用廣受歡迎的 C 編譯器 gcc 來編譯它:

$ gcc src/test.c src/libs/base64.c -o /tmp/b64test
In file included from src/libs/base64.h:31,
                 from src/test.c:3:
src/libs/tools.h: In function ‘us_signum_to_string’:
src/libs/tools.h:194:34: warning: implicit declaration of function ‘sigabbrev_np’ [-Wimplicit-function-declaration]
  194 |         const char *const name = sigabbrev_np(signum);

嗯,程式碼可以編譯,但我收到了許多關於 uStreamer 程式碼所引入的 tools.h 標頭檔的編譯器警告。

如果我查看 src/libs/tools.h,會發現所有錯誤都圍繞著單一函式:us_signum_to_string。我來試試看能否直接將該函式註解掉,以清除這些不相關的警告。

/*
DEBUG: Temporarily delete this function to get the build working again.
INLINE char *us_signum_to_string(int signum) {
...
  return buf;
}
*/

移除惱人的 us_signum_to_string 函式後,我再試一次編譯:

$ gcc src/test.c src/libs/base64.c -o /tmp/b64test && /tmp/b64test
input:        hello, world!
output:       aGVsbG8sIHdvcmxkIQ==
output bytes: 21

太好了,沒有編譯器警告了。

如果我要編譯整個 uStreamer,就必須想辦法讓 us_signum_to_string 能編譯。但在這次練習中,我只是要從 Zig 呼叫 us_base64_encode,所以不需要 us_signum_to_string

如果我將 test.c 程式的輸出與系統內建的 base64 工具的輸出相比,就可以驗證我產生了正確的結果:

$ printf 'hello, world!' | base64
aGVsbG8sIHdvcmxkIQ==

此階段的完整範例可在 GitHub 上查看

將 Zig 加入我的 uStreamer 專案環境

我最喜歡的 Zig 安裝方式是透過 Nix,因為它讓我能輕鬆切換 Zig 版本。你也可以用自己喜歡的任何方式安裝 Zig

我在專案中加入了以下的 flake.nix 檔案,它會將 Zig 0.11.0 拉入我的環境中:

{
  description = "Dev environment for zig-c-simple";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 0.11.0
    zig-nixpkgs.url = "github:NixOS/nixpkgs/46688f8eb5cd6f1298d873d4d2b9cf245e09e88e";
  };

  outputs = { self, flake-utils, zig-nixpkgs }@inputs :
    flake-utils.lib.eachDefaultSystem (system:
    let
      zig-nixpkgs = inputs.zig-nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = zig-nixpkgs.mkShell {
        packages = [
          zig-nixpkgs.zig
        ];

        shellHook = ''
          echo "zig" "$(zig version)"
        '';
      };
    });
}

接著,我可以執行 nix develop,就會看到 Zig 0.11.0 已在我的專案環境中可用:

# There's a weird quirk of Nix flakes that they have to be added to your git
# repo.
$ git add flake.nix
$ nix develop
zig 0.11.0

建立 Zig 可執行檔

Zig 編譯器的 init-exe 會建立一個樣板 Zig 應用程式,因此我將用它在 uStreamer 原始碼樹中建立一個簡單的 Zig 應用程式:

$ zig init-exe
info: Created build.zig
info: Created src/main.zig
info: Next, try `zig build --help` or `zig build run`

如果我嘗試編譯並執行這個樣板 Zig 應用程式,會看到一切運作正常:

$ zig build run
All your codebase are belong to us.
Run `zig build test` to run the tests.

我想呼叫的 uStreamer C 檔案相依於 C 標準函式庫,因此我需要在 build.zig 檔案中做一點小調整來連結該函式庫。在調整的同時,我也會將樣板的執行檔名稱替換為 base64-encoder

    const exe = b.addExecutable(.{
        .name = "base64-encoder", // Change binary name.
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    exe.linkLibC();               // Link against C standard library.
    exe.addIncludePath(.{ .path = "src" });

從 Zig 呼叫 uStreamer 程式碼

現在,我想從 Zig 呼叫 us_base64_encode 這個 C 函式。

提醒一下,以下是我想從 Zig 呼叫的 C 函式,相關說明已在上方提過

// src/libs/base64.h

void us_base64_encode(const uint8_t *data, size_t size, char **encoded, size_t *allocated);

事實證明,搞清楚如何在 C 型別與 Zig 型別之間轉換,是這個過程中最困難的部分,畢竟我還是個 Zig 新手。

這是我的第一次嘗試:

// src/main.zig

const ustreamer = @cImport({
    @cInclude("libs/base64.c");
});

pub fn main() !void {
    const input = "hello, world!";
    var cEncoded: *u8 = undefined;
    var allocatedSize: usize = 0;
    // WRONG: This doesn't compile.
    ustreamer.us_base64_encode(&input, input.len, &cEncoded, &allocatedSize);
}

這產生了以下的編譯器錯誤:

$ zig build run
zig build-exe b64 Debug native: error: the following command failed with 1 compilation errors:
...
src/main.zig:17:32: error: expected type '[*c]const u8', found '*const *const [13:0]u8'
    ustreamer.us_base64_encode(&input, input.len, &cEncoded, &allocatedSize);
                               ^~~~~~
src/main.zig:17:32: note: pointer type child '*const [13:0]u8' cannot cast into pointer type child 'u8'
/home/mike/ustreamer/zig-cache/o/9599bf4c636d23e50eddd1a55dd088ff/cimport.zig:1796:43: note: parameter type declared here
pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {

一開始我很難理解這個錯誤,因為其中有太多不熟悉的東西。

上述編譯器錯誤中最重要的部分是 error: expected type '[*c]const u8', found '*const *const [13:0]u8'。它告訴我,我嘗試傳入的是一個 *const *const [13:0]u8,但 Zig 需要我傳入 [*c]const u8

這是什麼意思呢?

理解我所使用的型別

根據 Zig 編譯器的說法,我傳入的參數型別是 '*const *const [13:0]u8。為了理解其含義,我將從右到左來解析:

u8 是無號位元組,也就是 Zig 用來表示字串中字元的方式。

[13:0] 表示一個以 null 結尾的陣列。13 是陣列的長度,由 Zig 在編譯時期計算。:0 表示該陣列多了一個值為 0 的位元組,用來標示字串的結尾。關於 Zig 中以 null 結尾字串運作機制的更多細節,請參閱我先前的文章

*const 表示一個常數指標。指標是記憶體中的一個位址,而 const 表示後續程式碼不得重新賦值該變數。

*const *const 表示一個指向常數指標的常數指標。換句話說,input 是一個指向字串的常數指標,因此 &input 就是一個指向常數指標的常數指標。

將 Zig 型別轉換為 C 型別

好了,現在我理解 Zig 如何看待我傳入的字串。那麼 Zig 希望我以什麼型別來傳入 input 呢?

expected type '[*c]const u8'

[*c] 到底是什麼意思?

這出乎意料地難以搞懂。我最終從幾個不同的來源拼湊出答案。

以下是官方 Zig 文件的說明:

C 指標

應盡可能避免使用此型別。唯一有正當理由使用 C 指標的情況是在從轉譯 C 程式碼所自動產生的程式碼中。

當匯入 C 標頭檔時,指標應該被轉譯為單一項目指標(*T)還是多項目指標([*]T)是模稜兩可的。C 指標是一種折衷方案,讓 Zig 程式碼能直接使用轉譯後的標頭檔。

https://ziglang.org/documentation/0.11.0/#C-Pointers

我看不懂這份文件,因為它似乎是在警告不要使用 C 指標,而不是解釋它們是什麼。

再透過 Kagi 搜尋後,我在 reddit 上找到了這個更容易理解的解釋:

[*c]T 就是一個指向型別 T 的 C 指標,它表示不知道該指標中是否有多個元素。可能有,也可能沒有。我們也不知道它的長度(它不是一個包含指標加長度的切片,它就只是一個指標)。而且如果有多個元素,我們也不知道它是否是以 null 結尾的。

-/u/slimsag on reddit

好,這樣就合理多了。

在 C 語言中,指標只是一個記憶體位址加上一個資料型別。C 的 char* 型別可能指向單一字元如 'A',也可能指向序列如 "ABCD" 中的第一個字元。

在 Zig 中,指向陣列的指標與指向單一元素的指標是不同的型別。當 Zig 必須從 C 程式碼推斷資料型別時,Zig 無法判斷 C 程式碼指的是單一元素還是陣列,因此 C 指標型別([*c]T)就是 Zig 用來表示「我不知道,這是從 C 來的」的方式。

透過反覆嘗試,我發現 Zig 希望我透過參照 input.ptr 來取得指向 input 的指標,而不是使用取址運算子 &

以下這段 Zig 程式碼片段顯示了 .ptr& 之間的差異:

const input = "hello, world!";
std.debug.print("input     is type {s}\n", .{@typeName(@TypeOf(input))});
std.debug.print("&input    is type {s}\n", .{@typeName(@TypeOf(&input))});
std.debug.print("input.ptr is type {s}\n", .{@typeName(@TypeOf(input.ptr))});
input     is type *const [13:0]u8
&input    is type *const *const [13:0]u8
input.ptr is type [*]const u8

回想一下,Zig 希望我傳給 us_base64_encode 的參數型別是 [*c]const u8,因此看起來它可以將 [*]const u8 轉換為該型別。

好,讓我再試一次呼叫 us_base64_encode

const input = "hello, world!";
var cEncoded: *u8 = undefined;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

結果得到:

$ zig build run
zig build-exe b64 Debug native: error: the following command failed with 1 compilation errors:
...
src/main.zig:12:54: error: expected type '[*c][*c]u8', found '**u8'
    ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
                                                     ^~~~~~~~~

有進展了!

程式碼仍然無法編譯,但 Zig 現在抱怨的是第三個參數,而不是第一個。這至少告訴我,我已經為前兩個參數提供了符合預期的型別。

將輸出參數轉譯為 Zig

編譯器錯誤中也包含了一條有助於呼叫 us_base64_encode 的 C 實作的資訊:

pub export fn us_base64_encode(arg_data: [*c]const u8, arg_size: usize, arg_encoded: [*c][*c]u8, arg_allocated: [*c]usize) void {

那是被轉譯成 Zig 的 C 函式簽章,因此 Zig 正在精確地告訴我呼叫該函式所需傳入的型別。

或者,我也可以使用 zig translate-c 工具將這個 C 函式簽章轉譯為 Zig。這實際上會得到與上述編譯器錯誤相同的結果,但會保留原始的參數名稱,而編譯器錯誤則會在前面加上 arg_ 前綴。

# We add --library c to let Zig know the code depends on libc.
$ zig translate-c src/libs/base64.h --library c | grep us_base64
pub extern fn us_base64_encode(data: [*c]const u8, size: usize, encoded: [*c][*c]u8, allocated: [*c]usize) void;

經過更多嘗試後,我最終猜出了從 Zig 呼叫 us_base64_encode 的正確寫法:

const input = "hello, world!";
var cEncoded: [*c]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

而且它成功編譯了!

我能做得比 C 指標更好嗎?

回想一下 Zig 文件中關於 C 指標的說法

唯一有正當理由使用 C 指標的情況是在自動產生的程式碼中⋯

這段程式碼是我手寫的,所以我想我不該使用保留給自動產生程式碼的型別。

我知道 us_base64_encode 的第三個參數是一個指向以 null 結尾字串的指標。在 Zig 中我該如何表示它?

我最初的想法是這樣做:

var cEncoded: [*:0]u8 = undefined;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

這看起來很合理。我知道 us_base64_encode 會將字串填入 cEncoded,而 [*:0]u8 代表一個長度未知、以 null 結尾的字串。但當我編譯時,Zig 卻說不行:

error: expected type '[*c][*c]u8', found '*[*:0]u8'

我感到困惑,因此在 Ziggit 這個 Zig 討論論壇上求助。不到一小時,另一位使用者就向我展示了解法

var cEncoded: ?[*:0]u8 = null;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

問題在於,在 C 語言中,char** 型別可以是 null,而 Zig 的 [*:0]u8 型別則不能為 null。這就是為什麼 Zig 拒絕讓我傳入先前的寫法。

拆解正確的 ?[*:0]u8 型別,可以看到它是:

  • 一個以 null 結尾的位元組切片(:0]u8
  • 長度未知([*
  • 可能為 null?

新的型別讓我可以編譯程式碼,但如果我嘗試印出 cEncoded 的值,得到的卻像是記憶體位址而非字串:

$ zig build run
input:       hello, world!
output:      u8@2b12a0      # << whoops, not what I expected
output size: 21

為了將 cEncoded 轉回可列印的字串,我必須透過在程式碼中驗證其值非 null,來將其從可選型別中解開:

var cEncoded: ?[*:0]u8 = null;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

...

std.debug.print("output:      {s}\n", .{output});

然後它就印出了正確的結果:

$ zig build run
input: hello, world!
output: aGVsbG8sIHdvcmxkIQ==
output size: 21

完成從 Zig 對 C 的呼叫

至此,我已經有了從 Zig 呼叫 C 的 us_base64_encode 的完整可用程式碼。以下是完整的 src/main.zig 檔案:

// src/main.zig

const std = @import("std");

// Import the base64 implementation from uStreamer's C source file.
const ustreamer = @cImport({
    @cInclude("libs/base64.c");
});

pub fn main() !void {
    // Create a standard Zig string.
    const input = "hello, world!";

    // Create variables to store the ouput parameters of us_base64_encode.
    var cEncoded: ?[*:0]u8 = null;
    var allocatedSize: usize = 0;

    // Call the uStreamer C function from Zig.
    ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);

    // Get the output as a non-optional type.
    const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

    // Free the memory that the C function allocated when this function exits.
    defer std.c.free(cEncoded);

    // Print the input and output of the base64 encode operation.
    std.debug.print("input:       {s}\n", .{input});
    std.debug.print("output:      {s}\n", .{output});
    std.debug.print("output size: {d}\n", .{allocatedSize});
}
$ zig build run
input:       hello, world!
output:      aGVsbG8sIHdvcmxkIQ==
output size: 21

太好了!成功了。而且結果與我上方的 C 語言實作完全相同。

此階段的完整範例可在 GitHub 上查看

為原生 C 實作建立 Zig 包裝函式

至此,我已能成功從 Zig 呼叫 C 的 us_base64_encode 函式,但程式碼有點雜亂。我大部分的 main() 函式都在處理與 C 程式碼之間的值轉換。

改善程式碼的一種方法是為 us_base64_encode 加上一個 Zig 包裝函式。這樣一來,我就能將所有 Zig 與 C 互操作的邏輯封裝起來,而呼叫此包裝函式的程式碼就不需要知道或在意我正在呼叫 C。

我的包裝函式應該長什麼樣子?

它應該接受任意位元組並回傳一個以 null 結尾的字串,因此函式簽章應該會像這樣:

fn base64Encode(data: []const u8) [:0]u8 {...}

根據上方 main() 函式的內容,我已經有了實作的前幾行:

fn base64Encode(data: []const u8) [:0]u8 {
  var cEncoded: ?[*:0]u8 = null;
  var allocatedSize: usize = 0;

  ustreamer.us_base64_encode(data.ptr, data.len, &cEncoded, &allocatedSize);

  // TODO: Complete the implementation.

誰負責釋放 C 所配置的記憶體?

還有一個我尚未處理的問題。us_base64_encode 將記憶體配置到 cEncoded 指標中。呼叫者有責任釋放該記憶體,或將此責任轉交給其呼叫者。

通常,函式聲明由呼叫者負責釋放輸出值是沒問題的,但在這個情況有點棘手。這不是一般的 Zig 配置記憶體緩衝區——而是一個 C 配置的緩衝區,需要使用特殊的釋放函式(std.c.free)。

我想將 C 實作的細節抽象化,因此呼叫者不應該需要使用 C 專用的記憶體釋放函式。

這告訴我完成 Zig 包裝函式實作所需做的事。我使用 defer std.c.free 來釋放 C 配置的記憶體,然後需要將其內容複製到由 Zig 管理的切片中:

fn base64Encode(data: []const u8) ![:0]u8 {
  var cEncodedOptional: ?[*:0]u8 = null;
  var allocatedSize: usize = 0;

  ustreamer.us_base64_encode(data.ptr, data.len, &cEncodedOptional, &allocatedSize);
  const cEncoded: [*:0]u8 = cEncodedOptional orelse return error.UnexpectedNull;

  // Get the output as a non-optional type.
  const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

  // Free the C-allocated memory buffer before exiting the function.
  defer std.c.free(cEncoded);

  // TODO: Copy the contents of cEncoded into a [:0]u8 buffer.
}

將 C 字串轉換為 Zig 字串

至此,我已將字串取得為 [*:0]u8(長度未知、以零結尾的 Zig 切片),但我想要回傳的是 [:0]u8(可感知長度、以 null 結尾的 Zig 切片)。我該如何將 C 風格的字串轉換為 Zig 切片?

先前的文章中,我透過以下流程將 C 字串轉換為 Zig 字串:

  1. 使用 std.mem.span 為 C 字串建立一個 Zig 切片。
  2. 使用 allocator.dupeZ 將切片內容複製到新配置的 Zig 切片中。

該流程在此也適用,但在步驟 (1) 中我會做一些無用功。std.mem.span 必須遍歷字串以找到 null 結束符。在這段程式碼中,我已經知道 null 結束符的位置,因為 us_base64_encode 已將該資訊儲存在 allocatedSize 參數中。

相反地,我像這樣為 cEncoded 切片建立一個可感知長度的 Zig 切片:

// The allocatedSize includes the null terminator, so subtract 1 to get the
// number of non-null characters in the string.
const cEncodedLength = allocatedSize - 1;

// Convert cEncoded (unknown length slice) to a length-aware slice.
const outputLengthAware: [:0] = cEncoded[0..cEncodedLength :0];

至此,我可以完成包裝函式的實作:

fn base64Encode(allocator: std.mem.Allocator, data: []const u8) ![:0]u8 {
    var cEncoded: [*c]u8 = null;
    var allocatedSize: usize = 0;

    ustreamer.us_base64_encode(data.ptr, data.len, &cEncoded, &allocatedSize);

    // Get the output as a non-optional type.
    const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;

    // Free the C-allocated memory buffer before exiting the function.
    defer std.c.free(cEncoded);

    // The allocatedSize includes the null terminator, so subtract 1 to get the
    // number of non-null characters in the string.
    const cEncodedLength = allocatedSize - 1;
    return allocator.dupeZ(u8, cEncoded[0..cEncodedLength :0]);
}

為了呼叫 dupeZ,我需要一個 Zig 配置器,因此我調整了 base64Encode 包裝函式的語意,讓它接受 std.mem.Allocator 型別。

整合所有內容

有了 Zig 包裝函式後,從 Zig 操作 C 的 us_base64_encode 函式就變得非常簡單了。

回想一下,我先前的程式碼看起來像這樣:

const input = "hello, world!";
var cEncoded: ?[*:0]u8 = null;
var allocatedSize: usize = 0;
ustreamer.us_base64_encode(input.ptr, input.len, &cEncoded, &allocatedSize);
const output: [*:0]u8 = cEncoded orelse return error.UnexpectedNull;
defer std.c.free(cEncoded);

有了 Zig 包裝函式後,語意簡化為兩行:

const output = try base64Encode(allocator, "hello, world!");
defer allocator.free(output);

以下是完整的範例:

const std = @import("std");

// Import the base64 implementation from uStreamer's C source file.
const ustreamer = @cImport({
    @cInclude("libs/base64.c");
});

fn base64Encode(allocator: std.mem.Allocator, data: []const u8) ![:0]u8 {
    var cEncodedOptional: ?[*:0]u8 = null;
    var allocatedSize: usize = 0;

    ustreamer.us_base64_encode(data.ptr, data.len, &cEncodedOptional, &allocatedSize);
    const cEncoded: [*:0]u8 = cEncodedOptional orelse return error.UnexpectedNull;
    defer std.c.free(cEncodedOptional);

    const cEncodedLength = allocatedSize - 1;
    return allocator.dupeZ(u8, cEncoded[0..cEncodedLength :0]);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    defer _ = gpa.deinit();

    const input = "hello, world!";
    const output = try base64Encode(allocator, input);
    defer allocator.free(output);

    // Print the input and output of the base64 encode operation.
    std.debug.print("input:       {s}\n", .{input});
    std.debug.print("output:      {s}\n", .{output});
    std.debug.print("output size: {d}\n", .{output.len});
}
$ zig build run
input:       hello, world!
output:      aGVsbG8sIHdvcmxkIQ==
output size: 20

輸出大小現在是 20 而非 21,因為底層的資料型別改變了。先前我印出的是 us_base64_encode 所填入的輸出大小參數,其中包含了 null 結束符。現在,我使用的是輸出字串的 .len 屬性,它不包含 null 結束符。

此階段的完整範例可在 GitHub 上查看

建立第一個單元測試

現在我已能透過便利的 Zig 包裝函式呼叫 C 的 us_base64_encode 函式,準備開始撰寫單元測試來驗證 C 實作是否正確。

我首先需要對 build.zig 檔案做一些小調整,讓單元測試能夠存取 libc 和 uStreamer 的 C 原始檔:

// build.zig

    const unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    unit_tests.linkLibC();                          // Link against libc.
    unit_tests.addIncludePath(.{ .path = "src" });  // Search src path for includes.

透過撰寫 Zig 包裝函式,我已經完成了繁重的工作,因此撰寫第一個單元測試就相當直接:

// src/main.zig

test "encode simple string as base64" {
    const allocator = std.testing.allocator;
    const actual = try base64Encode(allocator, "hello, world!");
    defer allocator.free(actual);
    try std.testing.expectEqualStrings("aGVsbG8sIHdvcmxkIQ==", actual);
}

zig build test 指令會執行我的單元測試:

$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 1/1 tests passed
test success
└─ run test 1 passed 1ms MaxRSS:1M
   └─ zig test Debug native success 2s MaxRSS:211M

成功了!我的第一個單元測試正常運作,並正在操作 C 程式碼。

此階段的完整範例可在 GitHub 上查看

檢查是否為誤判的測試結果

我的單元測試通過了,但我想確保測試確實有執行 C 程式碼,而不只是回傳誤判的通過。我可以透過故意在 C 程式碼中引入一個錯誤來驗證這點。

以下是 base64.c 實作中的一個片段:

#    define OCTET(_name) unsigned _name = (data_index < size ? (uint8_t)data[data_index++] : 0)
    OCTET(octet_a);
    OCTET(octet_b);
    OCTET(octet_c);
#    undef OCTET

讓我試著交換這兩行的順序:

    OCTET(octet_a);
    OCTET(octet_c); // I've swapped these
    OCTET(octet_b); // two lines.

以下是我在竄改後,重新對該 C 函式執行單元測試時發生的情況:

$ zig build test --summary all
run test: error: 'test.encode simple string as base64' failed: ====== expected this output: =========
aGVsbG8sIHdvcmxkIQ==␃

======== instead found this: =========
aGxlbCxvIG93cmRsIQ==␃

太酷了,測試奏效了!

當我在 us_base64_encode 中引入錯誤時,測試失敗並揭露了該錯誤。

加入多個單元測試

我想將單一測試案例擴充為多個測試案例,以提高我對涵蓋更多 C 函式邏輯的信心。

我的第一個單元測試中有一半的行數是圍繞記憶體管理的樣板程式碼,因此我想避免為每個測試重複這些內容。我撰寫了一個工具函式來封裝這些樣板:

fn testBase64Encode(
    input: []const u8,
    expected: [:0]const u8,
) !void {
    const allocator = std.testing.allocator;
    const actual = try base64Encode(allocator, input);
    defer allocator.free(actual);
    try std.testing.expectEqualStrings(expected, actual);
}

我的測試工具函式讓我能輕鬆加入新的測試:

test "encode strings as base64" {
    try testBase64Encode("", "");
    try testBase64Encode("h", "aA==");
    try testBase64Encode("he", "aGU=");
    try testBase64Encode("hel", "aGVs");
    try testBase64Encode("hell", "aGVsbA==");
    try testBase64Encode("hello, world!", "aGVsbG8sIHdvcmxkIQ==");
}

test "encode raw bytes as base64" {
    try testBase64Encode(&[_]u8{0}, "AA==");
    try testBase64Encode(&[_]u8{ 0, 0 }, "AAA=");
    try testBase64Encode(&[_]u8{ 0, 0, 0 }, "AAAA");
    try testBase64Encode(&[_]u8{255}, "/w==");
    try testBase64Encode(&[_]u8{ 255, 255 }, "//8=");
    try testBase64Encode(&[_]u8{ 255, 255, 255 }, "////");
}
$ zig build test --summary all
Build Summary: 3/3 steps succeeded; 2/2 tests passed
test success
└─ run test 2 passed 2ms MaxRSS:2M
   └─ zig test Debug native success 2s MaxRSS:195M

此階段的完整範例可在 GitHub 上查看

總結

得益於 Zig 與 C 之間優異的互操作性,我們可以在不修改任何 C 程式碼或建置流程的情況下,為現有的 C 應用程式加入單元測試。

在我展示的範例中,C 程式碼完全不知道 Zig 的存在,並且在不需對現有 Makefile 做任何變更的情況下,仍能照常運作。

我發現這次練習是深入了解 Zig 語言與受測 C 程式碼的有用方式。


感謝 Ziggit 社群對本篇文章的協助。摘錄自 uStreamer 的內容依 GPLv3 授權使用。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯