介面即泛型

garfileo發表於2019-05-10

有言在先,本文僅僅是從 C 語言的角度來看『介面』與『泛型』之間的關係,無意於證明 C 語言有多麼『強大』,以致於它連『介面』與『泛型』都能支援,也無意於貶低那些從語法層面就支援介面與泛型的語言在販賣概念。

C 式的泛型

C 語言在語法層面對泛型的支援,簡而言之,就是 void * + 型別轉換。

為了簡單起見,還是拿 C 標準庫提供的 qsort 函式說事。

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

qsort 是泛型的,只不過與那些為泛型提供了語法支援的程式語言相比,C 式泛型太過於簡陋,也可以說是醜陋。事實上,說 qsort 長醜的人,往往是對函式指標的宣告形式太生疏。

我覺得 qsort 不醜,它這樣的,應該叫樸素。

C++ 標準庫提供的的泛型 std::sort 函式的宣告如下:

template< class RandomIt, class Compare >
void sort(RandomIt first, RandomIt last, Compare comp);

要理解 std::sort,你需要了解 C 語言,瞭解 C++ 基於類的資料封裝,模板,容器,迭代器,然後是 C++ 標準庫提供的五種迭代器型別,然後你就會用 std::sort 了。像 std::sort 這樣的,他們說這叫華麗。

std::sort 有很多優點。與 qsort 相比,它最大的優點可能是型別安全。於是,當 std::sort 在排序速度上戰勝 qsort 的時候,就是 C++ 在計算速度上贏了 C。當 std::sort 在排序速度上沒有戰勝 qsort 時,就是 C++ 在型別安全上贏了 C。反正在 C++ 面前,C 是不能贏的,否則沒天理了……

型別不安全的 C

事實上,qsort 本身是型別安全的,型別不安全的是 int (*compar)(const void *, const void *)。更確切的說,不安全的是 compar 引用(指向)的函式。例如下面的這個比較完整的 qsort 用法示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define N 5

static int str_compare(const void *s1, const void *s2) {
        size_t l1 = strlen(*(char **)s1);
        size_t l2 = strlen(*(char **)s2);
        return (l1 > l2) ? 1 : ((l1 == l2) ? 0 : -1);
}

