现代C++学习指南 模板

·

模板作为C++重要的特性,一直有着举足轻重的地位,是编写高度抽象代码的利器。

什么是模板 #

模板在现实生活中就是范例:把都一样的部分固定起来,把变动的部分空出来,使用时将两部分合起来组成有效的东西。如申请书,Word模板都是这种形式。C++中的模板也是如此,不过更明确的是C++中的模板,变动的部分是一个代指类型的东西,称之为泛型参数。

我们先从一个例子来看一看模板是怎样发展而来的。如我们需要计算两个对象相加的结果,该如何写代码呢?在写代码前,我们有几个问题需要讨论清楚: 首先我们需要确定的是这两个对象是什么类型,毕竟C++是强类型的编程语言,变量,函数,类都是要明确指定类型是什么的,不确定的类型编译就不能通过。我们先假设这两个类型是整型。确定了类型之后,还需要确定这两个对象需要怎样加起来,根据我们假设的整型,我们知道可以直接调用运算符+。最后我们需要确定,两个对象相加后的结果类型是什么,整型相加的结果也是整型。综上,这个例子的代码看起来可能是这样的

int sum(int left,int right){
	return left + right;
}

这个例子很简单,简单到甚至都不需要单独写成一个函数。如果我们需要计算的数据不是两个数,而是一个数组的和呢?基于前面的分析和假设,我们也能很快实现相应的代码

int sum(const int data[], const std::size_t length) {
	int result{};
	for (int i = 0; i < length;++i) {
		result += *(data + i);
	}
	return result;
}

同样很简单。但是遗憾的是,这个函数通用性不强,它只能计算整型的数组和,假如我们需要计算带有小数点的数组和,它就不灵了,因为第一个参数类型不匹配,尽管我们知道sum的代码几乎都能复用,除了第一行的int需要替换成double。但是不能!我们只能复制一份,然后把int的地方改成double

double sum(const double data[], const std::size_t length) {
	double result{};
	for (int i = 0; i < length;++i) {
		result += *(data + i);
	}
	return result;
}

这时你就会发现问题了,这个过程,我们仅仅改变了类型信息。这样的问题还会继续增加,我们可能又需要求float的数组和,上面那个double的数组和同样匹配不了,因为floatdouble是两个类型。正是因为数据类型不一样,所以很多时候我们需要为不同的数据提供相似的代码,这在数据类型膨胀的情况下是很痛苦的,当对算法进行修改的时候我们需要保证所有的数据类型都被修改到,并且要逐个进行测试,这无疑会增加工作量,并放大错误率。但是实际有效的代码都是要明确类型的,如果类型不明确,编译器就没法确定代码是否合法,不确定的事情编译器就要报错,所以按照普通的思路,这个问题是无解的。 但是其实很多时候,这些相似的代码仅仅是数据类型不一样而已,对付这种重复的工作应该让给计算机来完成,也就是编译器。所以我们需要一种技术,让编译器先不管具体类型是什么,而是用一种特殊的类型来替换,这个类型可以替换成任何类型,用这个特殊的类型完成具体的算法,在使用的时候根据实际的需求,将类型信息提供给算法,让编译器生成满足所提供类型的具体算法,而这就是模板。这和生活中的模板思想上是共通的。算法是固定的部分,数据类型是可变的部分,两个合起来就是合法的C++代码。也就是利用模板,我们可以只写一个算法,借助编译器生成所有类型的算法,这些算法之间唯一不同的就是类型。 当然光有模板还不够,上面只解决了类型的问题,没有解决算法实现的问题。怎么说呢,如我们有一个需求,需要将数据先排序,再查找最大值。这对于数字(int,float,double等)类型是有效的,直接使用比较运算符(<,>)就可以完成了,但是假如想让这个算法适用于自定义类型呢?直接在模板实现中写比较运算符对自定义类型是无效的,因为自定义类型没有实现相对应的比较运算函数。解决方法也很简单,自定义类型实现相对应的比较运算符就行了。诸如此类的问题,在模板中会经常遇到,因为我们对类型的信息一无所知,但是又要确保几乎所有的类型都能正常运作,这就不得不运用各种技术对类型进行限定或者检测,这其实才是模板问题的精髓。所以模板问题不仅仅是类型问题,还是其他C++问题的综合体,需要对C++特性有着较为完整的理解,才能写出有用高效的代码。 C++中通常将模板分为函数模板和模板类,我们先从比较简单的函数模板开始认识。

函数模板 #

函数模板是一种函数,和普通函数不一样的地方是,它的参数列表中至少有一个是不确定类型的。我们用开头的例子来小试牛刀:

