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

本文介绍一种特殊的RAII对象,同时它也是一个代理对象,能够代理某个对象,并在对对象的访问操作时进行控制。

下面看一个动物类的类层次体系:

class animal {
public:
    virtual ~animal() = default;
    virtual void run() = 0;
};

class dog : public animal {
public:
    virtual void run() override {
        puts("car is running");
    }
};

class rabbit : public animal {
public:
    virtual void run() override {
        puts("rabbit is running");
    }
};

假设要对animal的run()成员函数在调用时进行一些log输出,比如在调用它的前后位置的地方打印时间信息。因为dog和rabbit都是animal的派生类,它们都重写了run(),可以在基类中通过多态机制调用派生类的run()函数,并在调用前后添加相应的功能。

class animal {
    void before() {
        // 输出时间的代码;
    }
    void after() {
        // 输出时间的代码;
    }
public:
    virtual ~animal() = default;
    virtual void run() = 0;
    void log_run() {
        before();
        run();
        after();
    }
};

显然只要是animal类的派生类,例如dog和rabbit,都可以通过调用log_run()来输出日志,这是设计模式中模板方法模式的典型应用。

假设我们有一个汽车类,它也有一个相同的run()成员函数:

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

如果也需要在调用它的成员函数run()的前后位置处,进行功能相同的log输出,因为car不是animal类的派生类,显然无法按照上面介绍的模板方法模式来实现。

通常情况下,可以使用相同的设计方案,并编写重复的代码来实现:让car是一个基类的派生类,比如基类是一个交通工具类-vehicle类,这样也像animal那样,在vehicle类中来实现模板方法,显然这是面向对象设计的典型思维模式。

显然,尽管dog类和car类都有相同的run()成员函数,但是因为它们没有共同的基类,不属于同一个类层次体系,需要为它们分别设计不同的类层次体系结构。

这种OOP方案虽然常见,但是并不灵活,需要在设计阶段就预先定义好各个类的层次体系,我们知道类之间的继承关系是静态的,在编程时是无法动态扩展的,不同于组合关系。

我们不妨按照OOP的思想来分析一下,这个需求实际上是如何为一组类的方法扩展相同的功能,如果这组类属于同一个基类的派生类,可以按照基类、派生类的继承路线进行扩展,即上面说的模板方法模式:在基类animal中实现了before()和after(),它们用于控制调用dog和rabbit的run()函数,从而为它们的run()扩展了相同的功能。

那么问题来了,如果任意一个类中都提供了void run()成员函数,如何使用最简单的编程方式为它们扩展相同的功能呢?

其实,除了使用模板方法模式实现外,还有一种实现方式是使用代理模式,它使用的是组合机制,比使用继承的模板方法模式更为灵活。

代理模式是为其它对象提供一种代理以控制对这个对象的访问。

在某些情况下,一个对象不直接使用另一个对象,而是通过一个代理对象在客户端和目标对象之间起到中介的作用,这个代理对象提供了和目标对象完全一样的功能接口,客户端访问这个代理对象就像是在访问目标对象一样,同时代理对象根据需要在访问目标对象时进行了控制。

具体实现是:目标类和代理类都实现了某个接口,同时目标类又是代理类中的数据成员,在代理类实现接口方法时,要调用目标类的接口,同时在调用前后进行控制。

可见,代理模式不是通过继承的方式扩展功能,而是使用组合的方式,在代理对象内部引用目标对象,从而在调用目标对象的方法时,进行控制,只要是有相同的接口,在编程时就能通过引用的方式来组合。

无论对于Dog还是Car,它们都有相同的run()成员函数,按照Java语言的说法,尽管它们不是同一个类层次体系中的类,但是可以让它们实现一个相同的接口interface,这是它们共同的特点:

interface Runnable {
    void run();
}

class Dog implements Runnable {
    void run() {...}
}

class Car implements Runnable {
    void run() {...}
}

因此,在Java语言中,尽管Car和Dog都没有相同的基类,但因为它们都实现了相同的接口Runnable,可以使用代理模式来为它们的调用实现日志输出:

class Proxy implements Runnable {
        private Runnable subject;

        public Proxy(Runnable runnable) {
            this.subject = runnable;
        }

        @Override
        public void run() {
            before();
            subject.run();
            after();
        }
    }
}

它们都实现了Runnable接口,Car和Dog都是被代理的角色,它们都可以被Proxy代理角色引用,并且在Proxy调用它们的run()时可以添加控制功能。虽然Dog和Car不属于同一个类层次体系的对象,但是在Proxy看来,它们都可以通过Runnable接口来调用它们的run()方法。

下面是这种模式的应用场景:

void test(Runnable runnable) {
    Proxy proxy = new Proxy(runnable);
    proxy.run();
}

void client() {
    Dog dog = new Dog();
    test(dog);

    Car car = new Car();
    test(car);
}

下面看一下两种实现模式的类结构关系图。

模板方法模式的animal和dog、rabbit类结构图:
图片

