有言在先,本文僅僅是從 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 *
型別的 s1
與 s2
進行了強制型別轉換。如果你不清楚 s1
與 s2
的實際型別,很容易在這個環節出錯。
要不要給型別買個意外傷害保險
型別不安全的程式碼彌散在大規模的程式中,可能會導致程式陷入萬劫不復的境地。這是很多人談 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);
}
看,現在我們不再需要對 s1
與 s2
進行強制型別轉換了,而是直接解引用,然後將它們傳給 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 優化。