泛型编程源起、实现与意义

来源:互联网 发布:js取指定时间的时间戳 编辑:程序博客网 时间:2024/06/04 19:07
 
泛型编程源起、实现与意义(上)
 
 
 
◎ 文/刘未鹏
 
为什么泛型
泛型编程(Generic Programming)最初提出时的动机很简单直接:发明一种语言机制,能够帮助实现一个通用的标准容器库。所谓通用的标准容器库,就是要能够做到,比如用一个List类存放所有可能类型的对象,这样的事情;熟悉一些其它面向对象的语言的人应该知道,如Java里面这是通过在List里面存放Object引用来实现的。Java的单根继承在这里起到了关键的作用。然而单根继承对C++这样的处在语言链底层的语言却是不能承受之重。此外使用单根继承来实现通用容器也会带来效率和类型安全方面的问题,两者都与C++的理念不相吻合。
于是C++另谋他法——除了单根继承之外,另一个实现通用容器的方案就是使用“参数化类型”。一个容器需要能够存放任何类型的对象,那干脆就把这个对象的类型“抽”出来,参数化它[1]:
 
template<class T> class vector {
 T* v;
 int sz;
public:
 vector(int);
 T& operator[](int);
 T& elem(int i) { return v[i]; }
 // ...
};
 
一般来说看到这个定义的时候,每个人都会想到C的宏。的确,模板和宏在精神上的确有相仿之处。而且的确,也有人使用C的宏来实现通用容器。模板是将一个定义里面的类型参数化出来,而宏也可以做到参数化类型。甚至某种意义上可以说宏是模板的超集——因为宏不仅可以参数化类型,宏实质上可以参数化一切文本,因为它本来就是一个文本替换工具。然而,跟模板相比,宏的最大的缺点就是它并不工作在C++的语法解析层面,宏是由预处理器来处理的,而在预处理器的眼里没有C++,只有一堆文本,因此C++的类型检查根本不起作用。比如上面的定义如果用宏来实现,那么就算你传进去的T不是一个类型,预处理器也不会报错;只有等到文本替换完了,到C++编译器工作的时候才会发现一堆莫名其妙的类型错误,但那个时候错误就已经到处都是了。往往最后会抛出一堆吓人的编译错误。更何况宏基本无法调试。
我们再来看一看通用算法,这是泛型的另一个动机。比如我们熟悉的C的qsort:
 
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
 
这个算法有如下几个问题:
1. 类型安全性:使用者必须自行保证base指向的数组的元素类型和compar的两个参数的类型是一致的;使用者必须自行保证size必须是数组元素类型的大小。
2. 通用性:qsort对参数数组的二进制接口有严格要求——它必须是一个内存连续的数组。如果你实现了一个巧妙的、分段连续的自定义数组,就没法使用qsort了。
3. 接口直观性:如果你有一个数组char* arr = new arr[10];那么该数组的元素类型其实就已经“透露”了它自己的大小。然而qsort把数组的元素类型给“void”掉了(void *base),于是丢失掉了这一信息,而只能让调用方手动提供一个size。为什么要把数组类型声明为void*?因为除此之外别无它法,声明为任意一个类型的指针都不妥(compar的参数类型也是如此)。qsort为了通用性,把类型信息丢掉了,进而导致了必须用额外的参数来提供类型大小信息。在这个特定的算法里问题还不明显,毕竟只多一个size参数而已,但一旦涉及的类型信息多了起来,其接口的可伸缩性(scalability)问题和直观性问题就会逐渐显现。
4. 效率:compar是通过函数指针调用的,这带来了一定的开销。但跟上面的其它问题比起来这个问题还不是最严重的。
泛型编程
泛型编程最初诞生于C++中,由Alexander Stepanov[2]和David Musser[3]创立。目的是为了实现C++的STL(标准模板库)。其语言支持机制就是模板(Templates)。模板的精神其实很简单:参数化类型。换句话说,把一个原本特定于某个类型的算法或类当中的类型信息抽掉,抽出来做成模板参数T。比如qsort泛化之后就变成了:
 
