c語言模擬Python的命名引數

apocelipes發表於2024-07-28

最近在書裡看到的,讓c語言去模擬其他語言裡有的命名函式引數。覺得比較有意思所以記錄一下。

目標

眾所周知c語言裡是沒有命名函式引數這種東西的,形式引數雖然有自己的名字,但傳遞的時候並不能透過這個名字來指定引數的值。

而支援命名引數的語言,比如python裡,我們能讓程式碼達到這種效果:

def k_func(a, b, c):
    print(f'{a=}, {b=}, {c=}')

k_func(1, 2, 3)        # Output: a=1, b=2, c=3
k_func(c=1, b=3, a=2)  # Output: a=2, b=3, c=1

我們想要的類似於k_func(c=1, b=3, a=2)的效果,至少表現形式上要接近。雖說c的語法並不支援這樣的表示式,但我們有辦法模擬。

實現

我們假設有這樣一個c語言的函式,現在我們想模擬命名引數傳遞:

int func(const char *text, unsigned int length, int width, int height, double weight)
{
    int printed = 0;
    printed += printf("text: %s\n", text);
    printed += printf("length: %d\n", length);
    printed += printf("width X height: %d X %d\n", width, height);
    printed += printf("weight: %g\n", weight);
    return printed;
}

模擬的關鍵在於如何完成名字到引數的對映。而且我們函式的五個引數有四種不同的型別,所以這個對映還得是異構的。

在不借助第三方庫的情況下,第一個能想到的應該是enum加void*陣列。enum可以完成名字到陣列索引的對映,void*可以儲存異構的資料。

這個方案的缺點很多:如果想要在length和width之間加個引數,我們很可能就需要修改所有的對映程式碼;比如void*可以接受任何資料型別的指標,所以我們幾乎沒有辦法確保型別安全,想象一下如果有人給text傳了個int的指標會發生什麼。所以這個方案並不推薦。

能容納異構的資料,同時還能給這些資料名字的東西,實際上在c裡非常常見,那就是結構體。我們要選擇的就是基於結構體的方案。

首先我們來定義這個結構體:

typedef struct func_arguments {
    const char *text;
    unsigned int length;
    int width;
    int height;
    double weight;
} func_arguments;

欄位的順序是無所謂的,你可以根據情況來任意調整。現在我們可以根據欄位名來設定值了。現在還缺一環,只有結構體是沒用的,我們需要把結構體的欄位傳遞給函式才行。所以我們要寫一個幫助函式:

int func_wrapper(func_arguments fa)
{
    // 根據需要還可以加入引數校驗等邏輯
    return func(fa.text, fa.length, fa.width, fa.height, fa.weight);
}

我們需要的工具基本上都在這了,然而現在和命名引數傳遞還有不少差距,因為我們需要這樣寫程式碼:

func_arguments fa;
fa.text = "text";
fa.length = 4;
fa.width = fa.height = 8;
fa.weight = 10.0;
func_wrapper(fa);

不僅形式上差遠了,程式碼還很繁瑣,所以我們還得藉助一下c99的複合字面量+指定初始化器的新語法來模擬命名引數傳遞:

func_wrapper((func_arguments){ .text = "text", .length = 4, .width = 8, .height = 8, .weight = 10.0 });

c99允許在初始化時使用.欄位名的形式給欄位設定值,沒有指定的欄位則初始化成零值,c99還允許符合要求的字面量型別轉換成陣列/結構體。利用新語法我們就能寫出上面的程式碼了。

現在形式上確實很接近了,但還是顯得有點囉嗦。這時候就得依賴宏了。c的宏可以實現文字替換和簡單的型別分發,所以可以用它來把一些看起來不合法的表示式轉換成正常的c語言程式碼。

首先宣告,不要濫用宏,尤其是像下面那樣,這裡只是充當一下記錄而不是教你生產實踐。

用宏可以這樣寫:

#define k_func(...) func_wrapper((func_arguments){ __VA_ARGS__ })

這裡用了另一個c99的新語法,可變長引數宏,三個點意味著宏可以接受用逗號分隔的任意個引數,而__VA_ARGS__會原樣替換成這些引數。因此我們只需要讓宏的引數裡是正確的指定初始化器就行了:

