通過 Zig,學習 C-- 元編程

儘管 Zig 社區宣稱 Zig 語言是一個更好的 C (better C),但是我個人在學習 Zig 語言時經常會 “觸類旁通”C++。在這裏列舉一些例子來說明我的一些體會,可能會有一些不正確的地方,歡迎批評指正。

“元能力” vs “元類型”

在我看來,C++ 的增強方式是希望賦予語言一種 “元能力”,能夠讓人重新發明新的類型,使得使用 C++ 的程序員使用自定義的類型,進行一種類似於 “領域內語言”(DSL)編程。一個通常的說法就是 C++ 中任何類型定義都像是在模仿基本類型int。比如我們有一種類型 T,那麼我們則需要定義 T 在以下幾種使用場景的行爲:

T x; //構造
T x = y; //隱式拷貝構造
T x{y}; //顯示拷貝構造
x = y; //x的類型是T,複製運算符重載,當然也有可能是移動運算符重載。
//以及一大堆其他行爲,比如析構等等。

通過定義各種行爲,程序員可以用 C++ 去模擬基礎類型int,自定義的創造新類型。但是 Zig 卻採取了另一條路,這裏我覺得 Zig 的取捨挺有意思,即它剝奪了程序員定義新類型的能力,只遵循 C 的思路,即struct就是struct,他和int就是不一樣的,沒有必要通過各種運算符重載來製造一種 “幻覺”,模擬int。相反,Zig 吸收現代語言中最有用的 “元類型”,比如slicetupletagged union等作爲語言內置的基本類型,從這一點上對 C 進行增強。雖然這樣降低了語言的表現力,但是卻簡化了設計,降低了 “心智負擔”。

比如 Zig 裏的tuple,C++ 裏也有std::tuple。當然,std::tuple是通過一系列的模板元編程的方式實現的,但是這個在 Zig 裏是內置的,因此寫代碼時出現語法錯誤,Zig 可以直接告訴你是tuple用的不對,但是 C++ 則會打印很多錯誤日誌。再比如optional,C++ 裏也有std::optinonal<T>,Zig 裏只用?T。C++ 裏有std::variant,而 Zig 裏有tagged union。當然我們可以說,C++ 因爲具備了這種元能力,當語法不足夠 “甜” 時,我們可以發明新的輪子,但是代價就是系統愈發的複雜。而 Zig 則持續保持簡單。

不過話說回來,很多底層系統的開發需求往往和這種類型系統的構建相悖,比如如果你的類型就是一個int的封裝,那麼即使發生拷貝你也無所謂性能開銷。但是如果是一個struct,那麼通常情況下,你會比較 care 拷貝,而可能考慮 “移動” 之類的手段。這個時候各種 C++ 的提供的幻覺,就成了程序員開發的絆腳石,經常你需要分析一段 C++ 表達式裏到底有沒有發生拷貝,他是左值還是右值,其實你在寫 C 語言的時候也很少去考慮了這些,你在 Zig 裏同樣也不需要。

類型系統

C 語言最大弊病就是沒有提供標準庫,C++ 的標準庫你要是能看懂,得具備相當的 C++ 的語法知識,但是 Zig 的標準庫幾乎不需要文檔就能看懂。這其實是因爲,在 C++ 裏,類型不是一等成員 (first class member),因此實現一些模版元編程算法特別不直觀。但是在 Zig 裏,type就是一等成員,比如你可以寫:

const x: type = u32;

即,把一個type當成一個變量使用。但是 C++ 裏如何來實現這一行代碼呢?其實是如下:

using x = uint32_t;

那麼我們如果要對某個類型做個計算,比如組合一個新類型,Zig 裏其實非常直觀

fn Some(comptime InputType: type) type

即輸入一個類型,輸出一個新類型,那麼 C++ 裏對應的東西是啥呢?

template <typename InputType>
struct Some {
  using OutputType = ...
}

