使用C++20实现轻量级AOP库
The following article is from CPP编程客 Author cpluspluser
编程是为了解决某个具体问题,当你看整个问题的时候,容易含糊不清,寻不到方向。将问题拆解一下,弄清它是由什么组成的。随着问题拆解的越来越小,你会发现问题也变得越来越简单。那么,将问题拆解到可以解决之时的各个组成要素,就是程序的模块,当把每个模块都完成的时候,再进行组合,便可以解决整个问题。
这种思维源于物理学中的还原论,面向对象的思维就是通过对象来表示每个模块,以继承和多态来描述模块之间的关系。
模块的基本原则是要满足正交性,即相互独立,所以想对某一模块添加额外处理,不应该强行添加到模块之中,这会破坏模块的独立性。
例如,一个简单的游戏模块,
这里的两个模块Player和Enemy属于同一层级,若要在每次玩家和怪物攻击时,检测数据是否合法,并记录伤害值、血量等等信息。也就是说,添加一些公共的行为,到这些现存的模块当中。
由于这些公共的行为分散在不同的模块当中,如记录的日志模块,检测的访问控制模块。所以再要通过继承提取这些分散的公共行为,会破坏模块之间的正交性。把原本不交叉的功能强行拼凑,这时这个模块也就失去了独立性。换句话说,这对于问题的分解出现了问题,再进行组合时,可能无法还原整个问题。
那么要如何在保证模块独立性的情况下,提炼这些共同的工作呢?
实际上,分解一个问题可以从两方面着手,深度和广度。
深度层面属于纵向分解,处于不同深度层级下的模块,抽象层次也不同。抽象层次每高一级,公共逻辑就更具有普适性,适应范围更广。也就是说,同一深度层次之间存在公共逻辑的交叉,可以直接组合使用,如Enemy可以表示Monster和Boss。
广度层面属于横向分解,可以组合互不交叉的多个独立模块。这些独立模块可能存在于不同深度层面,也可能不在同一深度之下,因为没有公共行为,所以无法交叉,也就无法直接组合。此时,换个角度,从广度分解,便能达到组合的效果。
横向组合后的这个模块,就称为切面(Aspect),这种将广度层面的公共逻辑组合在一起的方式称为横切关注点(Cross-cutting concerns),这种编程方式就称为面向切面编程(AOP,Aspect-Oriented Programming)。
利用每个切面来将多个公共行为添加到现存模块当中,这些添加的公共行为称为Advice。
总结一下,以上介绍了解决问题的两种编程思维,面向对象和面向切面。面向对象以对象表示问题分解的模块,继承和多态表示同一深度层次下模块的关系。面向切面以切面组合分散的模块,横向关联不相交的各个模块,以公共地使用它们。
2. AOP的相关概念
这里介绍一下通用的标准术语,就是统一名称,供交流之用,上节其实已经介绍。
Cross-cutting concerns:横切关注点,就是将非核心的公共逻辑横向组合到一起的方式。
Advice:就是想要向现存模块中额外添加的代码,如日志,访问控制等等。
PointCut:切点,指定何时应用横切关注点,在核心逻辑之前或之后,或其它时刻。
Aspect:切面,Advice和PointCut共同组合,形成了切面。
3. mc::aspect实现的第一个版本
mcveil是一个使用Modern C++特性编写的一些工具集。因为这些新特性参考资料少,用法也少,颇显神秘,所以名字就表示通过实现各种有用的库,来展示如何运用新特性来开发些有用的工具,甚至把它用到日常开发中来,揭开这些神秘的面纱。
本篇文章的最终代码都可以在这里找到:https://github.com/gxkey/mcveil
mcveil将切面命名为aspect,这个类应该不可拷贝,且不可继承,所以基本原型如下:
1template<typename Func>
2struct aspect final : noncopyable
3{
4 aspect(Func&& func) : func_(std::forward<Func>(func))
5 {}
6
7private:
8 Func func_;
9};
noncopyable只是把禁止拷贝这部分逻辑提炼抽象,boost中有一个相同的类,modern c++实现起来也很简单,所以就直接加到了mcveil,实现如下:
1namespace mc
2{
3
4class noncopyable
5{
6protected:
7 noncopyable() = default;
8 ~noncopyable() = default;
9
10 noncopyable(const noncopyable&) = delete;
11 const noncopyable& operator=(const noncopyable&) = delete;
12};
13
14} // namespace mc
aspect类只有一个模板参数Func,就是切面中的现存模块。将Func作为唯一的成员变量,在构造函数中进行初始化,以保存现存模块,供后面调用。
现在要考虑如何往现存模块加入额外逻辑?也就是说,如何在Func前后加入Advice?
首先,Advice应该可以存在无限个,所以应该使用C++11的可变参数模板。
其次,Func也可能会有0个或多个参数,所以参数也应该是可变的。
根据这两点,基本确定应该传递的参数,于是写出基本结构:
1template<typename... Advice, typename... Args>
2void invoke(Args&&... args) const
3{
4}
现在,要做就是在Func前后依次执行Advice。本来这个逻辑实现起来是非常复杂的,至少要定义多个invoke版本,以递归的方式展开参数包。
而利用C++17的Fold Expressions,只需四行代码,就已经完成了所有工作。实现如下:
1template<typename... Advice, typename... Args>
2void invoke(Args&&... args) const
3{
4 int dummy;
5 (Advice().before(std::forward<Args>(args)...), ...);
6 func_(std::forward<Args>(args)...);
7 (dummy = ... = (Advice().after(std::forward<Args>(args)...), 0));
8}
这几行代码相当精妙!
在C++17以前,要实现这个逻辑相较复杂,至少得多出十几行代码。
精妙之处不仅在于使用了Fold Expressions,还在于逗号表达式的使用,此举是为了逆序执行Advice的after()语句。
可以说,这里每一个看似无关的代码,其实都必不可少地发挥着作用,完成着80%的功能逻辑。
剩下的20%功能属于边角料工作,但也得费一番心思。
Advice中默认要提供before和after成员函数,就是两个切点。然而,你无法保证,因此需要一些编译期判断,同时,我们希望在两个切点都未提供的情况下,不做任何处理;若只提供了一个,那么就只调用一个。
这样处理是非常合理的,因为一个aspect中可以存在多个Advice,每个处理不同的工作,有些可能只想在核心关注点之前或之后进行相关处理,这些都应该满足。
这在C++20之前,实现起来也是相当的麻烦,需要借助宏与模板技巧,实现编译期分派,十分不灵活。
C++20之后,可以借助Concepts和C++17的if constexpr在编译期实现上述功能。
首先,定义两个Concepts,用来判断Advice中是否存在before和after成员,代码如下:
1template<typename Advice, typename... Args>
2concept HasMemberBefore = requires (Advice advice) {
3 advice.before(std::declval<Args>()...);
4};
5
6template<typename Advice, typename... Args>
7concept HasMemberAfter = requires (Advice advice) {
8 advice.after(std::declval<Args>()...);
9};
这里用的是simple requirements来检查表达式是否有效。
接着,使用if constexpr和定义的两个Concepts对Advice进行编译期检测,决定是否调用before()或after()。代码如下:
1template<typename... Advice, typename... Args>
2void invoke(Args&&... args) const
3{
4 auto invoke_before = [&](auto&& ad) {
5 if constexpr (HasMemberBefore<decltype(ad), Args...>)
6 ad.before(std::forward<Args>(args)...);
7 };
8
9 auto invoke_after = [&](auto&& ad) {
10 if constexpr (HasMemberAfter<decltype(ad), Args...>)
11 ad.after(std::forward<Args>(args)...);
12 };
13
14 int dummy;
15 (invoke_before(Advice()), ...);
16 func_(std::forward<Args>(args)...);
17 (dummy = ... = (invoke_after(Advice()), 0));
18}
可以看到,这里还是利用Fold Expressions展开每个Advice,再利用Lambda函数处理每个Advice,处理逻辑使用if constexpr和定义的Concepts。
至此,基本所有逻辑已经实现完成,算起来核心逻辑不到20行代码,这都要依赖C++最新的特性,才能如此简单地实现通用的AOP功能。
最后,aspect使用起来并不太方便,所以我们使用Factory Method将它的创建封装其中:
1template<typename... Advice, typename... Args, typename Func>
2void make_aspect(Func&& func, Args&&... args)
3{
4 mc::aspect<Func> ap(std::forward<Func>(func));
5 ap.invoke<Advice...>(std::forward<Args>(args)...);
6}
这里注意,通常的Factory Method用于创建对象并返回,而这里无需如此,因为我们就是想让aspect的使用像一个函数一样,简单易用。
现在,可以这样使用:
1#include "mcaspect.hpp"
2#include <iostream>
3
4struct Log {
5 void before(const char* str) {
6 std::cout << "Log before " << str << std::endl;
7 }
8
9 void after(const char* str) {
10 std::cout << "Log after " << str << std::endl;
11 }
12};
13
14struct Clock {
15 void before(const char* str) {
16 std::cout << "Clock before " << str << std::endl;
17 }
18
19 void after(const char* str) {
20 std::cout << "Clock after " << str << std::endl;
21 }
22};
23
24
25struct Lambda {
26 void before() {
27 std::cout << "Lambda before " << std::endl;
28 }
29
30 void after() {
31 std::cout << "Lambda after " << std::endl;
32 }
33
34};
35
36
37
38void foo(const char* str)
39{
40 std::cout << str << std::endl;
41}
42
43int main()
44{
45 mc::make_aspect<Log, Clock>(&foo, "foo function");
46 mc::make_aspect<Lambda>([](){ std::cout << "lambda function\n"; } );
47
48 return 0;
49}
将会如下输出:
4. 优化后的第二个版本
上述代码看似完美,实则还不完善。
上面的代码虽然可以在msvc编译,却无法在gcc编译。这是由于ap.invoke<>()的用法:
1template<typename... Advice, typename... Args, typename Func>
2void make_aspect(Func&& func, Args&&... args)
3{
4 mc::aspect<Func> ap(std::forward<Func>(func));
5 ap.invoke<Advice...>(std::forward<Args>(args)...);
6}
注意第5行,这里对invoke展开传入了Advice,这条语句若是单独拿到main,或一个常规的自由函数中,gcc编译起来也不会出现任何问题。
而一旦把它放到模板函数中,或是类中,gcc就无法完成这个工作。
什么意思呢?
这翻译一下,其实是说,他想要个对象,而我们却给了个类型(新东西支持的不行,或者说,语法更严格,别忘了此处是成员函数
为达目的,那么便顺其意,修改invoke的接口。先来看当前的invoke的接口:
1template<typename... Advice, typename... Args>
2void invoke(Args&&... args) const
3{
4}
这里要将Advice也作为具体的参数,那么两个可变参数,肯定无法识别。因此,必然要将一个的类型抽离,于是将Args转而作为aspect的模板参数(这招也不是第一次用了,在Factory Method那篇文章中也使用此法处理register_type引发的问题)。
于是,现在aspect类完整代码变成了这样:
1template<typename Func, typename... Args>
2struct aspect final : noncopyable
3{
4 aspect(Func&& func) : func_(std::forward<Func>(func))
5 {}
6
7 template<typename... Advice>
8 void invoke(Args&&... args, Advice&&... advice) const
9 {
10 auto invoke_before = [&](auto&& ad) {
11 if constexpr (HasMemberBefore<decltype(ad), Args...>)
12 ad.before(std::forward<Args>(args)...);
13 };
14
15 auto invoke_after = [&](auto&& ad) {
16 if constexpr (HasMemberAfter<decltype(ad), Args...>)
17 ad.after(std::forward<Args>(args)...);
18 };
19
20 int dummy;
21 (invoke_before(advice), ...);
22 func_(std::forward<Args>(args)...);
23 (dummy = ... = (invoke_after(advice), 0));
24 }
25
26private:
27 Func func_;
28};
那么,也要改变make_aspect方法,更改如下:
1template<typename... Advice, typename... Args, typename Func>
2void make_aspect(Func&& func, Args&&... args)
3{
4 mc::aspect<Func, Args...> ap(std::forward<Func>(func));
5 ap.invoke(std::forward<Args>(args)..., Advice()...);
6}
现在就将类型变为了具体的对象,传入到invoke成员函数当中,不会存在任何岐义。
以上就是当前的实现版本,我没有用最终版,就表示还可以添加功能,使其更加灵活。比如异常的处理,更多的切点等等考虑。
当然,我不太喜欢让一个东西变得太过复杂庞大,轻巧易用,简洁易懂,舍九而只取一更为重要。太多东西杂糅在一起,反而会让问题越来越复杂,影响整体的效率。
- EOF -
关注『CPP开发者』
看精选C++技术文章 . 加C++开发者专属圈子
↓↓↓
点赞和在看就是最大的支持❤️