C++
的泛型编程是一种非常强大的武器。但它看上去复杂的语法,以及背后不明的原理,一直让很多程序员望而生畏。很多即便已经使用了C++
很久的程序员也仅仅敢触碰其非常表象的部分。还有一些大胆而莽撞的程序员则对其进行滥用,让本来可以简单解决的问题,变成秀技场。因而,泛型技术一直得到一个不公正的名声:奇技淫巧。
但是,泛型编程对于很多问题的高效解决是不可或缺的。它是C++
最重要的元编程工具,也是很多框架制作的利器,也是组合式设计的主要手段之一。不掌握它,C++
的强大威力将大打折扣。
我们需要深入挖掘其背后的原理及本质,这样才能理解它,掌握它,在合适的场合使用它,避免滥用和无用。
C++
的泛型编程,其本质其实非常简单:为了做基于类型和常量的编译时多态。(关于多态的目的和价值,请参阅《多态,FP与OO》)
而C++
的编译时多态由如下四种类型构成:
- 模版
- 函数(操作符)重载
- 模板特化
- Duck Typing
我们下面一一介绍。
1. 模版:参数化多态
先看一段简单的haskell
代码:
max :: (Ord a) => a -> a -> a
max x y = if x > y then x else y
在这个实现里,a
是类型变量,(Ord a)
表示对a
约束,意思是:对于所有属于typeclass Ord
的类型a
,都可以实例化这个函数。
这种用法被称作参数化多态。很显然,参数化多态是为了避免基于类型的重复代码。
而回到C++
,基于模版的实现则为:
template <typename T>
T max(T a, T b)
{
return a > b ? a : b;
}
参数化多态,是C++
提供泛型的最初目的。STL
的各种容器和算法,基本上都是基于这类目的设计的。这也是最容易理解,被使用最为广泛的C++
编译时多态技术。
Concept
上述haskell
代码中的约束Ord a
,到C++14
为止尚未支持,但已经确定要在C++17
中支持(被称作Concept)。
高阶类型
上述haskell
例子中的多态类型属于rank 1 type
,但rank 1 type
无法支持这类的代码:
g f = (f True, f 'a')
其中,参数f
作为一个函数,在两次调用时传入的参数类型不同:分别为Bool
和Char
。这属于Rank 2 Type
的范畴。而为了支持Rank 2 Type
,GHC
提供了一个扩展`RankNTypes',并且要求程序员给出类型注解:
{-# LANGUAGE RankNTypes #-}
g :: (forall a. a -> a) -> (Bool, Char)
g f = (f True, f 'a')
而在C++
中,对应的实现技术为Template Template Parameter
。比如:
template <typename T, typename G,
template <typename E> class Container >
struct Foo
{
Container<T> tContainer;
Container<G> gContainer;
};
这就是参数化多态的全部:非常简单,因而被很多语言采纳和引入,也被程序员广泛的使用。
2. 函数重载:Ad-hoc多态
学习任何一门语言的第一个例子,往往都是Hello World
:
std::cout << "Hello, 2016!" << std::endl;
如果我们让程序稍微复杂一些,让这段代码可以对在任何一年都可以复用,那么我们可以提供一个类似于下面的函数:
void helloNewYear(unsigned int year)
{
std::cout << "Hello, " << year << "!" << std::endl;
}
在这个实现中,对于字符串和整数这两种不同类型的数据,std::cout
可以用一致的方式来操作。
按照多态四要素,std::cout
即是客户;同一外表是操作符<<
;而不同形态则是针对不同类型的不同实现:
std::ostream& operator<<(std::ostream& os, const std::string& str);
std::ostream& operator<<(std::ostream& os, const int value);
// ...
而编译器会根据类型进行匹配(这是一种简化版本的模式匹配),从而在不同形态间进行选择。
因而函数重载是一种多态,而这样的多态被称作ad-hoc
多态。
例子:双重派发
双重派发(Double Dispatch
),是访问者模式(Visitor Pattern
)依托的的实现技术。
假设,一颗语法树,上面有多种类型的节点。比如:Statement
,Expression
。
而一个访问者则必须提供针对各种类型节点的访问函数,比如:
struct Visitor
{
virtual void visit(Statement&) = 0;
virtual void visit(Expression&) = 0;
virtual ~Visitor() {}
};
所有的节点都需要实现accept
方法,所以它们都需要实现下面的接口:
struct Element
{
virtual void accept(Visitor&) = 0;
virtual ~Element() {}
};
而每个具体的节点在实现此接口时,分别调用Visitor
中针对自己类型的成员函数:
struct Statement: Element
{
void accept(Visitor& visitor)
{
visitor.visit(*this);
}
// ...
};
struct Expression: Element
{
void accept(Visitor& visitor)
{
visitor.visit(*this);
}
// ...
};
所谓双重派发,指的正是两个多态结构:
- 访问算法通过接口
accept
,在运行时将visitor
派发给相应的Element
; - 具体的
Element
再从visitor
的多个访问函数中,选择归属于自己的那一个,通过它,将自己派发给visitor
。
其中,前者使用的是运行时多态,后者使用的则是函数重载。有了函数重载,就完全没必要让每个具体的Element
重复的编写完全相同的accept
实现。所以,我们可以将上述代码重构为:
template <typename T>
struct AutoDispatchElement : Element
{
void accept(Visitor& visitor)
{
visitor.visit((T&)*this);
}
};
struct Statement
: AutoDispatchElement<Statement>
{
// ...
};
struct Expression
: AutoDispatchElement<Expression>
{
// ...
};
并非所有重载都是为了多态
需要强调的是:尽管函数(操作符)重载可以用来实现编译时多态,但并非所有的重载都是为了多态。大多数情况下,操作符的重载都是为了提高表达力,比如:
// 让复数可以进行 a + b 形式的操作
Complex operator+(const Complex& lhs, const Complex& rhs);
// 让向量可以进行 vector[5] 形式的操作
template <typename T>
T Vector<T>::operator[](unsigned int index);
至于那些参数个数不同的重载函数,与多态更是没有什么关系,比如:
void f(int a)
void f(short a, double b);
这种样式的重载,往往是因为开发人员懒得为之取一个更准确的名字,从而滥用了语言提供的重载机制,其结果反而伤害了表达力。对于这样的重载,要尽量避免。
小结
这一部分我们首先介绍了C++
范型最为简单的两种多态:以基于类型的重用为目的参数化多态和以函数重载为手段的ad-hoc多态。
在后续的文章里,会继续介绍更为复杂的C++
编译时多态技术。