Concept & requires是C++20引入的新语法,这两个新的概念对于没有大量模板编程经验的朋友来说会比较陌生,接下来我简单的介绍一下这个东西的意义以及我们可以在什么场景下使用它们,10分钟保证你理解!
Concept是静态的接口定义
这个说法不是完全的准确但是对不熟悉模板编程的朋友来说,可以快速的理解Concept的意义。接口对于熟悉OOP编程的朋友,或者熟练使用C#之类语言的朋友来说是非常常见的概念了:
接口定义了一组方法,任何实现该接口的类必须实现这些方法;以接口为参数类型的函数可以直接调用接口定义的方法而不用关心该方法具体由哪些类来实现。C#中我们通常使用interface来定义,C++一般会使用包含(纯)虚函数的抽象基类来定义。
现代语言已经非常好的支持了接口驱动的解耦方式作为开发中的关键一环,那么“静态接口”是什么意思?这里我们要略微深入到传统接口的实现方式 —— 无论是C#还是C++在调用接口方法或虚函数时运行时框架需要首先查找该函数在当前实际调用的实现类中的地址,这里不同语言实现方式不同但总体上说是一个(沿着继承链)递归查表的过程,在查找到正确的函数实现后跳转到该函数地址执行。
我们可以看到上述过程无论是查表还是函数调用都有比较多的性能开销,而考虑到现代CPU体系架构,CPU的二级缓存会保存非常多的指令上下文,较为长程的函数调用会导致无法命中缓存造成性能损失。因此我们在一些性能要求非常高的环境下,反复、大量的调用接口方法、虚函数会有显著的性能损失。那么我们是否有办法在保留这种“接口驱动”的解耦方式的同时,尽可能减小性能损失呢?那么答案就是C++ Concept了,也就是我说的“静态接口”。
Concept的核心是依托模板编程(也叫元编程)的能力将大量运行时的决策提前到编译阶段,熟悉模板的朋友会非常了解,模板的实例化是在编译阶段进行的完全没有运行时的计算代价。不熟悉模板编程的朋友可以看看我之前写的type traits的文章,因为头条审核系统的原因这里我就不贴链接了,大家点我的头像到个人页面找一找。我们可以通过Concept定义一组约束,这里的约束和接口的概念有一定的相似性,然后要求函数参数满足这个约束,那么我们就可以放心的基于这个约束中约定的能力来使用传入的参数了。
Concept是更易用的模板类型约束
这是Concept的准确定义,对于熟悉模板编程的朋友来说这句话可能就足够了。逻辑上来说Concept没有引入任何新的能力,在C++20之前我们有足够的工具来描述模板类型约束但是并不那么好用,需要写很多traits、冗长的类型约束表达式等等。Concept的引入某种程度上解放了我们的双手以及很大一部分大脑,有时候在编码过程中在大脑里做模板匹配是相当恼人的。
这里可以用更精确的语言来改进一下上面“静态接口”的说法:Concept是一组对类型的约束,这些约束可以是任意语法正确的C++表达式,如果这些表达式是可编译的那么就认为这一条检查的结果为true,而一个Concept如果是满足要求的那么其所有表达式联合的最终求值结果为true。
这里为什么说“所有表达式联合的最终求值结果为true”?因为Concept是可以使用或逻辑来连接不同的检查逻辑的。下面我举几个Concept表达式的例子:
// 要求类型std::hash<T>{}(a)是可编译的,并且返回的结果是std::size_t类型
{ std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
// 要求sizeof(T) > 1
sizeof(T) > 1;
// 要求T具有inner子类型
typename T::inner;
// 要求T + 1运算的结果是int类型
{x + 1} -> std::same_as<int>;
// 要求T存在析构函数,并且该析构函数是noexcept修饰的(不会抛出异常)
{ a.~T() } noexcept;
以上都是一些合法的约束,我们可以看到相比于接口只能定义“包含哪些方法”,Concept的约束范围无限扩大,其可以要求模板类型的任意C++表达式是可编译的。
Concept示例:Task Dispatcher
以下我们举一个用concept来实现Task dispatcher的例子来方便新老C++爱好者理解这个概念,对比一下基于抽象基类的方法和基于模板的方法的差异。
首先看接口类方法(或者我们称之为运行时多态):
class task {
public:
virtual void run() = 0;
};
class task_a : public task {
public:
void run() override {
}
};
class task_b : public task {
public:
void run() override {
}
};
class task_dispatcher {
public:
void dispatch(std::unique_ptr<task> t) {
t->run();
}
};
int main() {
std::unique_ptr<task> task1 = std::make_unique<task_a>();
std::unique_ptr<task> task2 = std::make_unique<task_b>();
task_dispatcher dispatcher;
dispatcher.dispatch(std::move(task1));
dispatcher.dispatch(std::move(task2));
return 0;
}
这个示例非常简单几乎没有任何实际意义的代码,其主要演示了task_dispatcher可以通过输入task的子类来调用其run()方法开始执行任务。这个设计是很清晰的但代价是每次调用t->run()时都要检查虚函数表然后跳转到task_a或者task_b的run函数执行。另外在构造task_a,task_b的时候都需要动态内存分配,这也带来了相当多的性能开销。
那么我们接下来看看基于Concept模板(或者说编译时多态):
template<typename T>
concept Task = requires(T a) {
{ a.run() } -> std::same_as<void>;
};
class task_a {
public:
void run() {
}
};
class task_b {
public:
void run() {
}
};
class task_dispatcher {
public:
template<Task T>
void dispatch(T t) {
t.run();
}
};
int main() {
task_dispatcher dispatcher;
dispatcher.dispatch(task_a());
dispatcher.dispatch(task_b());
return 0;
}
我们可以看到一些明显的区别:
- 没有task抽象基类了,改成了concept Task,这个concept要求该类型比如有一个返回void类型、且没有任何参数的run方法
- task_a、task_b的run方法现在都不是虚函数了,并且都可以完全的基于值传递而非指针,避免了申请动态内存的开销
- task_dispatcher在调用dispatch的时候,实际上会完全内联task_a、task_b的run方法,在编译产物中实际上没有函数调用
以上这个对比可以非常明显的体现出这两个思路下的写法差异:运行时多态强调抽象基类、强调虚函数,运行时需要查表和调用;编译时多态强调类型约束,强调内联。
当然上面是一个非常非常简单的例子,无法体现出concept的强大,在真实应用场景下Task会有更多的约束条件。
这里我没提供一个更复杂的例子的原因在于,一旦要做的事情变得复杂,以我的经验来说大概率要引入类型擦除这个技术(type erasure),C++做类型擦除一般就需要依靠std::function / std::any来做,从原理上来说需要引入一些运行时多态的能力(实现上不一定依赖vtable但一定要保存函数地址),性能更好的方式需要用到std::variant当然这种方法的限制会更大。无论如何一个更复杂的例子会带来更多复杂难解释的概念,所以这里就不展开了。关于类型擦除的技术我以后再写一篇文章来描述吧。
以上,感谢大家的阅读,如果觉得有趣、学到了知识就点赞、收藏、关注吧!