谁是真泛型


来自: SegmentFault

作者:garfileo

链接:https://segmentfault.com/a/1190000004171424


前两天写了篇表面上是批判 C++ 泛型但实际上只是自己的一点点反思的文章,目的只是说服自己以及那些像我一样被 C++ 折磨的欲仙欲死的人,以后不要再在 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,而一个 Lua 或 Python 解释器还不到 1 MB,Haskell 的解释器 ghc 刚 1 MB 多一点……


模板代码中如果存在错误,编译器产生的错误信息也膨胀了,特别是模板类的嵌套嵌套再嵌套,或者模板实例非常多的时候,编译出错信息无法卒读,甚至有人说编译出错信息甚至超出了他用的文本编辑器的缓存空间大小。


类型擦除


两天前,我不知道类型擦除是个什么东西,只是看了 Vala 语言 所实现的泛型之后才知道这个概念。因为 Vala 语言是编译到 C 的,所以很容易看到它的泛型是如何实现的。


下面是 Vala 模板类的示例:


public class Wrapper : 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();

 wrapper_str.set_data("test");

 var s = wrapper_str.get_data();

var wrapper_int = new Wrapper();

 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 * 指针的强制类型转换。 C 语言要模拟泛型编程,最自然的方式就是程序猿手动对 void * 进行类型转换,GLib 库中的所有数据容器都是这么做出来的。由于 Vala 编译器会对模板参数进行类型检查,因此基本上不需要担心 void * 的强制类型转换会导致类型不安全的问题。后来,看了几篇 Java 泛型的文档,才知道原来 Vala 的这个做法叫『类型擦除』。


类型擦除的最大特点是没有什么东西会膨胀,因为一个模板的全部实例会共享同一份代码。


谁是真泛型?


很多人说 Java 的泛型是伪泛型,那么 Vala 的泛型自然也是伪泛型了。也许我的世界观有问题,我总觉得类型擦除才是真的泛型,因为它能真实的模拟现实中的『泛型』。


现实中,我们所谓的泛型,例如一个登山包,你可以用它来装任何它能装得下的东西。你去驴行时,登山包里可以装水杯、书籍、手机/平板、充电器、帐篷、睡袋、救生用品等等;如果你不是去旅游,而是去逛超市,依然可以用这个登山包将所买的东西带回家。你肯定不会背着一大堆包去旅游或者去逛超市,其中装水杯包的叫水杯包,装手机的包叫手机包,装平板的包叫平板包,装面包的包叫面包……而且这些包都跟登山包差不多大——在 C++ 中,你所生成的程度必须背着这样的一大堆包去驴行或逛超市。


从 C++ 11 开始,有右值引用了,模板变得比以前更好用了。在 C++ 14 中,连匿名函数也支持泛型了……我觉得 C++ 模板所带来的代码膨胀迟早会走进寻常百姓家的。


事实上,Boost 库中的一些容器已经引入了类型擦除技术[2],例如 boost::any, boost::variant, boost::function 等等。虽然它们采用类型擦除技术的本意并非针对模板代码膨胀问题,只是一种模拟,而且依然存在着模板代码膨胀的问题。很久以前还看过一篇论文,名字忘记了,讲的是如何在 C++ 中利用类型擦除技术来调和面向对象编程与泛型编程之间的矛盾的。在 C++ 社区,类型擦除技术绝对是很高级的技术,之所以如此穷折腾,真的不是因为 C++ 编译器不支持类型擦除的缘故吗?


C++ 中的类型擦除技术是基于模板模拟出来的,其基本原理就是将类模板转化为函数模板[3]。C++ 编译器能够自动推导出函数模板参数的实例,从而让程序猿在写代码的时候无需设定模板参数,再借助运行时类型识别(RTTI)或函数模板取出被擦除了类型的数据。从本质上来说,这种类型擦除技术依然无法避免模板的膨胀,但是这个模拟过程已经将大部分与模板参数无关的代码抽离了出来。


有趣的是,《C++ Primer》第四版的中文译本在第 16 章『模板与泛型编程』中的导言部分很不严肃的将泛型编程定义为『以独立于任何特定类型的方式编写代码』。难道真的泛型不应该是以独立于任何特定类型的方式去编写独立于特定类型的代码么?如果 C++ 模板真的适合做编写独立于特定类型的代码这样的事,那么就不需要去将与参数无关的代码从模板中抽离出来了,也不需要有运算符重载、Traits 类、模板特化与偏特化等补救机制了(一直都感觉 C++ 太擅长解决那些它自身制造出来的问题了)。《C++ Primer》第 5 版的『模板与泛型编程』章的导言部分已将这个不严肃的泛型编程定义去掉了。


泛型的敌人


Vala 语言除了 GNOME 开发者之外没有多少人用,所以它是真泛型还是伪泛型,对这个世界几乎没有影响。


Java 的泛型引起的问题已经广为人知 [4-6],而且也因此获得『伪泛型』的伪大称号。但是,我觉得他们所说的 Java 泛型所引起的那些问题是面向对象编程范式引起的。因为他们所指出的那些问题,往往是在面向对象编程范式中使用泛型编程范式的场景中出现的。如果类型擦除真的不行,那么 Java 是如何实现了它的『STL』的?连 Vala 这种微不足道的小语言也实现了一些『STL』容器。


面向对象编程范式与泛型编程范式是矛盾的,熟悉 C++ STL 的人应该知道这个事实。


STL 之父 Alexander Stepanov 是反面向对象编程范式的。他在 1995 年的一次访谈[7]中说:『STL 不是面向对象的。我认为面向对象和人工智能差不多,都是个骗局……我发现面向对象编程在技术上是错误的,它妄图用基于单一类型的不同接口来分解世界,为了处理不同的实际问题你需要不同种类的代数学——横跨不同类型的接口族;我发现面向对象编程在哲学上是错误的,它声称一切都是一个对象。即使真的是这样这也不是很有趣─说一切都是对象跟什么都没说一样;我发现面向对象编程的方法论是错误的,它从类开始。就好像数学要从公理开始一样。你不是从公理开始——你是从证明开始。直到你找到了一大堆相关证据你才能归纳出公理。你是以公理结束。编程上存在着同样的事实:你要从有趣的算法开始。只有很好地理解了算法,你才有可能提出接口以让其工作。』


虽然 Alexander Stepanov 说的挺精彩,然而 STL 库里依然有一些类的继承,例如五种迭代器之间的关系;应该将 Alexander Stepanov 的话理解为他反对的是编程工作从类的设计开始。如果将很矛盾的两种世界观体混在在代码中,出现了冲突,这难道不是很正常么?为何要将这种矛盾归罪于类型擦除?C++ 模板之所以被大家视为真泛型,无非是因为 C++ 模板本来也是从面向对象编程范式中诞生的。用模板膨胀出一堆重复的代码,这种方式与面向对象编程范式中的类的派生如出一辙,这也恰恰就是 STL 之父所反对的『数学要从公理开始』。


泛型的世界是平坦的,没有继承,没有多态,例如你不能在自己的代码中去继承 STL 容器。我觉得 STL 的精华之处并不在与它提供了许多有用的数据容器,而在于容器、迭代器与算法这三者处于一个平坦的世界,并且被优美的组合了起来。

你可能感兴趣的:(C++)