k_func(.text="text", .length=4);
// 宏完成替換後等價於 func_wrapper((func_arguments){ .text = "text", .length = 4 });

是不是很神奇?

有人可能會擔心我們引數傳遞時複製了整個結構體,這會不會帶來效率問題?通常這不會帶來什麼問題,現在編譯器一般都能做到省略大部分不必要的複製,另外如果物件比較小的話複製通常也不會帶來太大的開銷,什麼叫小很難定義,以我個人的經驗來看尺寸比兩個cacheline小的通常都可以算是“小”。

如果還是不放心,也可以簡單得把引數型別改成結構體指標:

int func_wrapper(const func_arguments *fa)
{
    return func(fa->text, fa->length, fa->width, fa->height, fa->weight);
}

#define k_func(...) func_wrapper(&(func_arguments){ __VA_ARGS__ })

使用方法是一樣的。注意宏裡的&,這會分配一個auto生命週期的func_arguments變數(通常在棧上)然後再取它的指標。現在你可以不用擔心了。不過我一般不推薦這麼寫,除非你經過效能測試後發現引數複製真的導致了效能問題。

缺陷

奇蹟和魔法都不是免費的,所以上面像變魔術的程式碼也是有代價的。

第一個缺陷比較小,那就是欄位名字前必須加上點。如果這麼寫:printf("%d\n", k_func(.text="text", length=4)),注意length前我們不小心把點給漏了。編譯器會爆出一個不明所以的報錯:

test.c: In function ‘main’:
test.c:31:45: error: ‘length’ undeclared (first use in this function)
   31 |         printf("%d\n", k_func(.text="text", length=4));
      |                                             ^~~~~~
test.c:27:52: note: in definition of macro ‘k_func’
   27 | #define k_func(...) func_wrapper((func_arguments){ __VA_ARGS__ })
      |                                                    ^~~~~~~~~~~
test.c:31:45: note: each undeclared identifier is reported only once for each function it appears in
   31 |         printf("%d\n", k_func(.text="text", length=4));
      |                                             ^~~~~~
test.c:27:52: note: in definition of macro ‘k_func’
   27 | #define k_func(...) func_wrapper((func_arguments){ __VA_ARGS__ })
      |                                                    ^~~~~~~~~~~

它會告訴你length這個名字從來沒被定義過而不是告訴你漏了個.。平時看東西不認真的人就要受罪了,因為要找這個點得花上一番功夫。

第二個缺陷是沒有語法提示和自動補全。畢竟宏可以把任何符合規則的文字替換進去,至於替換完怎麼樣就管不到了,所以想要對k_func的引數進行提示和補全是不太容易的,而且實驗下來目前沒有ide和編輯器能幫我把欄位名自動補全。不過問題倒也不大,因為萬一寫錯了的話編譯器會給出準確的報錯,只不過開發效率會降低一些。

第三個缺陷在於可以寫出這樣的東西:printf("%d\n", k_func(.length=10, .text="text", .length=4))。我們指定了兩次length欄位的值,語法上這是允許的,length的值會被最右邊的那個覆蓋掉。但這顯然不符合我們的要求,而且前面說了因為沒有自動補全和語法提示,我們不小心把height寫成了weight也很難察覺到。更糟糕的是這種覆蓋在gcc下需要指定-Wextra才能看到一個不痛不癢的警告。同樣的情況在python下會直接收到一個語法錯誤的異常keyword argument repeated

前兩個缺陷還有辦法克服,最後一個是沒有任何辦法的,只能靠更高階別的警告設定和人力檢查了。

總結

正常來說我們做到func_wrapper那步就足夠,後面的宏沒啥意義純粹是為了在形式上模擬python的命名引數而做的。

除了用結構體包裝,寫一個包裝函式並調換引數的順序或者給出預設值也是常見的做法,但這種做法很快會讓介面的數量失控,大量相似的介面會讓程式碼變得臭不可聞,所以我更推薦用結構體。

最後學習新語法還是很有用的,因為很多新語法好好利用的話可以有效提升擴充套件性和你的開發效率。

相關文章