template <typename T>
T sum(const T data[], const std::size_t length) {
	T result{};
	for (int i = 0; i < length;++i) {
		result += *(data + i);
	}
	return result;
}

int main() {
	int intData[] = { 1, 1, 2, 2 };
	float floatData[] = { 1, 1, 2, 2 };
	double doubleData[] = { 1, 1, 2, 2 };
	auto len = sizeof(intData)/sizeof(intData[0]);

	std::cout << "intSum = " << sum<int>(intData,len) << ", floatSum = " << sum<float>(floatData,len) <<", doubleSum = " <<sum<double>(doubleData,len)<<std::endl;
	return 0;
}
// 输出
// intSum = 6, floatSum = 6, doubleSum = 6

在这里,我们仅仅写了一个函数,就可以同时适用于intfloatdouble。如果还有其它类型实现了默认初始化和运算符+=就同样可以使用这个函数来求和,不需要改动任何现有代码,这就是模板的魅力。 在继续看新东西前,我们先来认识一下函数模板和普通函数之间有什么不同:

  1. 函数模板需要一个模板头,即template<typename T>。它的作用是告诉编译器下面的函数中遇到T的地方都不是具体类型,需要在调用函数时再确定。
  2. 函数声明中,类型位置被T替代了,也就是说T是一个占位类型,可以将它当作普通类型来用。在写模板代码时,这是很有用的。

再来看使用函数的地方,也就是类似sum<xxx>(xxxData,len)的语句,其中的xxx代表数据类型,也就是函数模板中T的实际类型。简单来说就告诉编译器,用类型xxx替换函数模板中的类型T,这个过程有个官方的名字,实例化,这是另一个和普通函数不一样的地方.。用函数模板是需要经过两个步骤的。

  1. 定义模板。这一步没有具体类型,需要使用一个泛型参数来对类型占位,也就是只要是出现实际类型的地方,都要使用泛型参数来占位,并用这个泛型参数来实现完整的算法。这一步编译器由于不知道具体类型,不会对一些类型操作进行禁止,而只是检查标识符是否存在,语法是否合法等。
  2. 实例化。实例化的过程只会发生在开发者调用函数模板的地方,没有实例化的函数模板的代码是不会出现在最执行文件中的。编译器会对每一处发生实例化的地方,用实际参数来替换泛型参数,并检查实际类型是否支持算法中所有的操作,如果不支持,则编译失败,需要开发者实现相关的操作或者修改函数模板。如上例中,假如我们用一个自定义类型来实例化,就会发现编译无法通过,因为自定义类型没有定义操作符+=(除非该操作符已经被定义了),这个过程就发生在实例化。解决方案也很简单,对自定义类型添加操作符+=即可。

类型推导 #

在上例中,我们发现在实例化的过程中,要同时给函数模板传递类型参数和数据参数,并且类型参数往往和数据的类型是一一对应的,这中冗杂的语法对于现代C++来说是不可接受的,所以现代C++编译器都支持类型推导。类型推导可以让开发者省略类型参数,直接根据数据类型来推导出类型参数,所以上例实例化都可以写成sum(xxxData,len)的形式,编译器能分别推导出xxx的类型是int,floatdouble。 当然类型推导也不是万能的,我们来看下面这个例子

template <typename T>
T max(T a, T b) {
	return a > b ? a : b;
}
int main() {
	int a = 1;
	int b = 2;
	std::cout << "max(" << a << ","<<b<<") = " <<max(a,b) << std::endl;
	return 0;
}

// 输出
// max(1,2) = 2

这个例子很直观,结果当然也毫无意外。现在我们要变形了:我们把变量b的类型改为float,就会发现编译无法通过了。提示我们数据类型不匹配,因为aintbfloat,所以推导出的结果就是max<int,float>(),而实际上我们是只有一个类型参数的。 那既然问题很明了,解决方法也似乎很简单,给max再加一个参数不就行了吗?我们来看一看。

template <typename A,typename B>
A max(A a, B b) {
	return a > b ? a : b;
}
int main() {
	int a = 1;
	float b = 2;
	std::cout << "max(" << a << ","<<b<<") = " <<max(a,b) << std::endl;
	return 0;
}

// 输出
// max(1,2) = 2

经过这样改之后,编译和运行都不报错了,问题似乎解决了,是吗? 并不是,我们把float b = 2;换成float b = 2.5;

int main() {
	int a = 1;
	float b = 2.5;
	std::cout << "max(" << a << ","<<b<<") = " <<max(a,b) << std::endl;
	return 0;
}

// 输出
// max(1,2.5) = 2

