3

有言在先,本文仅仅是从 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 * 类型的 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("\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 优化。


garfileo
6k 声望1.9k 粉丝

这里可能不会再更新了。