相比之下, Zig 直觀太多。那麼很自然的,計算一個類型,Zig 裏就是調用函數,而 C++ 則是模板類實例化,然後訪問類成員。

Some<InputType>::OutputType

相當於對於 InputType 調用一個 Some“函數”,然後輸出一個 OutputType。

命令式 VS 聲明式

比如實現一個函數,輸入一個 bool 值,根據 bool 值,如果爲真,那麼輸出 type A,如果爲假那麼輸出 type B。

//基本模式
template <bool, typename A, typename B>
struct Fn {
    using OutputType = A;
};

//特例化的模式
template<typename A, typename B>
struct Fn<false, A, B> {
    using OutputType = B;
};

從這裏 C++ 代碼可以感覺出,其實你是拿着尺子,對照着基礎模式,然後通過模版偏特化來實現不同分支的邏輯。

Fn<sizeof(A) > sizeof(B), A, B>::OutputType

這段代碼表面上看是聲明瞭一個類型 OutputType,而這個類型的生成依賴於一些條件。而這些條件就是模板元編程,用來從 A 和 B 中選擇類型大小更大的類型,如果想要表達更復雜的邏輯,則需要掌握更多模板的奇技淫巧。

如果用 Zig 來做,則要簡單的多:

fn Fn(comptime A:type, comptime B: type) type {
    if (@sizeOf(A) > @sizeOf(B)) {
        return A;
    }
    return B;
}

這段代碼和普通的 CRUD[1] 邏輯沒什麼區別,特殊的地方在於操作的對象是『類型』。

我們再來看遞歸的列子。比如有一個類型的 list,我們需要返回其中第 N 個 type。同樣,由於在 C++ 中,類型不是一等成員,因此我們不可能有一個vector<type>的東東。那怎麼辦呢?方法就是直接把type list放在模板的參數列表裏:typename ...T

於是,我們寫出 “函數原型”:

template <int Index, typename ...T>
struct Fn;

然後我們遞歸的基礎情況

template <typename Head, typename ...Tail>
struct Fn<0, Head, Tail...> {
    using Output = Head;
};

然後寫遞歸式,

template<int Index, typename Head, typename ...Tail>
struct Fn<Index, Head, Tail...> : public Fn<Index - 1, Tail...>
{
};

這個地方其實稍微有點難理解,其實就是拿着...T來模式匹配Head, ...Tail

第一個偏特化,如果用命令式,類似於,

if (Index == 0)
    return Head;

第二個偏特化,類似於

else {
    return Fn(Index-1, Tail...);
}

這裏利用的其實是繼承,讓模板推導一路繼承下去,如果 Index 不等於 0,那麼Fn<Index, ...>類其實是空類,即,我們無法繼承到using Output = ...的這個Output。但是 index 總會等於 0,那麼到了等於 0 的那天,遞歸就終止了,因爲,我們不需要繼續 Index - 1 下去了,編譯器會選擇特化好的Fn<0, T,Tail...>這個特化,而不會選擇繼續遞歸。

但是 Zig 實現這個也很直觀,由於slicetype都是內置的,我們可以直接:

fn chooseN(N: u32, comptime type_list: []const type) type {
    return type_list[N];
}

pub fn main() void {
    const type_list = &[_]type{ u8, u16, u32, u64 };
    std.debug.print("{}\n", .{chooseN(2, type_list)});
}

即這個也是完全命令式的。當然 C++20 之後也出現了if constexprconcept來進一步簡化模版元編程,C++ 的元編程也在向命令式的方向進化。

結束語

儘管 Zig 目前 “還不成熟”,但是學習 Zig,如果採用一種對照的思路,偶爾也會 “觸類旁通”C++,達到舉一反三的效果。

ZigCC

引用鏈接

[1] CRUD: _https://en.wikipedia.org/wiki/Create,_read,update_and_delete

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CBoudpgZEH7ZFXusZfisqQ