显然,这里走的从基类到派生类的路线,是一种沿着类层次的“纵向”方向(如图中的红色虚线)进行的功能扩展。因为在纵向方向,派生类可以继承上层基类的功能,基类扩展了功能,派生类也能继承下来。其实,OOP的封装、继承、多态机制就是沿着“基类-派生类”这条“纵向”路线进行设计的。

代理模式的Proxy和Dog、Car类结构图:
图片

显然Dog、Car这些类,和Proxy类的关系是组合引用关系,它们都实现了Runnable接口,Proxy类的功能是控制作用,虽然Proxy类控制、扩展了Dog和Car这些类的run()方法的执行过程,但是它们在类结构中只是简单的平行关系(横向),不同于继承关系中的基类和派生类,它们有层次关系(纵向)。

Dog和Car属于不同的类,在概念上它们之间没有直接的关联关系,和Proxy也没有直接的关联关系,但是如果从水平方向上看待这些类,如同在这些对象上面横向切开了一个切面(如果把图中的红色虚线,看作是一个横向“切面”的话),在这个切面中有相同的run()函数,可以使用相同的机制来统一控制它,所以有一个专门的术语:面向切面编程-Aspect Oriented Programming,简称AOP。

如果OOP是在类层次“纵向”方向的面向继承和多态的编程技术,而AOP就是没有层次关系的不同类在“横向”方向的面向切面函数的技术,它是OOP的补充。

其实,我们从Proxy类实现就能看出,实现一个已有类的代理类非常简单,而且实现的代码也已经套路化了。因此,Java语言为了方便程序员开发,减低开发时的编写代码的工作量,提供了动态代理机制,可以由Java程序在运行时,为一个interface的实现类动态的生成代理类,显然这就为开发AOP带来了方便。

但是C++语言是没有动态反射机制的,也就无法像Java语言那样能够为一个接口类提供动态代理的实现机制。那么在C++中能否也能像Java那样,可以方便的实现AOP吗?

我们知道,C++有模板技术,只要对象类型中的成员函数符合模板参数类型的调用要求,就可以在模板中调用它,不管对象是什么类型。下面看一下C++的一个实现方案。

先定义两个函数:

#include <format>  // 使用C++20编译
void before() {
    auto now = std::chrono::system_clock::now();
    std::cout << std::format("开始时间: {:%Y-%m-%d %H:%M:%S}", now) << std::endl;;
}

void after() {
    auto now = std::chrono::system_clock::now();
    std::cout << std::format("结束时间: {:%Y-%m-%d %H:%M:%S}", now) << std::endl;
}

再使用模板定义一个AOP的实现,只要一个类型有run()成员函数,就能在调用run()的前后位置处使用before和after:

template<typename T>
void test_aop(T t) {
    before();
    t.run();
    after();
}

显然可以用于dog和car类型:

int main() {
    test_aop(dog{});
    test_aop(car{});
}

虽然能满足要求,但是程序存在异常不安全的风险,假如在before()执行时申请了内存或者互斥锁等资源,但是在中间某个环节发生了异常,最后没有调用after(),导致资源没有释放。

因此在这里,可以把RAII的概念延伸一下,在构造函数中获取资源,也可以认为是一个“开始”的操作,例如分配内存、获得锁、打开文件、建立连接、开始记录等操作,而析构函数中释放资源,也可以认为是一个“结束”的操作,例如释放内存、释放锁、关闭文件、断开连接、结束记录的等操作,也就是在构造函数和析构函数中完成一对相对应的操作。

显然,既然有“始”,就得有“终”,为了防止发生只有“始”,因为程序异常导致而没有发生“终”的错误,可以使用RAII惯用法进行处理。在这里可以把输出开始的时间看作是“始“,而把输出结束的时间看作是”终”,那么使用RAII机制来实现也就是不二之选了。

封装一个RAII类用于AOP操作,它的构造函数实现before的逻辑,而析构函数实现after的逻辑:

#include <format>  // 使用C++20编译
struct Aop {
    Aop() {
        auto now = std::chrono::system_clock::now();
        std::cout << std::format("开始时间: {:%Y-%m-%d %H:%M:%S}", now) << std::endl;;
    }

    ~Aop() {
        auto now = std::chrono::system_clock::now();
        std::cout << std::format("结束时间: {:%Y-%m-%d %H:%M:%S}", now) << std::endl;
    }
};

那么,使用AOP的方案就修改为:

template<typename T>
void test_aop(T t) {
    Aop aop;
    t.run();
}

运行前面dog和car的测试代码,运行结果显然达到了目的:

开始时间: 2026-03-15 02:31:43.721583338
dog is running
结束时间: 2026-03-15 02:31:43.721649916
开始时间: 2026-03-15 02:31:43.721659769
car is running
结束时间: 2026-03-15 02:31:43.721667174

不过,使用时仍有不便之处,还可以继续改进,下一篇继续讨论。

Logo

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

更多推荐