前两天写了篇表面上是批判 C++ 泛型但实际上只是自己的一点反思的文章。可能文中对 C++ 略有讥诮之态,而且文章也过长甚至有些水,没能突出我真正想表达的那些东西,结果导致与一哥们发生了一次不愉快的争论。事后,我将那篇文章删掉了。现在重新整理一下我的观点,我会尽量严肃。但是依然要事先声明一下,我不是任何一种语言的专家,在此只是表达一下个人的喜好……姑妄言之,姑妄听之。
C++ 的泛型编程是基于模板实现的,而 C++ 的模板采用的是代码膨胀技术。例如 std::list
容器,如果你将 int
类型的数据存进去,C++ 编译器就为你生成一个专门用来存 int
类型数据的列表数据结构。也就是说,你向 std::list
容器中存放什么类型,C++ 编译器就为你生成相应的列表数据结构。理论上,数据的类型是无限的,因此 C++ 要生成的列表数据结构也是无限的。如果你的程序中有大量的数据类型要存到 std::list
容器,那么代码就会高度膨胀,这种膨胀是 C++ 在目标文件连接阶段无法优化的。
现实中,可能你没见过模板引起的代码膨胀的事例,所以对此不以为然。我也没见过,因为我属于几乎不用 C++ 写代码并且几乎不关注 C++ 世界都发生了什么的那种人。没见过,不等于没有。我看到的一本讲 C++ 模板编程的书(担心有人再认为我将一本国产书视为圣经,书名我就不提了)里提到应用 boost::spirit 时很容易出现代码极度膨胀的情况,类似的事在 [1] 中也提到了。
《Effective C++》的作者可能见过代码膨胀的例子,所以他在条款 44 中建议『将与参数无关的代码抽离 templates』。这个条款也许是 C++ 应对模板导致的代码膨胀问题的唯一解决方案了,然而这个方案往往并不是那么容易实现。你需要仔细审度你的代码,认真的从模板类(或模板函数)中将那些不涉及模板参数的代码抽离出来做成基类(或辅助函数)。即使你能很好的做到这一点,但是请认真想一想,这样做真的有意义么?
模板技术原本是为了简化编程任务而被提出来的,但是要消除模板带来的代码膨胀,你不得不对本来逻辑很清晰的代码进行肢解,这个过程或多或少的都会破坏你原来很清晰的逻辑,结果弄出来一个浑身插着电线的模板类或模板函数。
C++ 模板代码所导致的膨胀,主要带来以下问题:
源代码膨胀了,因为要做『将与参数无关的代码从模板中抽离』这件事。有人做过试验,即使是一个不太大的 List 实现,将代码从模板中抽离后,导致源代码膨胀了 20%……其实开发效率也自然降低了很多。
编译时间被拖长了,因为编译器在代码编译阶段要对模板代码进行『惰性计算』,要产生模板的实例代码,在目标文件连接阶段还要消除各个目标文件中重复的模板代码。
目标文件膨胀了。有人说他用 boost::spirit 实现了一个很小的语法解析器,开了 GCC 的优化选项,目标文件也要几十 MB。
模板代码中如果存在错误,编译器产生的错误信息也膨胀了,特别是模板类的嵌套嵌套再嵌套,或者模板实例非常多的时候,编译出错信息无法卒读,甚至有人说编译出错信息甚至超出了他用的文本编辑器的缓存空间大小。
两天前,我不知道类型擦除是个什么东西,只是看了 Vala 语言 所实现的泛型之后才知道这个概念。因为 Vala 语言是编译到 C 的,所以很容易看到它的模板是如何实现的。
下面是 Vala 模板类的示例:
public class Wrapper<G> : GLib.Object { private G data; public void set_data(G data) { this.data = data; } public G get_data() { return this.data; } } void main() { var wrapper_str = new Wrapper<string>(); wrapper_str.set_data("test"); var s = wrapper_str.get_data(); var wrapper_int = new Wrapper<int>(); wrapper_int.set_data(100); var n = wrapper_int.get_data(); }
泛型之处在于:
private G data; wrapper_str.set_data("test"); var s = wrapper_str.get_data(); wrapper_int.set_data(100); var n = wrapper_int.get_data();
上述代码片段,会被 Vala 编译器编译为下面的 C 代码:
gpointer data; /* gpointer 类型就是 void * 类型 */ wrapper_set_data (wrapper_str, "test"); _tmp1_ = wrapper_get_data (wrapper_str); s = (gchar*) _tmp1_; wrapper_set_data (wrapper_int, (gpointer) ((gintptr) 100)); _tmp3_ = wrapper_get_data (wrapper_int); n = (gint) ((gintptr) _tmp3_);
如果不打算看懂这些代码也没关系。简单的说,Vala 的模板或泛型就是基于 void *
指针的强制类型转换。由于 Vala 编译器会对模板参数进行类型安全检查,因此基本上不需要担心 void *
的强制类型转换会导致类型不安全的问题。后来,看了几篇 Java 泛型的文档,才知道原来 Vala 的这个做法叫『类型擦除』。
类型擦除的最大特点是代码不会膨胀,因为一个模板的全部实例会共享同一份代码。 C 语言要模拟泛型编程,最自然的方式就是程序猿手动对 void *
进行类型转换,GLib 库中的所有数据容器都是这么做出来的。但是具有类型擦除功能的编译器,会检查模板参数类型的正确性,从而在一定程度上可以保证类型参数类型的安全性。
很多人说 Java 的泛型是伪泛型,那么 Vala 的泛型自然也是伪泛型了。也许我的世界观有问题,我总觉得类型擦除才是真的泛型,因为它能真实的模拟现实中的『泛型』。
现实中,我们所谓的容器,例如一个登山包,你可以用它来装任何它能装得下的东西。你去旅游时,登山包里可以装水杯、书籍、手机/平板、充电器、帐篷、睡袋、救生用品等等。你肯定不会背着一大堆包去旅游,其中装水杯包的叫水杯包,装手机的包叫手机包,装平板的包叫平板包……而且这些包都跟登山包差不多大——在 C++ 中,你就得背着这样的一大堆包。
从 C++ 11 开始,模板变得比以前更好用了。在 C++ 14 中,连匿名函数也支持泛型了……我觉得 C++ 模板所带来的代码膨胀迟早会走进寻常百姓家的。
Vala 语言除了 GNOME 开发者之外没有多少人用,所以它是真泛型还是伪泛型,对这个世界几乎没有影响。
Java 的泛型引起的问题已经广为人知 [2, 3],而且也因此获得『伪泛型』的伪大称号。但是,我觉得他们所说的 Java 泛型引起的问题其实并非泛型的问题,而是面向对象编程的问题。因为他们所指出的那些问题,很大程度上是在面向对象编程范式中使用泛型编程范式的场景中出现的。
面向对象编程范式与泛型编程范式是矛盾的,熟悉 C++ STL 的人应该是知道这个事实。
1995 年 STL 之父 Alexander Stepanov 是反面向对象编程范式的。他在一次访谈中说:『STL 不是面向对象的。我认为面向对象和人工智能差不多,都是个骗局……我发现面向对象编程在技术上是错误的,它妄图用基于单一类型的不同接口来分解世界,为了处理不同的实际问题你需要不同种类的代数学——横跨不同类型的接口族;我发现面向对象编程在哲学上是错误的,它声称一切都是一个对象。即使真的是这样这也不是很有趣─说一切都是对象跟什么都没说一样;我发现面向对象编程的方法论是错误的,它从类开始。就好像数学要从公理开始一样。你不是从公理开始——你是从证明开始。直到你找到了一大堆相关证据你才能归纳出公理。你是以公理结束。编程上存在着同样的事实:你要从有趣的算法开始。只有很好地理解了算法,你才有可能提出接口以让其工作。』
将很矛盾的两种世界观体现在代码中,出现了冲突,这难道不是很正常么?为何要将这种矛盾归罪于类型擦除?C++ 模板之所以被大家视为真泛型,无非是因为 C++ 模板本来也是从面向对象编程范式中诞生的。用模板膨胀出一堆重复的代码,这种方式与面向对象编程范式中的类的派生如出一辙,也恰恰就是 STL 之父所反对的『数学要从公理开始』。
泛型的世界是平坦的,没有继承,没有多态。我觉得 STL 的精华之处并不在与它提供了许多有用的数据容器,而在于容器、迭代器与算法这三者处于一个平坦的世界,并且被优美的组合了起来。
也许类型擦除技术与高阶函数组合起来也能实现一个同样优美且不会引起代码膨胀的『STL』,即:(1) 基于类型擦除实现数据容器;(2) 基于闭包实现迭代器;(3) 基于高阶函数实现算法。C 语言标准库提供的 qsort
就是这种方式的雏形。C++ 11 之后的 C++ 在一定程度上应该能做到这些[4]。
[1]
Vczh Library++3.0之我的语法分析器和boost::spirit
[2]
Java 的泛型
[3]
泛型的内部原理:类型擦除以及类型擦除带来的问题
[4]
通向现代 C++ 之路