再次运行程序,就会发现输出是错误的了。因为函数模板中,我们把返回值定义成了A,在实例化的时候A被推导成了int类型,所以实际上max的返回值就成了int类型,最大值B就被从float强制转换成了int类型,丢失了数据精度。那有没有解决方法呢?有的,而且不止一种! 根据上面的分析,其问题的根本是数据被强转了,解决方案当然就是阻止它发生强转,也就是保持两种数据类型是一致的,那怎么保证呢?阻止编译器的类型推导,手动填写类型参数。

int main() {
	int a = 1;
	float b = 2.5;
	std::cout << "max(" << a << ","<<b<<") = " <<max<float>(a,b) << std::endl;
	return 0;
}

// 输出
// max(1,2.5) = 2.5

可以看到在此例中,我们只填写了一个类型参数,因为类型B会自动推导成float。没错,类型推导是可以部分禁用的! 另一种解决方案就是完全让编译器计算类型。怎么计算呢,C++11提供了autodecltypeauto可以计算变量的类型,decltype可以计算表达式的类型,用法如下:

auto a=1; // a被推导成int类型
auto b=1.5; // b被推导成double类型
decltype(a+b) //结果是double类型

也就是可以将返回值置为auto,然后让编译器决定返回类型

template <typename A,typename B>
auto max(A a, B b) {
	return a > b ? a : b;
}
int main() {
	int a = 1;
	float b = 2.5;
	std::cout << "max(" << a << ","<<b<<") = " <<max<float>(a,b) << std::endl;
	return 0;
}

// 输出
// max(1,2.5) = 2.5

假如编译器只支持C++11的话,会麻烦一点,不仅要前置auto,在函数头后还要使用decltype来计算返回类型,这个特性称为尾返回推导。

template <typename A,typename B>
auto max(A a, B b)->decltype(a + b) {
	return a > b ? a : b;
}

这里decltype里面写的是 函数模板暂时放一放,我们来看一看类模板是怎样的。

类模板 #

和函数模板一样,类模板也至少包含一个泛型参数,这个泛型参数的作用域是整个类,也就是说可以使用这个泛型参数定义成员变量和成员函数。

template <typename T>
class Result {
	T data;
	int code;
	std::string reason;

public:
	Result(T data, int code = 0, std::string reason = "success") :data{ data }, code{ code }, reason{ reason } {

	}

	friend std::ostream& operator<<(std::ostream& os, const Result result) {
		os << "Result(data = " << result.data <<", code = " << result.code <<", reason = " << result.reason <<")" << std::endl;
		return os;
	}
};
int main() {
	Result<int> result{ 9527 };
	std::cout << result << std::endl;
	return 0;
}

// 输出
// Result(data = 9527, code = 0, reason = success)

可以看到,类模板和普通类类似,普通类有的它都有——成员函数,成员变量,构造函数等等,值得一说的依然是这个泛型参数T。上例是SDK中常见的数据类,用于指示操作是否成功并且必要时返回操作结果。对于返回一般数据类型,这个类已经足够了,但是假如我们的某个接口无返回值,按照传统即返回void类型,问题出现了。data的实际类型是void,但是我们找不到任何值来初始化它。更进一步,返回void的时候,我们根本不需要data这个成员变量。为了解决类似这种问题,模板提供了特化。

特化和偏特化 #

特化就是用特定类型替代泛型参数重新实现类模板或者函数模板,它依赖于原始模板。如上例中,我们已经有了原始模板类Result<T>,为了解决void不能使用的情况,我们需要为void类型重新定义一个Result,即Result<void>,则Result<void>就称为Result<T>的一种特化,原来的Result<T>称为原始模板类。这样的特化版本可以有很多个,一个类型就是一个特化版本,它完美融合了通用性和特殊性两个优势。当实例化过程中,如果实例化类型和特化类型一致,则实例化将使用特化的那个类(函数)来完成,如下面的例子

// Result定义保持不变,新增特化版本
template <>
class Result<void>{
	int code;
	std::string reason;
public:
	Result(int code = 0, std::string reason = "success"): code{ code }, reason{ reason }{}

	friend std::ostream& operator<<(std::ostream& os, const Result result) {
		os << "Result("<<"code = " << result.code << ", reason = " << result.reason << ")" << std::endl;
		return os;
	}
};

int main() {
	Result<void> voidResult;
	Result<int> intResult{9527};
	std::cout << "void = "<< voidResult<<std::endl<<"int = " << intResult << std::endl;
	return 0;
}

// 输出
// void = Result(code = 0, reason = success)
// int = Result(data = 9527, code = 0, reason = success)

