转发自公众号文章:谈谈RAII惯用法-代理对象RAII(下)

我们知道汽车除了跑之外,还包括启动、加速、减速、停车等功能,为它们定义相应的成员函数,即:

class car {
public:
    void start() {
        puts("start car");
    }

    void stop() {
        puts("stop car");
    }

    void run() {
        puts("car is running");
    }

    void speed_up() {
        puts("car is speed up");
    }

    void speed_down() {
        puts("car is speed down");
    }
};

如果为了测量car类的每个成员函数的执行时间,需要对每个成员函数都要按照前文的方式使用Aop进行控制,代码如下:

int main() {
    car c;
    {
        Aop aop;
        c.start();
    }
    {
        Aop aop;
        c.run();
    }
    {
        Aop aop;
        c.speed_up();
    }
    {
        Aop aop;
        c.speed_down();
    }
    {
        Aop aop;
        c.stop();
    }
}

显然,所有成员函数的AOP应用套路完全一样,代码也非常简单,每一个成员函数调用时都使用AOP对象来控制,并且借助于{}限定的作用域在函数调用前后位置输出对应的时间日志。虽然编写程序时形式很简单,但是并不优雅,因为每一处成员函数调用都要编写雷同的代码,充满了“坏味道”。

有没有优雅点的方式?

看一下使用AOP的代码片段:

{
    Aop aop;
    c.run();
}

每一处run()调用时都要对应一个aop对象的创建,为了限定aop的作用域,又得要使用{},显然,如果想办法隐藏掉这个AOP对象,这些重复代码和{}也就不需要了,因此让RAII对象是一个匿名对象,使用匿名表达式RAII可能是一个正确的方向。

我们知道,C++除了有模板技术之外,它还支持操作符重载,并且有一个访问结构成员的操作符->,通过它可以更方便的实现代理模式。因此,基于操作符->重载的机制,方案实现如下:

#include <format>  // 使用C++20编译
template<typename T>
class time_log_aop {
    // 内部类,作为RAII和代理角色
    class proxy_raii {
    public:
        proxy_raii(T *ptr) : ptr(ptr) {
            // 打印输出开始时间
            auto now = std::chrono::system_clock::now();
            std::cout << std::format("开始时间: {:%Y-%m-%d %H:%M:%S}", now) << std::endl;;
        }

        ~proxy_raii() {
            // 打印输出结束时间
            auto now = std::chrono::system_clock::now();
            std::cout << std::format("结束时间: {:%Y-%m-%d %H:%M:%S}", now) << std::endl;
        }
        
        T *operator->() { // 提供->操作符 
            return ptr;
        }
    private:
        T *ptr;
    };

public:
    time_log_aop(shared_ptr<T> v) : ptr(move(v)) {}

    // 操作符->(),返回proxy_raii对象 
    proxy_raii operator->() {
        return proxy_raii(ptr.get());
    }

    proxy_raii around() {
        return operator->();
    }

private:
    shared_ptr<T> ptr;
};

先看一下内部类proxy_raii,它在构造时获得开始时间,在析构时获得结束时间,显然是有始有终的操作,并且这对“始、终”组合要分别用在函数调用的前后位置处,这符合RAII机制的应用场景。

此外,它还有一个数据成员T *ptr,和一个返回ptr指针的操作符->重载函数。因为指针的“->”操作具有访问结构成员的能力,而ptr指向一个T类型的对象,因此通过ptr可以访问所指向的T类型对象的成员。proxy_raii包含一个T类型的指针ptr,并且又能通过重载的->操作符来调用ptr的->操作符,也就是它也提供了T类型的所有接口操作,显然可以认为proxy_raii是T类型对象的代理类。

在time_log_aop类中也有一个操作符->重载函数,返回一个proxy_raii对象,显然如果调用time_log_aop::operator->()时,没有接收返回值,此时会返回一个proxy_raii匿名对象,这是前面文章中介绍的匿名对象表达式RAII的应用场景,从而去掉了显式{}。

它的典型应用方式是:

aop->某个函数(); // aop是包装T类型的一个time_log_aop对象

下面看使用场景:

int main() {
    time_log_aop aop(make_shared<car>());
    aop->run();
    aop->speed_up();
    aop->speed_down();
}

运行后,输出结果:

开始时间: 2026-03-19 21:16:00.351698338
car is running
结束时间: 2026-03-19 21:16:00.351740722
开始时间: 2026-03-19 21:16:00.351744313
car is speed up
结束时间: 2026-03-19 21:16:00.351746562
开始时间: 2026-03-19 21:16:00.351748559
car is speed down
结束时间: 2026-03-19 21:16:00.351750499

分析一下下面调用语句的工作原理:

aop->run();

aop是time_log_aop类型,当调用它的operator->()操作符时,会创建并返回一个proxy_raii类的临时对象,因为这个proxy_raii类重载了操作符->,所以继续调用它的operator->()操作符,最后返回了aop对象所保存的裸指针(从智能指针对象中获得),该指针最终指向了一个car对象,这样使用operator->()操作符可以访问它所指向的对象的成员函数run()了,这是proxy_raii对象实现的代理功能。

同时又因为调用time_log_aop类的operator->()操作符时,返回的proxy_raii对象是临时对象,而临时对象的特点是具有完整表达式生存期,也就是一旦aop->run()调用运行结束,该临时对象就会被销毁。这个proxy_raii临时对象在创建时进行“开始”的操作,在销毁时进行“结束”的操作,这是proxy_raii对象实现的RAII功能。

因此,该调用语句相当于:

proxy_raii tmp = aop.operator->(); // 输出开始时间
car *p = tmp.operator->();
p->run();
tmp->~proxy_raii(); // 输出结束时间