int main(void) {
        char *str_array[] = {"a", "abcd", "abc", "ab", "abcde"};
        qsort(str_array, N, sizeof(char *), str_compare);
        for (int i = 0; i < N; i++) {
                printf("%s ", str_array[i]);
        }
        printf("
");
        return 0;
}

型別不安全之處在於:

size_t l1 = strlen (*(char **)s1);
size_t l2 = strlen (*(char **)s2);

之所以說這兩行程式碼是型別不安全的,是因為對 void * 型別的 s1s2 進行了強制型別轉換。如果你不清楚 s1s2 的實際型別,很容易在這個環節出錯。

要不要給型別買個意外傷害保險

型別不安全的程式碼彌散在大規模的程式中,可能會導致程式陷入萬劫不復的境地。這是很多人談 C 色變的原因之一吧。C 語言另一個令人色變之處是手動記憶體管理。

如果熟悉 C 指標的知識,再搭建足夠好的單元測試環境,型別不安全的問題也是能夠被早發現早解決的,但是這對程式設計者的技能與素養具有較高的要求,換句話說,亦即 C 語言不適合軟體的快速開發或原型開發。

不適合快速程式設計的語言,自然不會得到『市場』的認可。C 語言的用武之地日漸萎縮,這幾年新出來的幾種程式語言,例如 Go, Rust, Nim 之類,它們似乎有一個共同的理想——取代 C。在這些語言的教程中,C 語言往往是被揪出來作為批鬥物件的……它們異口同聲的說,你看,C 語言中這樣那樣的缺陷,我們都解決了,所以我們是未來!

你知道一個人從初學 C 到遊刃有餘的駕馭 C 編寫解決現實問題的程式,這需要多少年?可能譚浩強們說一個學期就足夠了,然而 Peter Norvig 卻聳人聽聞故作高深的說需要十年。Peter Norvig 何許人也?他是人工智慧專家,寫過《Paradigms of AI Programming》與《Artificial Intelligence:A Modern Approach》,現在是 Google 研發部門總監,這比譚浩強們威武多了。

C 語言雖然有這樣那樣的缺陷,但它可能並沒有拖慢你成長為程式設計專家的速度,甚至不僅沒有拖慢,反而會有增益。你經常看到那些有十年程式設計經驗的專家說 C 這也不好那也不好,但是這並不意味這你能像他們一樣真正意識到 C 的各種缺陷所帶來的問題。如果連這些問題都意識不到,那麼新語言提供的特性再美好,也幾乎是沒有意義的。

當然,即使不瞭解這些問題,你也能夠用新語言創造出一些東西,讓自己很體面的活著。世界就是這樣子。你們村的王二沒上過大學,掙的錢可能也是你的很多倍,創造的社會價值可能也是你的很多倍……所以,不必在意我說的。

如果你喜歡自下向上的編寫程式,C 語言依然是一門最好的語言,使用它,你能夠製造出效能不錯的原語級別的東西,然後再想辦法組合這些原語,來產生複雜的邏輯。由於原語通常很短小,也使得 C 的缺陷所產生的複雜效應得到控制。即使 C 在開發效率上沒有什麼優勢可言,但是原語級的東西往往也是複用程度最高的東西,它似乎值得你為它們的效能犧牲一些時間。

我甚至願意再浪費一些時間來談談如何讓 C 的型別再安全一些,特別是對於類似 qsort 這樣的泛型函式。

介面

C 語言沒有介面的概念。老一輩程式設計師們可能天天在用 C 寫介面,但是卻沒有意識到那就是後來我們所謂的介面。

在類 Unix 系統中,檔案就是介面。例如一個程式 A 可以向管道中寫資料,而另一個程式 B 可以從管道中讀資料,管道就是一種檔案。程式 A 不需要知道程式 B 的內部是如何實現的,反之亦然。

用計算機硬體結構來解釋介面會更直觀。USB 埠就是介面。你的機器不需要知道你插的是 U 盤,Mp3,行動硬碟還是外接光碟機,反之亦然。

對於 qsort 這樣的函式而言,我們可以把它封裝成介面形式的,例如:

void i_sort(struct sortable *I) {
        qsort(I->base, I->nmemb, I->size, I->compare);
}

I 就是一個介面,它規定:如果你要向 i_sort 函式插入一種裝置,那麼這種裝置必須提供 base, nmemb, size 以及 compare 這些元素,即:

struct sortable {
        void *base;
        size_t nmemb;
        size_t size;
        void *compare;
};

介面即協議。

有了這個介面,我們就可以編寫特定型別的 compare 函式了。可將上文中出現的 str_compare 函式修改為:

static int str_compare(const char **s1, const char **s2) {
        size_t l1 = strlen(*s1);
        size_t l2 = strlen(*s2);
        return (l1 > l2) ? 1 : ((l1 == l2) ? 0 : -1);
}

看,現在我們不再需要對 s1s2 進行強制型別轉換了,而是直接解引用,然後將它們傳給 strlen 函式。

有了 sortable 這個『介面』,就可以像下面這樣呼叫 i_sort 函式:

char *str_array[] = { "a", "abcd", "abc", "ab", "abcde" };
struct sortable I = {str_array, 5, sizeof(char *), str_compare};
i_sort(&I);

這意味著,無論你的陣列裡存放著何種資料,只要你能夠向 i_sort 提供一個 sortable 的例項,那麼 i_sort 就能夠這個陣列中的元素進行快速排序。

i_sort 就是一個泛型函式,它與 C++ 標準庫中的 std::sort 在本質上並無不同。

利用介面,可以讓 C 編譯器自動替你完成型別轉換的工作,從而可以在一定程度上提高型別的安全性。也就是說,只要你不去手動做型別轉換,那麼型別就自然會安全一些。

void 指標與函式指標能相互轉換嗎?

對於上一節所實現的介面版的排序函式, 在此我給出一份完整的示例程式碼:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct sortable {
        void *base;
        size_t nmemb;
        size_t size;
        void *compare;
};

void i_sort(struct sortable *I) {
        qsort(I->base, I->nmemb, I->size, I->compare);
}

static int str_compare(const char **s1, const char **s2) {
        size_t l1 = strlen(*s1);
        size_t l2 = strlen(*s2);
        return (l1 > l2) ? 1 : ((l1 == l2) ? 0 : -1);
}

int main(void) {
        char *str_array[] = { "a", "abcd", "abc", "ab", "abcde" };
        struct sortable I = {str_array, 5, sizeof(char *), str_compare};
        i_sort(&I);
        for (int i = 0; i < 5; i++) {
                printf("%s ", str_array[i]);
        }
        printf("
");
        return 0;
}

這份程式碼,用開啟 -std=c99-Wall 選項的 gcc 與 clang 編譯,不會出現警告或錯誤資訊,但是用嚴格的 ISO C99 標準來編譯,即開啟 -pedantic 選項,編譯器就會給出警告。例如:

$ gcc  -pedantic -Wall -std=c11 test.c
test.c: In function ‘i_sort’:
test.c:13:43: warning: ISO C forbids passing argument 4 of ‘qsort’ between function pointer and ‘void *’ [-Wpedantic]
         qsort(I->base, I->nmemb, I->size, I->compare);
                                           ^
In file included from test.c:2:0:
/usr/include/stdlib.h:764:13: note: expected ‘__compar_fn_t {aka int (*)(const void *, const void *)}’ but argument is of type ‘void *’
 extern void qsort (void *__base, size_t __nmemb, size_t __size,
             ^
test.c: In function ‘main’:
test.c:24:60: warning: ISO C forbids initialization between function pointer and ‘void *’ [-Wpedantic]
         struct sortable I = {str_array, 5, sizeof(char *), str_compare};
                                                            ^
test.c:24:60: note: (near initialization for ‘I.compare’)

這些警告資訊指出,void * 型別與 int (*)(const void *, const void *) 型別不相容。但是,在 ISO C 標準中,這種行為屬於未定義行為,依賴於編譯器的實現。至少在 GCC, CLang 以及 MSVC 這幾個主流的 C 編譯器以及 Linux/Solaris/Windows/OS X 這些主流的作業系統上,這樣做是沒有問題的。

在主流作業系統上廣泛使用的 GLib 庫中,整個訊號/槽機制完全建立在 void 指標與函式指標的型別轉換上。在所有相容 POSIX.1-2001 標準的作業系統上,void 指標與函式指標的型別轉換總是安全的,因為 POSIX.1-2001 標準規定的 dlsym 函式也是基於 void 指標與函式指標的型別轉換實現的。

介面即泛型

Go 語言的 sort 函式用法一個簡單示例:

package main

import (
    "fmt"
    "sort"
)

type Person struct {
    Name string
    Age  int
}

func (p Person) String() string {
    return fmt.Sprintf("%s: %d", p.Name, p.Age)
}

type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func main() {
    people := []Person{
        {"Bob", 31},
        {"John", 42},
        {"Michael", 17},
        {"Jenny", 26},
    }

    fmt.Println(people)
    sort.Sort(ByAge(people))
    fmt.Println(people)
}

如果你能看懂前面我用 C 實現的 i_sort,即使你不懂 Go 語言,也能看出來 Go 在用介面做什麼。

顯然用介面,可以實現函式的泛型。

有人說,Generics 其實就是在 Haskell 等函式式語言裡面所謂的 parametric polymorphism,是一種非常有用的東西。

事實上,Haskell 的引數化多型往往需要藉助型別類(Type Class)才能達到泛型的效果,然而型別類與介面有什麼本質上的不同麼?例如,Hasekll 的 Eq 是一個型別類,它的定義就是 == 函式簽名:

class Eq a where
    (==) :: a -> a -> Bool

所有屬於 Eq 型別類的型別都支援 == 運算,但是在定義 Eq 例項的時候,必須給出 == 的具體實現……這難道不是介面麼?

應該怎麼泛,自己看著辦

基於模板的泛型,本質上就是將型別運算層面的東西轉化為資料攤開,然後再用查表的辦法尋找目標資料。

C++ 的做法就是,如果一個泛型的 sort 函式要處理 100 種資料型別,那麼就把泛型的 sort 函式攤開為 100 個相應型別的 sort 函式,然後編譯器從裡面查詢一個與具體問題相關的 add 版本。

模板泛型,實現起來較為簡單,型別識別任務,在編譯期就可以完成,沒有執行時負擔。但是,缺點也顯而易見,如果資料型別過多,泛型容器、泛型演算法以及泛型迭代器,會讓程式碼發生膨脹。如果還沒遇到這樣的問題,那麼應該慶幸,自己的程式還是太小了,還沒有湊夠 10000 種資料型別要做 sort 運算。

C++ 的模板函式的引數型別,在遵守遊戲規則的前提下,可被編譯器正確推匯出來,但是模板函式的返回值型別至少在 C++11 中是無法自動推匯出來的。C++ 泛型容器與迭代器中的資料型別,需要使用者顯式設定,例如 list<int> integers,這在性質上與不安全的強制型別轉換沒什麼區別。

還有一種是基於型別擦除的方式模擬的模板泛,程式碼不膨脹,也沒有執行時負擔,但型別不夠安全。

介面泛型,就是我前面囉哩囉嗦的那些辦法。比較直觀,只需按照泛型函式所需要的介面形式,創造一個介面,將資料以及與資料密切相關的基本運算通過介面傳給泛型函式即可。C++ 標準庫中的迭代器與分配器其實都是介面。

對於 i_sort 這樣的泛型函式,由於它是呼叫 qsort 的,這限定了只能將陣列通過介面傳遞給 i_sort。如果你的資料是存在連結串列裡的,那麼你只能將連結串列轉化為陣列,然後傳給 i_sort。如果是我們自己實現的 qsort,那就完全可以像 Go 語言那樣通過介面來泛。

介面泛型,不會導致程式碼膨脹,型別也足夠安全,缺點是有一點執行時負擔,特別是 C 語言通過函式指標實現的介面,編譯器無法對程式碼做 inline 優化。

相關文章