可以看到,当实例化为int类型时,使用的是原始的模板类。而当实例化为void类型时,使用的是特化的版本。 除了特化,还有偏特化。偏特化和特化很像,就是对类型进行一个更窄的限定,使之适用于某一类类型,如const,指针,引用等。或者对有多个泛型参数的类进行部分特化。 特化和偏特化是对模板特殊类型的补充,解决的是模板实现上的一些问题。很多时候如果通用模板不好实现,可以考虑使用特化。当然,特化版本越多,模板的维护成本就越高,这时候就该考虑是否是设计上存在缺陷了。

类型限定 #

C++模板的强大不仅仅表现在对类型的操作上,有时候为了防止我们的类被滥用,我们还需要对这些能力做一些限定,比如禁止某些特定的类型实例化。 在上面的例子中,假设我们规定Result必须返回实际的数据,禁止void实例化该怎么做呢?容易想到的是,我们首先需要一种方法判断实例化时的类型是否是特定类型,然后需要在实例化类型是禁止类型时告诉编译器编译失败。所有的这些,标准库type_traits都提供了支持。它提供了一系列工具来帮助我们识别类型参数,如数字,字符串,指针等等,也提供了一些其他工具辅助这些类型参数工具完成更复杂的功能。 此例中,我们希望实例化类型不能是void,经过查找type_traits,我们发现有个is_void的类,它有个value常量,这个常量在类型参数为void是为true,否则为false。当然有了判定方法还不够,我们还需要在类型不匹配时让编译器报错的方法,恰好,我们有enable_if_t。它有两个类型参数,第一个是布尔表达式,第二个是类型参数。当表达式为真时,类型参数才有定义,否则编译失败。所以为了完成禁止void实例化的功能,我们需要借助两个工具,is_void判断类型参数是否是void,enable_if_t完成布尔表达式到类型参数的转换。综上,让我们来看看实现:

template <typename T>
class Result {
	std::enable_if_t<!std::is_void<T>::value,T> data;
	int code;
	std::string reason; 

public:
	Result(std::enable_if_t< !std::is_void<T>::value,T> data, int code = 0, std::string reason = "success") :data{ data }, code{ code }, reason{ reason } {

	}

	friend std::ostream& operator<<(std::ostream& os, const Result< std::enable_if_t< !std::is_void<T>::value, T>> result) {
		os << "Result(data = " << result.data <<", code = " << result.code <<", reason = " << result.reason <<")" << std::endl;
		return os;
	}
};

例中,第3行和第8行都用到了类型限定,其实我们只需要在构造函数是对T限定就可以了。当用void来实例化Result时,将无法通过编译。

其他问题 #

C++模板有两方面的问题要解决,一方面是本身模板相关的问题,而另一方面就是和其他特性一起工作。如C++11引入了右值引用,但是右值引用通过参数传递以后会造成引用坍缩,丢失其右值引用的性质,表现得像一般引用类型,为了解决这个问题,C++提供了std::move工具。这对于普通函数是没问题的,但是假如这是一个模板函数呢?C++同样提供了完美转发的解决方法。 所谓完美转发,就是让右值引用保持右值引用,左值引用也保持左值引用。它需要配合万能引用一起使用。万能引用和右值引用很相似,只不过万能引用类型是不确定的,在编译期才能确定。看下面的例子

template <typename T>
void test(T&& p) {
	std::cout << "p = " << std::forward<T>(p) << std::endl;
}

int main() {
	int a = 1;
	test(a);
	test(std::move(a));

	return 0;
}

// 输出
// p = 1
// p = 1

T&&是万能引用,因为它类型不确定,然后通过std::forward<>转发参数。可以看到在8,9行,我们成功传递给test左值和右值,并且也成功得到了预期结果,不需要为右值单独写函数来处理。模板的这个功能极大简化了函数的设计,对于API的设计来说简直就是救星。 此外,函数模板还有重载的问题。通常来说普通函数的优先级会高于函数模板的优先级,函数模板之间越特殊的会优先匹配等等。这些问题随着对模板了解的深入,会慢慢出现,但是在学习初期没必要花费太多精力来了解这些特性,一切以实用为主。

总结 #

模板是C++中很大的一个课题,融合了类型系统,标准库,类等一系列的大课题。所以写出完美的模板代码需要首先对这些课题有较为完整的了解。其次由于模板对类型控制较为宽松,还需要开发者对模板的适用范围有全局的把控,禁止什么,对什么类型需要特殊化处理,都要考虑到位,稍不注意就会隐藏一个难以察觉的bug。 总之就是一句话,模板是常学常新,常用常新的,需要在实践中学习,又要在学习中实践的东西,祝大家每次都有新收获!

参考资料 #

  1. type_traints