有言在先,本文仅仅是从 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("\n");
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("\n");
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 优化。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。