使用Concepts表示变化「定制点」
设计程序,经常需要分离不变的和变化的逻辑。将不变的逻辑放到一块,再以某种形式为变化的部分提供「定制点」,从而使程序具有更好的可扩展性,同时增加相似逻辑的可复用性。
因此,本质上来说,设计是为了应对变化。通过抽离系统的变化点,再以合适的结构来表示变化,从而控制变化的影响范围。
C++ 提供许多技术来表示定制点,大家最熟悉的就是 OOP 的继承和多态,而 Concepts 也是其中之一。那么 Concepts 为何可以表示定制点?它又产生了怎样的结果?这种方式有哪些好处?又有哪些缺点?与继承的方式有哪些区别?流程有何变化?实际例子有哪些?
这些问题,就是本篇将要讨论的内容。
首先,让我们来探讨一个问题:变化的最小单元是什么?
一个程序是一个系统,一个系统必须具备三个最基本的元素:输入、处理和输出。这三个元素都存在变化,不同的输入对应不同的处理,导出不同的结果。在编程语言中,输入和输出对应「数据」,处理对应「方法」。不同类型的数据需要不同的方法来进行处理,无数个数据和方法构成了整个程序。因此,变化就存在于数据与方法当中。
OOP 通过组块,将数据与方法组合起来,称为一个类,一个类就是一些具有联系的数据与方法的集合。但是,只有一个类无法应对变化,任何变化都会引起对该类的修改,或是重新编写相同的方法。因此,对需要共用某些相同数据或方法的一些类,进行向上一层的抽象,把这些相似的数据或方法放到更高层级,将其他变化的逻辑放到更低层级,使得低层级类可以使用高层级类的共有逻辑,从而提供扩展和复用的能力。通过这种结构化方式,将具备相似关系的类置于一个「层级体系」,高层级的类具有更高的抽象,低层级的类则更加具体。于是,高层级的类可以用来表示接口,低层级的类可以用来定制具体实现,以此来表示变化。「继承」便是用来表示层级关系,而「多态」则是用来重新定义方法。
Concepts是命名的约束,约束其实是一种条件关系,只有满足某些条件才能触发接下来的操作。OOP中,处于同一层级体系的类本身就是一种约束,对于其他类,由于不在该层级当中,因此不符合约束,也就无法使用那些共有的操作了。若要使用这些共有方法,则需要通过继承添加定制点。既然Concepts表示约束,那么就也可以作为一种表示定制点的方式。
那么具体来看,继承和多态是如何表示定制点的呢?
以一个简单的例子来说明:
// Dynamic Polymorphism
struct Graph {
virtual ~Graph() = default;
virtual void draw() const = 0;
};
struct Circle : Graph {
float radius;
void draw() const override {
std::cout << "draw triangle\n";
}
};
struct Rectangle : Graph {
int width;
int height;
void draw() const override {
std::cout << "draw rectangle\n";
}
}
这里拥有两个类,Cricel
和 Rectangle
,它们各自拥有自己的数据,因为数据不同,因此具有不变性。此外,由于它们都属于图形,都具有绘制操作,但却存在变化点,因此抽象出一个 Graph
类,并将绘制操作以draw()接口表示。这是典型的多态表示定制点的方式,若有新的图形类,只需从 Graph
继承,并实现 draw()
接口,就能够使自己成为图形家族的一员。
这种方式因为使用了多态和虚函数,发生于运行期,故也称为动态多态。C++ 中,我们可以使用 CRTP 来实现静态多态:
// CRTP
template <typename Derived>
struct Graph {
auto draw() const {
auto& self = *static_cast<Derived*>(this);
return self.draw();
}
};
struct Circle : Graph<Circle> {
float radius;
void draw() const {
std::cout << "draw triangle\n";
}
};
struct Rectangle : Graph<Rectangle> {
int width;
int height;
void draw() const {
std::cout << "draw rectangle\n";
}
};
静态多态发生于编译期,没有虚表的消耗,是比较常用的一种定制点表现方式。
不论是静态多态还是动态多态,所有的类整体还是处于一个层级关系当中,因此,它们之间可以共享方法、共享数据、共享类型定义,也可以显式地控制类型边界,拒绝非层级关系内的类调用共享内容。此外,方法也可以提供默认实现,这样许多时候你可能并不需要定制行为。
那么 Concepts 又要如何表示上述定制点的呢?
首先,Concepts 是一种约束,并不是一个类型,它只能依附于模板进行使用。因为它本身就可以表示约束,所以不再需要继承带来的层级关系。因此,也就不再需要对相似类进行更上一级的抽象,直接定义类。
struct Circle {
float radius;
void draw() const {
std::cout << "draw circle\n";
}
};
struct Rectangle {
int width;
int height;
void draw() const {
std::cout << "draw rectangle\n";
}
};
接着,要做的就是为此类相似关系提供约束。
template <typename T>
concept graph = requires (T t) { t.draw(); };
在公共接口中要使用使用哪些数据和方法,就需对其施加约束。如此一来,就可以在调用操作之前对类型检验。由于不再需要层级体系,接口也可以直接使用普通函数进行指定。
void draw(graph auto& t) {
t.draw();
}
// Usage
Circle c;
draw(c);
若要定义其他行为,只需要添加类型,并满足约束条件,就具备了使用公共接口的资格。
这种方法同样能够共享方法,且发生于编译期,也会检验类型的一致性,只是无法共享数据与定义的类型。
当前Concepts的实现还是存在一个小问题,它没有对类型进行边界检验。因此,当你随意添加一个类型,只要提供了满足约束的接口,就能够使用接口。这虽然在语法上不存在错误,但却会导致语义错误。换句话说,不是图形类,但是只要满足约束,也可以调用 draw()
接口。继承和多态没有这个问题,是因为层级关系自带约束。要解决这个问题,可以使用变量模板,修改上述实现。
// 通过变量模板定义predicates
template <typename T>
inline constexpr auto is_graph = false;
// 修改Concepts的定义形式
template <typename T>
concept graph = is_graph<T> && requires (T t) { t.draw(); };
通过变量模板,就可以为类型提供边界。接着,为边界内的类型提供变量模板特化。
template<> inline constexpr auto is_graph<Circle> = true;
template<> inline constexpr auto is_graph<Rectangle> = true;
凡是新增一个类型,为其提供一个特化,就能够访问不变的一些逻辑。
总的来说,Concepts 表示定制点的方式还是比较清晰、富有表现力的。你可以很方便地增加一个新类,而不需要从某个类继承。对于第三方类,Concepts 中只要为其添加一个变量模板特化就能使其符合约束,通过继承则不行。但它也有一些缺点。比如,它有类型的侵入,为满足约束你不得不在自己的类型当中定义一些满足约束的东西(继承的方式同样有该缺点)。它需要全局保存约束名称,并且约束需要小心定义,以防碰撞。
Concepts 表示定制点的方式是 Ranges 库的基础,比如 view 的定义:
template<typename _Tp>
inline constexpr bool enable_view = derived_from<_Tp, view_base>;
template<class T>
concept view = ranges::range<T>
&& std::movable<T>
&& ranges::enable_view<T>;
这里的 enable_view
其实就是通过变量模板来进行边界检验,view
用来表示一个 View 应该满足的约束。
Ranges 库中充斥着大量的 Concepts 技术,感兴趣的话可以去看其源码。
C++中定制点的表示方式不止本篇介绍的这两种,同时还有一些新标准仍在提,反射就是其中非常强大的一种。当然也不止反射这一种,还有其他的一些相关提案,有些新东西久久未能进入标准就是它们的实现要依赖这些特性,就好像是 Concepts 和 Ranges 库的关系。
本篇介绍的不算太过深入,更多相关主题有机会再来继续写。