创建一个注册回调的抽象基类的正确方法

right way to make an abstract base class which registers callback

本文关键字:抽象 基类 方法 回调 注册 一个 创建      更新时间:2023-10-16

我想有一个基类,它的目的是为回调注册(并在析构函数中取消注册),回调是一个纯虚函数。这样的。

struct autoregister {
  autoregister() { callback_manager.register(this); }
  ~autoregister() { callback_manager.deregister(this); }
  virtual void call_me()=0;
};

但这对我来说似乎不可靠,我怀疑那里有几个竞争条件。1)当callback_manager看到指针时,call_me仍然是不可调用的,并且它可能会花费任意的时间直到对象完成构造,2)在调用取消注册时,派生对象的析构函数被调用,因此不应该调用回调。

我在想的一件事,是检查,在callback_manager中,指针的call_me是否有效,但我找不到一个标准的兼容方式来获取call_me的地址或任何东西。我正在考虑将typeid(指针)与typeid(autoregister*)进行比较,但两者之间可能存在抽象类,使其不可靠,派生:公共中间{};middle: public autoregister {};, middle的构造函数可能花费一个小时,例如加载SQL或搜索google,回调函数看到它不是基类,并认为可以调用回调函数,然后boom。这能做到吗?

Q1:是否存在其他竞态条件?

Q2:如何做到这一点正确(没有竞争条件,未定义的行为和其他错误)而不要求派生类手动调用寄存器?

Q3:如何检查虚函数是否可以在指针上调用?

你应该把回调句柄和注册句柄分开。除了callback_manager之外没有人需要call_me方法,所以为什么要让它从外部可见?使用std::function作为回调,因为它非常方便:任何可调用对象都可以转换为它,而且lambda非常方便。从回调注册方法返回一个Handle对象。Handle唯一拥有的方法是一个析构函数,你可以从中移除回调函数。

class Handle {
public:
    explicit Handle(std::function<void()> deleter)
        : deleter_(std::move(deleter))
    {}
    ~Handle()
    {
        deleter_();
    }
private:
    std::function<void()> deleter_;
};
class Manager {
public:
    typedef std::function<void()> Callback;
    Handle subscribe(Callback callback) {
        // NOTE: use mutex here if this method is accessed from multiple threads
        callbacks_.push_back(std::move(callback));
        auto itr = callbacks_.end() - 1;
        // NOTE If Handle lifetime can exceed Manager lifetime, store handlers_ in std::shared_ptr and capture a std::weak_ptr in lambda.
        return Handle([this, itr]{
            // NOTE: use mutex here if this method is accessed from multiple threads
            callbacks_.erase(itr);
        });
    }
private:
    std::list<Callback> callbacks_;
};

Q1:是否存在其他竞态条件?

回调/句柄可能比callback_manager更长寿,并将尝试从已删除的对象中取消订阅。这可以通过策略(总是在删除管理器之前取消订阅)或使用弱指针来修复。

如果从多个线程访问callback_manager,则需要用互斥锁保护回调存储

Q2:如何正确地做到这一点(没有竞争条件,未定义行为)和其他错误),而不要求派生类调用寄存器手动吗?

见上图。

Q3:如何检查虚函数是否可以在指针上调用?

这不可能。

在自动寄存器的构造函数中,由于对象尚未完全构造,因此传递给callback_manager的'this'指针是危险的。我推荐一个稍微不同的设计。

struct callback {
  virtual void call_me() = 0;
}
struct autoregister {
  callback*const callback_;
  autoregister(callback*const _callback)
    : callback_(_callback) {
    callback_manager.register(callback_);
  }
  ~autoregister() {
    callback_manager.deregister(callback_);
  }
};

Q1:是否存在其他竞态条件?

也许。如果多个线程可能使用callback_manager,那么它必须是同步的。但是我的版本的autoregister本身没有竞争条件。

Q2:如何做到这一点正确(没有竞争条件,未定义的行为和其他错误)而不要求派生类手动调用寄存器?

我的代码是如何我认为可以做到这一点。

Q3:如何检查虚函数是否可以在指针上调用?

在我的代码中是不必要的。但一般来说,你可以在类中保留一个标志,它在初始化列表中设置为false,在准备被调用时设置为true。

争用条件是指两个线程同时尝试做某件事,其结果取决于精确的时间。我认为在这个代码片段中没有竞争条件,因为this只有执行构造函数的线程才能访问。然而,callback_manager中可能存在竞争条件,但您没有发布该代码,因此我无法判断。

这里还有一个问题:对象是从基类到派生类构造的,所以当autoregister的构造函数运行时,不能调用虚拟的call_me。请参阅此FAQ条目。除非确保类完全构造,否则无法检查虚函数调用是否正常工作。

通过继承来解决这个问题的任何解决方案都不能确保在注册回调之前已完全构造好被注册的类,因此必须在被注册的类的外部完成注册。最好的方法是使用一些RAII包装器,它在构造时注册对象,在销毁时注销对象,并可能强制通过处理注册的工厂创建对象。

我认为@Donghui Zhang在正确的轨道上,但是还没有真正到达,可以这么说。不幸的是,他所做的引入了自己的一组陷阱——例如,如果您将本地对象的地址传递给autoregister的actor,您仍然可以注册一个回调对象,该对象会立即超出作用域(但不一定会立即注销)。

我也认为使用call_me作为回调时调用的成员函数定义回调接口是有问题的(充其量)。如果需要定义一个可以像函数一样调用的类型,c++已经为该函数定义了一个名称:operator()。我将强制执行,而不是call_me存在。

要完成所有这些,我认为你真的需要使用模板而不是继承:

template <class T>
class autoregister {
    T t;
public:
    template <class...Args>
    autoregister(Args && ... args) : t(std::forward(args)...) {
       static_assert(std::is_callable<T>::value, "Error: callback must be callable");
       callback_manager.register(t);
    }
    ~autoregister() { callback_manager.deregister(t); }
};

你可以这样使用:

class f {
public:
     virtual void operator()() { /* ... */ }
};
autoregister<f> a;

static_assert保证你作为模板参数传递的类型可以像函数一样被调用。

这也支持通过autoregister将参数传递给它所包含的对象的构造函数,所以您可能会有这样的内容:

class F {
public:
    F(int a, int b) { ... }
    void operator()() {}
};
autoregister<F> f(1,2);

…并且1, 2在构造时将从autoregister传递到F。还要注意,这并没有尝试为回调函数强制一个特定的签名。例如,如果您要修改回调管理器以将回调作为int r = callback(1);执行,那么只有当您注册的回调对象可以用int参数调用并返回int(或者可以隐式转换为int的东西)时,代码才会编译。编译器将强制回调具有与其调用方式兼容的签名。这里唯一的大缺点是,如果您传递的类型可以调用,但是(例如)不能使用回调管理器试图传递的参数调用,那么您得到的错误消息可能不像您希望的那样可读。