Using Zig to Unit Test a C Application

Michael Lynch

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

原文由 Michael Lynch 發布,訂閱此部落格

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 硬體最佳化的影像串流工具。

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

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);
}

我會用 gcc 這個常見的 C 編譯器來編譯它:

$ 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 上

在 uStreamer 專案環境中加入 Zig

我最喜歡的 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 是無號位元組(unsigned byte),也就是 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 指標,它表示不知道這個指標是指向多個元素還是一個元素。可能是,也可能不是。我們也不知道它的長度(它不是有指標加長度的 slice,就只是一個指標)。而且就算有多個元素,我們也不知道它是不是以 null 結尾的。

-/u/slimsag on reddit

好,這樣就合理多了。

在 C 語言中,指標就只是一個記憶體位址加上一個資料型別。一個 char* 型別的 C 指標,可能指向像 '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

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

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'

我卡住了,所以我在 Zig 討論區 Ziggit 上求助。不到一小時,就有另一位使用者給了我一個解法

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 結尾的位元組 slice(:0]u8
  • 長度未知([*
  • 而且可能是 null?

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

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

為了把 cEncoded 轉回可列印的字串,我必須透過在程式碼中驗證其值非 null,來從 optional 變數中解包:

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() 函式中大部分都是在處理 Zig 與 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_encodecEncoded 指標中配置了記憶體。呼叫者有責任釋放那塊記憶體,或是把這個責任交給它的呼叫者。

通常,讓函式宣告呼叫者有責任釋放一個輸出值是沒問題的,但在這個情況下有點棘手。這不是一個普通的 Zig 所配置的記憶體緩衝區——它是 C 所配置的緩衝區,需要用特殊的釋放函式(std.c.free)。

我想把 C 實作的細節抽象掉,所以呼叫者不應該需要使用 C 專屬的記憶體釋放函式。

這告訴我,為了完成 Zig 包裝函式的實作,我該怎麼做。我用 defer std.c.free 來釋放 C 所配置的記憶體緩衝區,然後我需要把它複製到一個由 Zig 管理的 slice 中:

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 slice),但我想回傳的是 [:0]u8(可感知長度、以 null 結尾的 Zig slice)。要怎麼把 C 風格的字串轉換為 Zig 的 slice 呢?

我之前的文章中,我用以下流程把 C 字串轉換為 Zig 字串:

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

這個流程在這裡也行得通,但我在步驟 (1) 會做白工。std.mem.span 必須遍歷字串來找到 null 結尾字元。在這段程式碼中,我已經知道 null 結尾字元在哪裡,因為 us_base64_encode 已經把那個資訊存在 allocatedSize 參數中了。

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

// 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 allocator,所以我調整了 base64Encode 包裝函式的語意,讓它接受一個 std.mem.Allocator 型別。

全部串起來

有了 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 授權使用。

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

留言