C++20之设计模式:观察者模式

观察者模式

观察者

观察者模式是一种流行且必需的模式,QT的信号槽机制本质上就是观察者模式。

属性观察者

一个人每长大一岁的时候都会庆祝生日,怎么实现呢?可以给出下面这样一个定义:

c++ 复制代码
struct Person {
  int age;
  Person(int age) : age{age} {}
};

怎么知道一个人的年龄发生改变了呢?尝试轮询?这种方法太糟糕!更好的是通知机制,当年龄变化时发出通知。

设计一个setter,使其变化时发出通知。

c++ 复制代码
struct Person {
  int get_age() const { return age; }
  void set_age(const int value) { 
  	// 仅在变化时通知
  	if (age != value) {
  		age = value; 
  		// notify
  	}
  }

 private:
  int age;
};

怎么实现notify呢?

Observer<T>

一种方法是定义一个基类,任何关心Person变化的对象都需要继承它

c++ 复制代码
struct PersonListener {
  virtual void person_changed(Person &p, const string &property_name) = 0;
};

然而,属性更改可以发生在Person以外的类型上,此时需要为这些类型生成额外的类。这里使用更通用的定义:

c++ 复制代码
template <typename T>
struct Observer {
  virtual void field_changed(T &source, const string &field_name) = 0;
};

field_changed()中的两个参数,第一个是对属性发生更改的对象的引用,第二个是属性的名称。名称作为字符串传递将损害代码的可重构性(因为属性名可以变化)

这个实现将允许我们观察Person类的变化

c++ 复制代码
struct PersonObserver : Observer<Person> {
  void field_changed(Person &source, const string &field_name) override {
    cout << "Person's " << field_name << " has changed to " << source.get_age() << ".\n";
  }
};

这个方案允许同时观察多个类的属性变化。例如,将Creature类加入。

c++ 复制代码
struct ConsolePersonObserver : Observer<Person>, Observer<Creature> {
  void field_changed(Person &source, const string &field_name) {}
  void field_changed(Creature &source, const string &field_name) {}
};

另一种替代方法是使用std::any。

Observable

Person作为一个可观察类,它将承担新的责任,即:

  • 维护一个列表,其中保存所有订阅Person变化的观察者
  • 观察者可以通过 subscribe()/unsubscribe() 订阅或者取消订阅
  • 当Person发生改变的时候,通过notify通知所有的观察者。
c++ 复制代码
template <typename T>
struct Observable {
  void notify(T& source, const string& name);
  void subscribe(Observer<T>* f) { observers.emplace_back(f); };
  void unsubscribe(Observer<T>* f);

 private:
  vector<Observer<T>*> observers;
};

subscribe()/unsubscribe(),将一个观察者从列表中加入/删除。

notify()遍历每个观察者,并且依次调用对应的observer.field_changed()函数。

c++ 复制代码
void notify(T &source, const string &name) {
  for (auto &&obs : observers) observes->field_changed(source, name);
}

但是,仅继承Observable<T>是不够的,我们的类还需要在其属性发生改变的时候调用notify()函数。

例如,考虑set_age()函数,它现在有三个职责:

  • 检查属性值是否已实际更改。如果age是20岁,设置为20岁,通知时是没有意义的。
  • 给属性赋合理的值。
  • 用正确的参数调用notify()函数

因此,set_age()的新实现可能长成这样:

c++ 复制代码
struct Person : Observable<Person> {
  void set_age(const int age) {
    // check_age(age);
    if (this->age != age) {
      this->age = age;
      notify(*this, "age");
    }
  }

 private:
  int age;
};

连接观察者和被观察者

现在,使用设计的观察者和被观察者,下面是观察者的示例:

c++ 复制代码
// CRTP
struct PersonObserver : Observer<Person> {
  void field_changed(Person &source, const string &field_name) override {
    cout << "Person's " << field_name << " has changed to " << source.get_age()
         << ".\n";
  }
};

用法:

c++ 复制代码
Person p{ 20 };
PersonObserver ob;
p.subscribe(&ob);
p.set_age(21); // Person's age has changed to 21.
p.set_age(22); // Person's age has changed to 22.

