查看原文
其他

使用C++20实现轻量级AOP库

CPP开发者 2021-07-20

The following article is from CPP编程客 Author cpluspluser

1. OOP与AOP的两种编程思维

编程是为了解决某个具体问题,当你看整个问题的时候,容易含糊不清,寻不到方向。将问题拆解一下,弄清它是由什么组成的。随着问题拆解的越来越小,你会发现问题也变得越来越简单。那么,将问题拆解到可以解决之时的各个组成要素,就是程序的模块,当把每个模块都完成的时候,再进行组合,便可以解决整个问题。

这种思维源于物理学中的还原论,面向对象的思维就是通过对象来表示每个模块,以继承和多态来描述模块之间的关系。

模块的基本原则是要满足正交性,即相互独立,所以想对某一模块添加额外处理,不应该强行添加到模块之中,这会破坏模块的独立性。

例如,一个简单的游戏模块,

这里的两个模块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 -


推荐阅读  点击标题可跳转

1、C++ 虚函数表及多态内部原理详解

2、C++ 移动函数原理浅析

3、C++ 条件变量使用详解


关注『CPP开发者』

看精选C++技术文章 . 加C++开发者专属圈子

↓↓↓


点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存