template<class RandomAccessIterator, class Compare>
void sort(RandomAccessIterator first, RandomAccessIterator last,
        Compare comp);
 
其中first,last这一对迭代器代表一个前闭后开区间,迭代器和前开后闭区间都是STL的核心概念。迭代器建模的是内建指针的接口(解引用、递增、递减等)、前开后闭区间是一个简单的数学概念,表示从first(含first)到last(不含last)的区间内的所有元素。此外,comp是一个仿函数(functor)。仿函数也是STL的核心概念,仿函数是建模的内建函数的接口,一个仿函数可以是一个内建的函数,也可以是一个重载了operator()的类对象,只要是支持函数调用的语法形式就可成为一个仿函数。
通过操作符重载,C++允许了自定义类型具有跟内建类型同样的使用接口;又通过模板这样的参数化类型机制,C++允许了一个算法或类定义,能够利用这样的接口一致性来对自身进行泛化。例如,一个原本操作内建指针的算法,被泛化为操纵一切迭代器的算法。一个原本使用内建函数指针的算法,被泛化为能够接受一切重载了函数调用操作符(operator())的类对象的算法。
让我们来看一看模板是如何解决上面所说的qsort的各个问题的:
1. 类型安全性:如果你调用std::sort(arr, arr + n, comp);那么comp的类型就必须要和arr的数组元素类型一致,否则编译器就会帮你检查出来。而且comp的参数类型再也不用const void*这种不直观的表示了,而是可以直接声明为对应的数组元素的类型。
2. 通用性:这个刚才已经说过了。泛型的核心目的之一就是通用性。std::sort可以用于一切迭代器,其compare函数可以是一切支持函数调用语法的对象。如果你想要将std::sort用在你自己的容器上的话,你只要定义一个自己的迭代器类(严格来说是一个随机访问迭代器,STL对迭代器的访问能力有一些分类,随机访问迭代器具有建模的内建指针的访问能力),如果需要的话,再定义一个自己的仿函数类即可。
3. 接口直观性:跟qsort相比,std::sort的使用接口上没有多余的东西,也没有不直观的size参数。一个有待排序的区间,一个代表比较标准的仿函数,仅此而已[4]。
4. 效率:如果你传给std::sort的compare函数是一个自定义了operator()的仿函数。那么编译器就能够利用类型信息,将对该仿函数的operatpr()调用直接内联。消除函数调用开销。
 
 
注解:[1] B. Stroustrup: A History of C++: 1979-1991. Proc ACM History of Programming Languages conference (HOPL-2). March 1993。
实际上,还有一种实现通用容器的办法。只不过它更糟糕:它要求任何能存放在容器内的类型都继承自一个NodeBase,NodeBase里面有pre和next指针,通过这种方式,就可以将任意类型链入一个链表内了。但这种方式的致命缺点是(1)它是侵入性的,每个能够放在该容器内的类型都必须继承自NodeBase基类。(2)它不支持基本内建类型(int、double等),因为内建类型并不,也不能继承自NodeBase。这还姑且不说它是类型不安全的,以及效率问题。
[2] http://en.wikipedia.org/wiki/Alexander_Stepanov
[3] http://www.cs.rpi.edu/~musser
[4] 实际上,STL的区间概念被证明是一个不完美的抽象。你有没有发现,要传递一个区间给一个函数,如std::sort,你需要传递两个参数,一个是区间的开头,一个是区间的末尾。这种分离的参数传递方式被证明是不明智的,在一些场合会带来使用上不必要的麻烦。比如你想迭代一组文件,代表这组文件的区间由一个readdir_sequence函数返回,由于要分离表达一个区间,你就必须写:
readdir_sequence entries(".", readdir_sequence::files);
std::for_each(entries.begin(), entries.end(), ::remove);
如果你只想遍历这个区间一次的话,你也许不想声明entries这个变量,毕竟多一个名字就多一个累赘,你也许只想:
std::for_each(readdir_sequence(".", readdir_sequence::files), ::remove);
下一代C++标准(C++09)会解决这个问题(将区间这个抽象定义为一个整体)。■