可见在调用p->run()时,在调用前后分别会有一个打印开始时间和打印结束时间的操作,从而实现了对任意对象的run()成员函数的around方式的AOP操作。同样aop->speed_up()和aop->speed_down()也是如此。

比较一下不使用和使用AOP机制的代码,可见,代码结构和形式完全一样,使用AOP方式时,仿佛就是通过一个car类型的指针来调用它的成员函数:

//创建car对象       创建car代理对象
car *a = new car;  time_log_aop aop(make_shared<car>());
//调用成员函数      通过代理调用成员函数
a->run();          aop->run();
a->speed_up();     aop->speed_up();
a->speed_down();   aop->speed_down();

内部类proxy_raii是一个代理类,它代理了外部类的操作,即通过重载操作符->可以访问外部类的所有操作,当然,它并没有直接在每个操作上面进行控制,而是每次代理操作都要通过创建一个临时对象进行,然后在这个临时对象的创建和销毁时进行了控制,比如本例中控制输出了函数执行前后的的开始和结束时间。

此外,proxy_raii类也实现了操作符->的重载,time_log_aop类使用operator->()操作符产生的proxy_raii临时对象接着调用->操作符,从而让->操作符在一个RAII对象的控制下访问结构成员。

结合proxy_raii的特点,它有下面常见的应用场景。

使用场景

1、匿名RAII对象,控制单行函数调用

因为RAII代理对象是匿名的,在这里不妨称之为匿名代理模式。

下面是使用AOP技术访问不同类型对象的run()成员函数的一个函数模板(因为dog和car在“纵向”方向上没有共同的基类,只能使用模板类型来接收dog和car了):

template<typename T>
void test(shared_ptr<T> ptr) {
    time_log_aop aop(move(ptr));
    aop->run();
}
下面是测试代码:
int main() {
    test(make_shared<dog>());
    test(make_shared<car>());
}

下面是运行结果:

开始时间: 2026-03-19 21:17:36.913256905
dog is running
结束时间: 2026-03-19 21:17:36.913294896
开始时间: 2026-03-19 21:17:36.913299425
car is running
结束时间: 2026-03-19 21:17:36.913302529

在dog和car对象的run()成员函数时,在调用前和调用后分别输出了开始时间和结束时间的日志信息。

匿名对象是隐式创建的,当然它也可以显式创建,例如:

template<typename T>
void test(shared_ptr<T> ptr) {
    time_log_aop aop(move(ptr));
    aop.operator->()->run();
}

不过,语句aop.operator->()->run(),这种使用形式并不常见,有点晦涩难懂,阅读代码时不太容易理解,我们可以封装一个普通的成员函数,来用于这个这种场景。

proxy_raii around() {
    return operator->();
}

修改为:

template<typename T>
void test(shared_ptr<T> ptr) {
    time_log_aop aop(move(ptr));
    aop.around()->run();
}

这样更能清晰的表示了是在一个临时对象上面调用了run()等函数。

2、具名RAII对象,控制多行函数调用

RAII代理对象也可以当作具名RAII对象来使用,例如:

int main() {
    time_log_aop aop(make_shared<car>());
    {
        auto raii = aop.operator->();
        raii->run();
        raii->speed_up();
        raii->speed_down();
    }
}

运行结果:

开始时间: 2026-03-19 21:18:38.629020332
car is running
car is speed up
car is speed down
结束时间: 2026-03-19 21:18:38.629059963

此时,自动变量raii接收了time_log_aop的操作符->调用返回的proxy_raii对象,并使用{}指定它的生存期作用域,紧接着后面的run,speed_up以及speed_down等成员函数的调用,在这些函数序列调用之前输出开始时间,在调用之后输出结束时间,显然这是常规RAII使用场景。

同样,我们也可以根据operator->()实现的功能,提供一个有名字的普通成员函数,来专门用于这个这种场景,例如前面说的around()成员函数。around()也返回一个proxy_raii对象,可以指定一个对象来接收,此时,RAII代理对象是有名字的,在这里不妨称之为具名代理模式。下面是更常见的使用方式:

int main() {
    time_log_aop aop(make_shared<car>());
    {
        auto raii = aop.around();
        raii->run();
        raii->speed_up();
        raii->speed_down();
    }
}

3、AOP操作

前面举的例子的属于AOP环绕方式-around,即在函数调用前后分别进行控制,当然它也支持before和after方式:

如果要求仅支持before时,那就是proxy_raii的构造函数有“开始”的操作,析构函数中不提供“结束”的操作;如果要求仅支持after时,那就是proxy_raii的构造函数没有提供“开始”的操作,析构函数中有“结束”的操作,不再详述。

最后,总结一下这个proxy_raii类的特点:

首先,它实现了RAII机制,可以在构造函数中进行“开始”的操作,在析构函数中进行“结束”的操作。

其次,它是代理对象类,存放了某种目标对象的指针类型的数据成员,并重载了指针访问结构成员的操作符->,也就是它是一个“模拟指针”类,通过->操作符来访问目标对象的成员,进而通过它可以做更多的逻辑处理。

最后,它可以按照匿名对象表达式RAII来使用,也可以作为具名RAII对象来使用,分别用在不同的应用场景。

留一个问题供大家思考:

在C++中,容器类不是线程安全的,如果要对容器,比如vector的成员函数的调用时保证线程安全,需要使用互斥锁进行保护,除了保护单个成员函数的调用外,还有一些多个成员函数一起调用的场景也需要保证线程安全,例如:

if (vec.size() < 100) {
    vec.push_back(xxx);
}

结合本文以及前面介绍的RAII使用方式,实现一个线程安全的vector容器类。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