如果不关心有关属性依赖关系和线程安全性/可重入性的问题,就可以在这此止步。如果想看到更复杂的讨论,请继续阅读。

依赖问题

大于18岁的人具有恋爱权,当某个人具有恋爱权之后我们希望被通知到。首先,假设Person类有如下的getter函数:

c++ 复制代码
bool get_can_love() const { return age >= 18};

注意,get_can_love()没有底层属性成员和setter(我们可以引入这样的字段,例如can_love(),但它显然是多余的),但是有必要添加notify()接口。怎么做呢?试着找出是导致can_love改变的原因,是set_age()做的!因此,想要得到恋爱状态变化的通知,这些需要在set_age()中完成。

c++ 复制代码
void set_age(const int value) const {
  if (age != value) {
    auto old_can_love = can_love();  // store old value
    age = value;
    notify(*this, "age");

    if (old_can_love != can_love())  // check value has changed
      notify(*this, "can_love");
  }
}

set_age()里不仅检查年龄是否改变,也检查can_love是否改变并通知发出通知! 想象一下can_love依赖于两个字段,比如age和parent------这意味着它们的两个setter都必须处理can_love通知。更糟糕的是,如果年龄也会以这种方式影响其他10种属性呢? 这是一个不可用的解决方案!

当然,属性依赖关系可以被形式化为某种类型的map<string, vector<string>>。这将保留一个受属性影响的属性列表(或者相反,影响属性的所有属性)。遗憾的是,这个map必须手工定义,而且要与实际代码保持同步是相当棘手的。

取消订阅和线程安全

观察者如何从可观察对象中取消订阅?从观察者列表中删除即可,这在单线程场景中非常简单:

c++ 复制代码
void unsubscribe(Observer<T>* observer) {
  observers.erase(remove(observers.begin(), observers.end(), observer), observers.end())
}

erase-remove的用法只在单线程场景中是正确的。vector不是线程安全的,所以同时调用subscribe()和unsubscribe()可能会导致意想不到的结果,因为这两个函数都会修改vector。

这很容易解决:只需对所有可观察对象的操作都加一个锁。这看起来很简单:

c++ 复制代码
template <typename T>
struct Observable {
  void notify(T& source, const string& name) {
    scoped_lock<mutex> lock{mtx};
    ...
  }
  void subscribe(Observer<T>* f) {
    scoped_lock<mutex> lock{mtx};
    ...
  }
  void unsubscribe(Observer<T>* o) {
    scoped_lock<mutex> lock{mtx};
    ...
  }

 private:
  vector<Observer<T>*> observers;
  mutex mtx;
};

另一个非常可行的替代方案是使用类似TPL/PPLconcurrent_ vector。当然,您会失去排序保证(换句话说,一个接一个地添加两个对象并不能保证它们按照那个顺序得到通知),但它肯定会让您不必自己管理锁。

可重入

最后一个实现通过在3个关键接口中加锁来保证线程安全。例如,有一个交通管理组件一直监视一个人,直到他18岁。当他们18岁时,组件取消订阅:

c++ 复制代码
struct TrafficAdministration : Observer<Person> {
  void TrafficAdministration::field_changed(Person& source, const string& field_name) override {
    if (field_name == "age") {
      if (source.get_age() < 17)
        cout << "Whoa there, you are not old enough to drive!\n";
      else {
        // oh, ok, they are old enough, let's not monitor them anymore
        cout << "We no longer care!\n";
        source.unsubscribe(this);
      }
    }
  }
};

这将会出现一个问题,因为当某人17岁时,整个调用链将会是:

notify() --> field_changed() --> unsubscribe()

这是存在一个问题,因为在unsubscribe()中试图获取一个已经被获取的锁。这就是可重入问题。

  • 一种方法是简单地禁止这种情况

  • 另一种方法是放弃从集合中删除元素的想法。相反,我们可以这样写:

    c++ 复制代码
    void unsubscribe(Observer<T>* o) {
      auto it = find(observers.begin(), observers.end(), o);
      if (it != observers.end()) *it = nullptr;  // cannot do this for a set
    }

    随后,当使用notify()时,只需要进行额外的检查:

    c++ 复制代码
    void notify(T& source, const string& name) {
      for (auto&& obs : observes)
        if (obs) obs->field_changed(source, name);
    }
