前序文章请看:
C++模板元编程详细教程(之一)
C++模板元编程详细教程(之二)
C++模板元编程详细教程(之三)
C++模板元编程详细教程(之四)
C++模板元编程详细教程(之五)
C++模板元编程详细教程(之六)
C++模板元编程详细教程(之七)
C++模板元编程详细教程(之八)
C++模板元编程详细教程(之九)
C++模板元编程详细教程(之十)
上一节我们介绍了如何编写一个「访问器」,以及用std::visit
来实现访问。那么这一节就来手撸一个访问函数visit
,用于使用访问器来访问variant
。
前面我们介绍过如何写一个动态的get
方法,这里的思路与它如出一辙,在静态编译期把variant
的所有情况都生成一个对应的方法,这里我们就需要与访问器进行关联,调用对应的访问方法。等到运行时,根据当前variant
的index
值选择调用对应的访问函数。
我们这里只实现一个支持单个variant
的情况,如果读者希望实现一个跟STL中相同的支持多个variant
的情况可以尝试自行完成。话不多说,直接上代码:
// 辅助函数2
template <typename Visitor, typename R, typename Variant, size_t Index>
R visit_detail_invoke(Visitor &&vis, Variant &&var) {
return std::invoke(vis, std::get<Index>(var));
}
// 辅助函数1
template <typename Visitor, typename R, typename Variant, size_t... Index>
R visit_detail(Visitor &&vis, Variant &&var, const std::index_sequence<Index...> &) {
std::array<R(Visitor &&, void *), std::variant_size_v<Variant>> funcs {
// 按照变参展开,如果遇到类型相同的也会匹配同一个函数实例
&visit_detail_invoke<Visitor, R, Variant, Index>...
};
// 根据运行期的index选择调用哪个函数
return funcs.at(var.index())(vis, var);
}
template <typename Visitor, typename... Args>
auto visit(Visitor &&vis, std::variant<Args...> &&var) -> decltype(vis(std::get<0>(var))) {
using R = decltype(vis(std::get<0>(var))); // 用0号类型类型去调用访问器,只是为了推导出对应的返回值类型
using Seq = std::make_index_sequence<sizeof...(Args)>; // 构造一个序列,用于展开
return visit_detail<Visitor, std::variant<Args...>>(vis, var, Seq{});
}
先别急,我来解释一下上面做的事。首先我们先看visit
函数,这一层要做2件事,首先是取得返回值。我们知道在C++17标准中的visit
要求访问器返回值类型必须保持一致,所以这里,不管用variant
的哪种类型成员去访问,返回值结果都应该是一致的。又因为我们不知道类型的个数,但至少它能有1个类型,所以就用0号去获取。decltype(vis(std::get<0>(var)))
就是尝试用0号类型去访问,获得的返回值。
接下来,就是所谓编译期要把所有可能得情况都生成一个对应处理函数的步骤。由于variant
中可能会出现多种相同类型的情况,这种情况下,相同类型的数据应该能命中同一个访问器方法才对。于是我们还是需要按照Index
展开,而不是按照Args...
展开(因为如果按类型展开的话,遇到重复类型,调用std::get
时会报错)。既然要按Index
展开,就需要先生成一个从0
到size - 1
的序列,于是我们用了std::make_index_sequence
来生成序列(这部分的具体实现可以参考前面动态get的章节)。
第二层visit_detail
就不再接收类型变参,转为接收序列,因此variant
的具体参数就被屏蔽了,在visit
里调用的时候会把std::variant
整体类型传递给visit_detail
的Variant
参数。接下来就是按照Index
展开了,对每一种可能出现的类型进行适配,把对应的处理函数(visit_detail_invoke
的一个实例)保存下来。(注意这里我们用了std::array
类型,数组元素是函数类型,其实就等价与函数指针类型,也就是说等价于std::array
。)
第三层visit_detail_invoke
就是使用访问器进行访问了,首先把对应Index
的数据取出来,然后调用访问器方法即可。(例子里使用了std::invoke
,其实直接写成vis(std::get
也是一样的。)
至此,我们从「多选一结构」到「访问器」到「访问函数」,体验了一个闭环,希望以此为例子,能让读者深度体验模板元编程的用途和使用方法。
详细体验过模板元编程理念和方法之后,我们要再来介绍一下C++的多范式。虽然一开始C++是为了给C语言扩充以适配OOP(面相对象编程)范式的,但后来成熟了的C++语言本身开放性很足,因此能够使用多种编程范式。同时又因为STL本身并没有使用OOP范式,而是把模板玩出了花,因此「模板范式」在C++中也有了弥足轻重的地位。
所谓「模板范式」,其核心理念就是「在编译期,确定尽量多的事情」。也就是说,它倾向于把更多的工作交给编译期来完成,以减少程序运行时期的不确定因素。
举例来说,某处代码需要调用一个对象的method
方法,它并不关心这个对象的类型是什么,只要它实现了method
方法就可以使用。单独这么说可能有点抽象,我们换一个更加贴近实际的例子。前端开发中,TableView
类表示一个列表视图,每一个列表视图的实例都需要一个数据的提供者,来告诉他这个列表中应该显示哪些内容。但是,TableView
它并不关心这个数据是谁给的,有可能是网络回包,有可能是主控制器,也有可能是其他的控件。是谁不重要,只要能给我提供数据即可,所以我要求这个数据的提供方实现std::string GetData()
方法。
对于上面这个例子来说,如果我们采用传统OOP范式,自然会想到定义一个「协议类」(或者叫「接口类」),来作为数据提供方的类型。
// 协议类
class TableViewDataSource {
public:
virtual std::string GetData() const;
};
// 使用者
class TableView {
public:
void SetDataSource(TableViewDataSource *data_source);
private:
TableViewDataSource *data_source_;
};
当TableView
需要获取数据时,只需要从数据源调用方法即可:
if (data_source_ != nullptr) {
auto data = data_source_->GetData();
// 处理data
}
此时,假如要充当数据源的是一个http请求类,我们就可以通过「继承协议类」的方式来「遵守协议」,让它成为「数据源」。
// 它可能有自己原本的父类,称为「属性父类」,而成为数据源继承的是「协议父类」
class HttpRequest : public SocketReq, public TableViewDataSource {
public:
... // 一些自己的方法
// 实现协议类方法
std::string GetData() const override;
};
这是前端开发中非常常用的手段,也是最符合OOP范式的方法。但我们发现,OOP范式的实现基于多态,也就是TableViewDataSource
类型的泛化子类中的方法,对应的GetData
是一个虚函数。C++中,虚函数的实现基于虚函数表,也就是说,所有TableViewDataSource
的子类的实例,内部都含有一个虚函数表,里面存放了所有虚函数对应的函数指针。
显然,这种方式主要的处理阶段是运行时,运行时通过对象的虚函数表,找到对应的虚函数再进行调用的。而C++除了可以使用这种更加依靠运行时的OOP范式以外,还可以使用更加依赖编译期的模板范式来实现相同的方法。请看示例:
// 用于判断类型T中是否含有GetData方法
template <typename T, typename V = std::string>
struct IsDataSource : std::false_value {};
template <typename T>
struct IsDataSource<T, std::void_t<decltype(std::declval<T>().GetData())>> : std::true_value {};
class TableView {
public:
template <typename DataSource>
std::enable_if_t<IsDataSource<DataSource>::value, std::string>
GetData(DataSource *data_source) const {
return data_source->GetData();
};
};
这里,我们直接在静态编译期就判断了传进来的类型是否实现了GetData
方法,(具体的判断方法在前面章节都已经介绍过了),这样一来,只要你的类型实现了GetData
方法,就可以直接用作数据源,无需提供协议类,也就无需额外生成虚函数表,运行时的开销就被降低了。
// 不需要多继承,不会改变继承链
class HttpRequest : public SocketReq {
public:
... // 一些自己的方法
// 实现数据源方法,不需要虚函数
std::string GetData() const;
};
// 使用时
void Demo() {
HttpRequest hr;
TableView tv;
tv.GetData(&hr); // hr、tv中都不携带虚函数表,如果hr的类型不符合要求,编译期就直接会被拦截
}
这就是所谓的「模板范式」,倾向于把尽可能多的工作放在编译期。当然,它的缺点也很明显,就是可读性会变差,如果仅仅是上面示例这种「协议类」的需求,笔者个人还是更倾向于选择使用OOP范式的写法,维护性会更高一些。但是借此例子希望读者能够认识到「模板范式」,体会它与OOP范式的不同,以及理解C++的多范式性。
当然,上面的这个例子不仅仅是OOP范式和模板范式可以实现,我们是甚至都可以用函数式编程的思路来解决:
class TableView {
public:
void SetDataSource(std::function<std::string()> func) {
get_data_func_ = func;
}
std::string GetData() const {
return get_data_func_();
};
private:
std::function<std::string()> get_data_func_;
};
class HttpRequest : public SocketReq {
public:
std::string GetData() const;
};
void Demo() {
TableView tv;
HttpRequest hr;
tv.SetDataSource(std::bind(&HttpRequest::GetData, &hr)); // 脱离数据源对象实体,直接把函数传进去
tv.GetData();
}
这跟模板范式又是完全不同的思路,不过函数式编程不是本篇的重点,因此不在这里过多介绍了。
整个C++模板元编程教程系列到这里就接近尾声了,如果你能读到这里,笔者非常感谢支持!最后这一章将以Q&A的方式,展示一些笔者被问到的,或者是一些笔者认为大家可能会有疑惑的问题的解答,同时穿插着希望分享给读者的感悟。
这个问题源于C++本身的设计理念和历史因素。C++诞生之时,就是希望对C语言进行一个扩展,或者我们可以简单理解为,C++就是C的一个新版本,所以它打一开始就不是冲着「一门新的编程语言」来设计的,所以这就注定C++不会颠覆C语言的理念。
另一方面,在C++诞生的那个年代,硬件资源其实是非常昂贵的,所以更加倾向于把更多的工作留给编译期,这样可以「一劳永逸」,与此同时,C++也是像C语言一眼,考虑的是跨平台,能够支持尽可能多的架构、平台、内核等,所以,编译器可以根据不同的平台,做针对性的优化。而如果把这些工作放到了运行期,那编译器就望尘莫及了。
所以说,C++的设计理念就让它更加注重编译期处理,模板就是非常针对性的语法,因此C++一直都在扩充模板语法的各种功能,而并没有把重点放在运行期的功能特性上。
也有读者曾问过我,Objective-C也是基于C语言扩展来的,可以完全兼容C,但它却完全没有受C语言的束缚,将静态期和运行期的分离做得相当到位,同时也很好地支持反射、RTTI等特性。为什么C++不这样做,而是要用很多蹩脚的静态期语法?
笔者觉得这个问题问得本身就有点小问题,其实OC跟C++对于C语言来说完全是两个维度。在Q1里面笔者解释过,C++原本是作为C的新版本出现的,换句话说,C++其实就是C语言,它自然进化后成为了C++,而现在的C语言反倒成为了原始C语言的一个分支,或者说一个克隆版了。
OC是在C的基础上,扩充了很多动态语言特性来的,而这里跟C++是完全不冲突的。因为OC是在「C的基础上」扩充的,而「C++就是新版的C」,那么OC自然也可以支持C++,或者说,使用C++的静态属性,在此基础上扩展动态特性,这也是OK的。为了区分,这种语言也被叫做Objective-C++。OC的动态特性语法和C++的静态特性语法可以同时存在,完全不冲突。
这种感觉就像,C++是「长大后的C」,而OC是「拿着武器的C」,那么长大后的C也可以拿着武器呀。因此说C++和OC对C的扩充完全是两个维度的,互不冲突。
这也是一个灵魂拷问问题,对于C++的模板元编程,业界的看法一致都是两极分化严重。支持的人爱不释手,反对的人如视仇雠。而且双方长时间稳定地对峙,谁也别想说征服谁。
笔者还是认为,完事不可一棒子打死,既然大家能争执,就说明这件事情一定同时存在正反两面。我们要做的并不是站队,然后一定要跟对方争个输赢来。而是应当首先,学会、掌握它,然后去客观分析它的优缺点,并且针对使用的场景得出是否应当使用的结论。
C++模板的优点就是尽可能多的事情在编译期解决了,性能会高,并且有时候一些代码改成模板生成会减少代码量,提升开发效率;而它的缺点也是显而易见的,就是语法晦涩,并且门槛高,需要对使用者有一定技术上的要求。
因此,在实际开发中,应当根据工程项目注重的点的情况、项目成员平均技术水平的情况、项目开发投入的情况、项目潜在交接可能性情况、项目负责人员流动性情况等等等等诸多因素去考虑,这里应当使用哪种范式和要求。而不要一棍子打死,全面禁用某个特性。
而对于个人来说,无论是否需要投入生产,笔者认为都应当自己先掌握,掌握了我们才有去评判的资格。这就好比「我会做饭,但是我不需要做(有人为我服务,偶尔没人给我做了我也能自己做)」跟「我不会做饭,我只能指着别人给我做(没人做的时候我只能饿肚子)」是截然不同的处境。
这个问题跟Q3其实正好是反过来的。的确,模板范式是STL整个采用的编程风格,也是很多真正意义上的「C++崇尚者」强烈推荐的方式。
模板是C++的特色,的确是其他编程语言中见不到的,非常有特点、非常奇特的语法,甚至可以称作「奇技淫巧」的一种用法了。所以可能会让人有「这才是真正的C++」的感觉。
但笔者认为,C++最大的用途一定是编写程序,只有它投入了生产,创造了价值,它才有意义。这就跟数学、物理学等等学科是相同的道理。我们研究这些学科,最大的意义是它能够为我们的生产提供帮助,提供方法。举例来说,我们想计算一个矩形材料的面积,只要用「长×宽」这就够了。你不能说,这种算法不够「数学」,我们一定要用专业的计算面积的数学方法,硬要把它写成:
S = ∫ 0 a l ( x ) d x S = \int _0^a l(x)dx S=∫0al(x)dx
甚至是:
S = ∬ S f ( s ) d s = ∫ 0 a ∫ 0 b f ( x , y ) d x d y S = \iint _S f(s)ds = \int _0 ^a \int _0 ^b f(x, y) dx dy S=∬Sf(s)ds=∫0a∫0bf(x,y)dxdy
那就真的是把简单问题复杂化了,不要较真,去追求这种「数学味道」。
相比自然学科,C++的这种「工具性」还要更强一点,我们千万不要为了研究而研究,为了让代码更像C++而放弃真正更高效更简洁的写法,就像继承多态可以很简洁方便地解决的问题,咱们就没必要硬给他搞成模板了吧。
最合适的才是最好用的,我们做理论研究的时候自然是要把这些学会、学精,但真的在生产实践的时候,还是应当选择最适合的。
C++20标准引入的「concept」概念,也属于模板的一种,或者说是对C++17及以前的模板元编程的一次拨乱反正,大幅提高了模板的可读性和可测试性,但它归根结底仍然属于模板的一种,所以从「实例化」「编译期行为」等角度来说,concept都是符合模板的定义的,因此也具有模板的所有性质。
关于concept的详情,感兴趣的读者可以参考笔者的另一篇文章:C++20之Concept。
这个问题其实很好解释,如果我们自己来写一些最简的容器:
template <typename T, size_t N>
class Array {
private:
T data[N];
};
template <typename T>
class Vector {
public:
Vector(size_t n) : data(new T[n]) {}
private:
T *data;
};
我们看看,如果T
是引用的话,能编译通过吗?
// 假如T是int &的时候
class Array<int &, 5> {
private:
int &data[5]; // 显然编译不通过
};
class Vector<int &> {
public:
Vector(size_t n) : data(new int &[n]) {} // 编译不通过
private:
int &*data; // 编译不通过
};
所以理由很简单,就是实例化引用的时候,编译不过。
那为什么不针对引用类型,给他特化成对应的指针类型呢?
template <typename T>
class Vector<T &> { // 针对引用类型进行特化
public:
Vector(size_t n) : data(new T *[n]), // 这里转换指针类型
size(n) {}
void push_back(T &ele) {
data[size++] = &ele; // 引用都转换为指针
}
private:
T *data; // 成员还用指针
size_t size;
};
看起来上面这样操作很完美,把引用类型转为了指针的语法糖。但它其实存在一个很严重的问题,就是自动类型推导:
void Demo() {
int a = 5;
Vector ve{a}; // 请问这里是推Vector还是Vector类型?
}
照理说,a
在此是个左值,所以应该推引用类型才对,但这么做大多数情况都是反直觉的,所以在各种隐式推导和转换的时候会出问题。(其实在内存管理上也存在问题,比如说pop_back
操作时不会析构,因为转换为指针了,所以并不负责引用对象的生命周期,也可能会出现潜在的问题。)
所以,在STL中规定,容器存储的是「对象」,变量、指针都可以视为「对象」,但引用并不是,所以不允许存储,除非我们用「引用对象」,也就是std::reference_wrapper
来封装成对象才能存入容器。