Understanding the Observer pattern in C++ beyond the basic idea

Viewed 1542

I'm studying design patterns from Head First Design Patterns and, in order to get confident, I plan to implement the each pattern in C++ after studying the corresponding chapter.

Concerning the Observer pattern, I am really struggling to beyond the language-indpended main idea.

I've been skiming through the following:

but, as soon as I started coding in C++, some language-specific difficulties brought to light some misunderstanding of mine on the whole topic, which I've not been able to solve with the liks above. However, I am posting here since I've got a (seemingly) working code.

The sample code is the following, afterwards I list some of my concerns about my understanding and use of this pattern.

#include <algorithm>
#include <iostream>
#include <unordered_map>
#include <unordered_set>

class Observer {
 public:
  virtual void update() = 0;
};

class Observable {
 protected:
  std::unordered_set<Observer*> observers;
 public:
  virtual void addObserver(Observer&) = 0;
  virtual void removeObserver(Observer&) = 0;
  virtual void notifyObservers() = 0;
};

class Virus : public Observable {
 public:
  void addObserver(Observer& o) {
    observers.insert(&o);
  };
  void removeObserver(Observer& o) {
    std::erase_if(observers, [&o](auto const& io){ return &o == io; });
  };
  void notifyObservers() {
    for (auto& o : observers) o->update();
  };
  void operator++() {
    ++spread;
    std::cout << "\nLevel: " << spread
              << "\nSending notifications:\n";
    notifyObservers();
  }
  int getSpread() { return spread; };
 private:
   int spread = 0;
};

class NormalCountry final : public Observer {
 private:
  void update() override {
    if (obs.getSpread() < 2)
      std::cout << "NormalCountry: What!? Coronavirus?\n";
    else
      std::cout << "NormalCountry: Ok, let's quarantine...\n";
  };
 private:
  Virus& obs;
 public:
  void selfUnsubscribe() {
    obs.removeObserver(*this);
  };
  void selfSubscribe() {
    obs.addObserver(*this);
  };
  NormalCountry() = delete;
  NormalCountry(Virus& o) : obs(o) {
    selfSubscribe();
  }
};

class BraveCountry final : public Observer {
 public:
  void update() override {
    std::cout << "BraveCountry: No worries, people, our antibodies are cooler!\n";
  };
};

int main() {
  Virus cv;
  NormalCountry it(cv);
  BraveCountry uk;
  ++cv;
  cv.addObserver(uk);
  ++cv;
  it.selfUnsubscribe();
  ++cv;
}

Doubts about the pattern itself:

  • My understanding is that the observers only have to be able to be informed, by the Observable that they observe, that something changed in it, which makes easy to think of the Observer as an interface, which in C++ means an abstract class defining no more than a pure virtual update method, which forces the derived classes into overloading that method with that specific signature (this seems to be more or less the same in Java); however, this does not say anything about whether the observers should know more than the "binary" information (something/nothing changed); indeed, here it comes the pull or push design decision, which, on the one hand, looks like an implementation detail to me; on the other hand, the decision affects how the update pure virtual method should be declared (choosing the arguments list), as well as whether the concrete observers should hold a pointer/reference to the observed object. This is to say that if I have been given the two abstract classes Observer and Observable, an implementation choice has already been taken, and I cannot change.

  • As regards the Observable interface, the book says it only has to provide declarations for addObserver, removeObserver, and notifyObservers. But then the collection of Observers has to be a member of the concrete observer classes. I understand that the choice of the collection (std::vector, std::list, ...) is an implementation detail, but the fact that some kind of collection must be in the Observable does not look as a detail. However, choosing a collection in a concrete observable becomes necessary as soon as one tries to code the three member functions mentioned above. Maybe that's enough, I don't know.

  • The Observer::update pure virtual method should be public in order for concrete observables to implement notifyObservers. But the concrete observers can make their implementation of update private. Doing so kinda makes sense to me: if update and notifyObservers are the two ends of each observer-observable relation, why should it be possible for the caller code to call an observer's update if the observable's notifyObservers has not decided to do so? Well, maybe for the same reason why an observer can handle its own (un)subscription (in the case that it holds an handle to the observed object).

C++-specific doubts:

  • Should the observable class (the abstract base or the concrete derived) have a collection of raw pointers or smart pointers? What the implications of this choice could be?

  • addObserver and removeObserver should take an Observer parameter. I don't think it should by passed by value, to avoid copying; maybe not even by pointer, otherwise at the call site we have to pass &obj instead of obj. Then it's by reference; but which? A const lvalue reference would allow passing temporary observers, but does this make sense? If it does, then one should have two overloads for rvalues and lvalues, as having one template function with a universal reference is not allowed for virtual functions.

  • In my example code I have stored a reference to the concrete observable object in one of the concrete onservers class, so that I can use some member specific to the concrete observable (getSpread) in the update implementation. I fear this might be bad.

1 Answers
  1. Regarding observer interface

Obesever should have update (or onChange or somthing similar) method. Obesever will almost always need some context about the change to update itself. Hence question of push vs pull appears here. In push, observable has to pass context information payload to observer. But now the question arises, what should be structure of payload which satisfies all observers? How will payload structure evolve when new observer is added which needs some context parameter which is not part of current payload structure? On the other hand in pull design, observer needs to query observable about new state. It means observable needs to expose appropriate interface to observer. Choice between push vs pull is dependent on use case. Push provides better decoupling, but update context information should be simple enough. In pull approach, observer can query observable and get more complex and customized state information.Even different observer can query observable differently. But pull approach has stogner coupling between observer and observable.

  1. Regarding collection of observer in observable

There are three main use case of observer collection addObserver, removeObserver and notifyObservers. So any unordered collection should be fine.

  1. Regarding observers update being public

update is a way of notification, so it must be available in public interface.

C++ specific

  1. Observable should keep weak reference(Doesn't extend observer life time)observers.
  2. Keeping reference to observable in observer: It is not good idea. It can be seen as an implicit implementation of pull approach. But keeping interface will be better than keeping concrete object.
Related