目录
导语
C++是由C语言发展而来,基本继承了C语言的绝大部分特性和优点。其在C语言的面向过程基础之上,逐步支持面向对象和泛型编程。随着C++标准的不断发展,这些不同的范式也在不断的完善。
作为一种支持多种范式的编程语言,对于同样的业务设计方案,C++有时可以通过不同的方式实现。这大大的增加了程序设计的选择空间和灵活性,但这也是C++语言复杂的原因之一。
其中,C++11标准引入了很多新的特性,模板的变长参数就是重要的一种。有了模板的变长参数,在此之前不易实现的数据结构或方案,现在实现更加容易,STL中的元组(tuple)就是典型的应用。
本文主要是讨论使用C++的变长模板参数实现一些设计模式,以期充分利用C++模板和设计模式的两方面优点。
C++的变长模板参数
变长模板参数概述
模板是C++中实现泛型编程的手段。在面向过程和面向对象编程中,我们操作的是变量。而在模板编程中,被操作的是类型,这些类型可以是基本类型,如int、char、float,也可以是自定义的类型class。不同在于,前者是在执行期完成,而后者是在编译期完成的。
模板的变长参数,允许任意个数、任意类型的模板参数,不必在模板定义的时候固定参数个数。其会在编译期根据实例化的参数类型进行展开。
以tuple为例
template< class... Types > class tuple;
在使用tuple的时候,可以指定多个类型参数,甚至不指定任何参数
Std::tuple<> var1;
Std::tuple<int,char> var2;
Std::tuple<int,std::string> var3;
变长模板的实现一般依赖于模板的递归展开和偏特化完成。
以简化版的tuple为例。
template<typename... _Types>
class tuple;
template<typename T>
class tuple<T> //1
{
public:
tuple(){}
int sum() const
{
return 0;
}
//......
protected:
T value;
};
template<typename T, typename... Args>
class tuple<T,Args...> : public tuple<Args...> //2
{
public:
tuple(T arg,Args... args):tuple<Args...>(args...)
{
value =arg;
}
//......
protected:
T value;
};
在实例化tuple模板的时候,如果只有一个类型参数,则直接特化1处的tuple。
如果有N个类型参数(N>1),会匹配到2处的tuple,此时2中的tuple会将N个参数分为参数T和N-1个的变长参数Args,并继承Args实例化的tuple,而其有N-1个类型参数。这样依次递归,直至只剩一个类型参数,根据偏特化匹配到1处的tuple。
这样最终通过递归展开和偏特化实现了任意个参数类型的模板展开。
与函数变长参数的类比
函数的变长参数,从C语言就已经开始支持。C++11之后的模板变长参数,在语法层面与前者有几分相似,但两者有根本的区别。异同点主要有以下几点:
相同点:
-
均不用在定义期指定参数个数或类型。
-
均需要在调用的地方(模板是在模板实例化的位置),明确的指定参数类型或个数。
不同点:
-
函数变长参数是在运行期进行展开,模板变长参数是在编译期进行展开。
-
函数变长参数需要调用者专门的参数,用于指定后续参数的个数或类型,例如printf中的后续的变长参数是通过format中的占位符指定。而在模板变长参数中,不用明确指定参数个数,编译器会根据实例化的参数列表进行解析确定。
模板在软件设计中优缺点
优点:
-
编译期间完成运算展开,提高运行期的性能
-
突破类型限制壁垒,做到代码复用
-
抽象数据结构及算法,减少重复造轮子,使代码更简洁
-
编译期间语法检验,提高程序健壮性
缺点:
-
模板代码本身可读性较差
-
gdb 时展开名称过长,debug不方便
-
声明与定义不易分离,不便实现定义封装
-
多次展开,bin文件过大
变长模板参数实现用组合
继承与组合
在面向对象的编程范式中,继承和组合是两个很常见的设计模式。继承代表了“是什么”的概念,而组合代表了”有什么”的概念。
class Person
{
public:
unsigned age;
std::string name;
};
class Adult //成人
{
public:
std::string mate_name;
}
class Child //小孩
{
public:
std::string father_name;
};
class Teacher //老师
{
public:
std::string cource; //教授课程
unsigned teach_years; //教龄
};
class Student //学生
{
public:
unsigned grade; //年级
std::string class; //班级
std::vector<unsigned> scores; //分数
};
如上,有五个类,分别是Person、Adult、Child、Teacher、Student。
按照继承的方式,是Adult、Child、Teacher、Student直接继承自Person。按照组合的方式,是Adult、Child、Teacher、Student包含一个Person的成员。当然,此处的几个类的现实意义更适合用继承。
广义的说,“是什么”就“有什么”的一种特殊情况。我们在说,Teacher是Person的同时,其实是在说Teacher有了Person的所有属性。所以,从业务意义上来说,组合和继承是没有明显的界限的。
但在方案设计时,组合和继承还是有差别的。
| 组合 | 继承 |
语法访问方式 | 多一次引用(点操作符) | 直接访问 |
绑定强度 | 弱绑定 | 强绑定 |
多态支持 | 不支持 | 支持 |
属性组合灵活性 | 较好 | 较差 |
变长模板实现组合
就以上来看,用组合和继承各有利弊,那么能不能有一种方式可以兼顾两者的部分优点呢。可以通过模板的变长参数来实现。
template<typename... Args>
class Combination;
template<typename T>
class Combination<T> : public T
{
};
template<typename T,typename... Args>
class Combination<T,Args...> : public Combination<Args...>,public T
{
};
typedef Combination<Person,Adult> Person1;
typedef Combination<Person,Adult,Teacher> Person2;
typedef Combination<Person,Adult,Teacher,Student> Person3;
typedef Combination<Person,Child,Student> Person4;
从以上的语法来看,此处的组合是通过多重继承来实现的。
具有继承的如下优点:
-
这样,在Person2、Person3、Person4这些类型中,可以直接通过点操作符访问Person、Adult、Child、Teacher、Student中的成员,不用多一次应用。
-
通过模板展开可以发现,组合之后的类型可以是其中任何一个的子类,可以支持动态多态。
除此之外,也支持了组合的灵活性。其中Person1、Person2、Person3、Person4就是通过不同的组合定义了不同身份的人。
存在的问题
-
模板变长参数的组合实现使用了多重继承。
-
由于其本质上是继承实现的,所以在 属性类型中如果有同名变量,存在访问的歧义或冲突问题。
用变长模板实现责任链模式
什么是责任链模式
责任链模式就是用于处理特定业务或数据的一条执行链,执行链上有多个节点或是模块,每个节点都有可能处理数据。在执行链的整个执行过程中,可以在任何一个节点之后就结束执行。
责任链模式是典型的一种顺序执行模式。在软件设计中,我们会经常有意无意的使用其中的思想。
主要有几种实现方式:
-
硬编码方式。依次调用并执行各个模块,每个模块执行完成后做执行状态判断以决定是否继续执行。
-
模块数组方式。将函数指针或是函数对象动态的放入数组,在需要执行相关业务的时候,循环的遍历数据执行其中的每一个模块,并判断执行状态。
-
模块链表方式。将多个模块以链表的方式串联,在上一模块的成员中记录下一模块的引用。这也是设计模式中常用的实现模式。
不管是哪种实现方式,本质上都是记录执行链中的模块顺序,执行时依次调用。区别在于模块的记录方式、执行方式的不同以及模块是否可动态改变。
但是三种方式都存在一定的问题。
硬编码方式:重复的代码调用,不简洁。
节点数组方式,节点链表方式:由于C++是强类型约束的,这要求各个模块(不管是函数还是类)必须是相同的类型或是同样的父类。这大大约束了使用场景。
变长模板实现责任链模式
如果通过模板的变长模板参数来指定各个模块,而并在模板内部完成各个模块的调用,就可以简单的实现责任链的抽象封装。
template<typename T, typename FUNC_T>
int execute_chain(T& param, FUNC_T& func)
{
return func(param);
}
template<typename T, typename FUNC_T, typename... Args>
int execute_chain(T& param, FUNC_T& func, Args&... executors)
{
if(func(param) !=0)
return -1;
else
return execute_chain(param, executors...);
return 0;
}
FUNC_T可以是普通的函数也可以是支持括号运算符的类。execute_chain中的模板参数中,第一个参数是真正要处理的数据类型,后续的为依次要执行的模块类型。同理,函数参数中, param是实际的数据参数,executors是各个实际执行模块。
在execute_chain中,param和executors均是通过引用的方式传递的,这是为了防止对参数和模块类的拷贝构造。
具体使用方式如下:
typedef struct _Parames
{
}Parames;
class C1
{
public:
C1()
{
std::cout<<"in C1"<<std::endl;
}
int operator()(Parames& parames)
{
std::cout<<"execute C1"<<std::endl;
return 0;
}
};
class C2
{
public:
C2()
{
std::cout<<"in C2"<<std::endl;
}
int operator()(Parames& parames)
{
std::cout<<"execute C2"<<std::endl;
return 0;
}
};
int test_func(Parames& parames)
{
std::cout<<"execute test_func"<<std::endl;
return 0;
}
int main(int argn, char* argv[])
{
C1 c1;
C2 c2;
auto func1 = test_func;
Parames parames;
execute_chain(parames, c1,c2,func1,c2,c1,c2,func1);
return 0;
}
优点
-
打破了对类型的约束,可以执行不同类型的模块。
-
业务代码简洁、清晰。
缺点
-
数据参数个数唯一。为了区分模板参数中哪些是数据,哪些是模块,必须要固定数据参数的个数。
-
各个模块必须都支持括号运算符。
-
无法动态指定模块。
结语
作为C++的新特性,变长模板参数可以在很多地方发挥其强大的作用,其递归的展开方式也丰富了其使用场景。很多比较繁复的实现都可以通过它来实现。
例如boost中的bind函数为了支持不同个数的参数,定义了多个版本的实现。如果使用C++模板变长参数,就可以实现任意个数的bind函数,而且代码会更加的简洁。
本文所讨论的实现方式,只是众多方式中的一种,并不具有广泛的通用性。我们应该根据自身业务特性和设计方案,确定自己的诉求点,然后在多种实现方式中选择满足诉求的设计。