通过 Boost.Signals2 来实现 Observer

观察者模式有很多预打包的实现,并且可能最著名的是 Boost.Signals2 库。本质上,该库提供了一种称为信号的类型,它表示 C++ 中的信号术语(在别处称为事件)。可以通过提供函数或 lambda 表达式 来订阅此信号。它也可以被取消订阅,当你想通知它时,它可以被解除。

c++ 复制代码
template <typename T>
struct Observable {
  signal<void(T&, const string&)> property_changed;
};

它的调用如下所示:

c++ 复制代码
struct Person : Observable<Person> {
  void set_age(const int age) {
    if (this->age == age) return;
    this->age = age;
    property_changed(*this, "age");
  }
};

API 的实际使用将直接使用信号,当然,除非你决定添加更多 API 陷阱以使其更容易:

c++ 复制代码
Person p{123};
auto conn = p.property_changed.connect([](Person&, const string& prop_name) {
  cout << prop_name << " has been changed" << endl;
});
p.set_age(20);  // name has been changed
// later, optionally
conn.disconnect();

connect() 调用的结果是一个连接对象,它也可以用于在你不再需要信号通知时取消订阅。

总结

毫无疑问,本章中提供的代码是一个明显的例子,它过度思考和过度设计了一个超出大多数人想要实现的问题的方式。

让我们回顾一下实现 Observer 时的主要设计决策:

  • 决定你希望你的 observable 传达什么信息。例如,如果你正在处理字段/属性更改,则可以包含属性名称。你还可以指定旧/新值,但传递类型可能会出现问题。

  • 你想让你的观察者成为tire class,还是你只需要一个虚函数列表?

  • 你想如何处理取消订阅的观察者?

    • 如果你不打算支持取消订阅------恭喜你,你将节省大量的实现观察者的工作,因为在重入场景中没有删除问题。
    • 如果你计划支持显式的 unsubscribe() 函数,你可能不想直接在函数中擦除-删除,而是将元素标记为删除并稍后删除它们。
    • 如果你不喜欢在(可能为空)裸指针上调度的想法,请考虑使用 weak_ptr 代替。
  • Observer<T> 的函数是否有可能是 从几个不同的线程调用?如果他们是,你需要保护你的订阅列表:

    • 你可以在所有相关函数上放置 scoped_lock;或者
    • 你可以使用线程安全的集合,例如 TBB/PPLcurrenct_vector。你将失去顺序保证。
  • 来自同一来源的多个订阅允许吗?如果是,则不能使用 std::set

遗憾的是,没有理想的 Observer 实现能够满足所有条件。 无论你采用哪种实现方式,都需要做出一些妥协。

相关推荐
程序员与背包客_CoderZ32 分钟前
C++设计模式——Abstract Factory Pattern抽象工厂模式
c语言·开发语言·c++·设计模式·抽象工厂模式
zzzhpzhpzzz34 分钟前
设计模式——组合实体模式
设计模式
zzzhpzhpzzz4 小时前
设计模式——前端控制器模式
设计模式
forestsea4 小时前
【Java 解释器模式】实现高扩展性的医学专家诊断规则引擎
java·人工智能·设计模式·解释器模式
小白不太白9506 小时前
设计模式之 命令模式
设计模式·命令模式
吃汉堡吃到饱7 小时前
【创建型设计模式】单例模式
单例模式·设计模式
小白不太白9507 小时前
设计模式之 备忘录模式
服务器·设计模式·备忘录模式
zzzhpzhpzzz7 小时前
设计模式——策略模式
设计模式·策略模式
入门到跑路7 小时前
【君正T31开发记录】8.了解rtsp协议及设计模式
网络协议·设计模式
小白不太白9507 小时前
设计模式之 解释器模式
java·设计模式·解释器模式