| Lesson 8 | Observer Pattern Consequences |
| Objective | Evaluate the benefits, implementation choices, and potential pitfalls of the Observer pattern. |
The Observer pattern keeps several dependent objects synchronized with one authoritative subject. It is useful when the subject's state can be presented or used in several ways and the set of dependents can change while the program is running. Instead of embedding spreadsheet, chart, display, audit, or cache logic in the subject, the design defines a stable observer contract. Concrete observers register for notifications through that contract and respond when relevant state changes.
Observer reduces coupling, but it does not make the participants completely independent. The subject knows that observers exist, although it knows them only through their shared abstraction. Observers depend on the meaning and timing of the subject's notifications, and pull-model observers also depend on part of the subject's query interface. The benefit is precise: the subject does not depend on concrete observer classes, so new observer types can usually be introduced without changing the subject.
A typical Observer collaboration assigns the following responsibilities:
These responsibilities do not require one particular class hierarchy. Java programs commonly express the observer contract as an interface, listener, callback, or event handler. They do not need the deprecated java.util.Observable class or java.util.Observer interface. C++ programs may use an abstract base class, callable object, function wrapper, or framework callback. Composition and an explicit contract are generally clearer than treating multiple inheritance as the default solution.
The following C++20 program defines two calculation observers. Each receives a reference to the subject when notified and then pulls the current value through getValue(). The subject prevents duplicate registration, supports explicit removal, and does not notify when an assignment leaves its value unchanged.
#include <algorithm>
#include <iostream>
#include <string_view>
#include <vector>
class Subject;
class Observer {
public:
virtual ~Observer() = default;
virtual void update(const Subject& subject) = 0;
};
class Subject {
public:
void attach(Observer& observer) {
if (std::find(observers.begin(), observers.end(), &observer)
== observers.end()) {
observers.push_back(&observer);
}
}
void detach(Observer& observer) {
std::erase(observers, &observer);
}
void setValue(int newValue) {
if (value == newValue) {
return;
}
value = newValue;
notify();
}
[[nodiscard]] int getValue() const {
return value;
}
private:
void notify() {
const auto snapshot = observers;
for (Observer* observer : snapshot) {
observer->update(*this);
}
}
int value{0};
std::vector<Observer*> observers;
};
class CalculationObserver final : public Observer {
public:
CalculationObserver(std::string_view label, int divisor)
: label(label), divisor(divisor) {}
void update(const Subject& subject) override {
const int value = subject.getValue();
std::cout << label << ": " << value
<< " / " << divisor << " = " << value / divisor
<< ", remainder " << value % divisor << '\n';
}
private:
std::string_view label;
int divisor;
};
int main() {
Subject subject;
CalculationObserver byFour{"Divide by four", 4};
CalculationObserver byThree{"Divide by three", 3};
subject.attach(byFour);
subject.attach(byThree);
subject.setValue(14);
subject.detach(byFour);
subject.setValue(15);
}
The program produces this output:
Divide by four: 14 / 4 = 3, remainder 2
Divide by three: 14 / 3 = 4, remainder 2
Divide by three: 15 / 3 = 5, remainder 0
Observer is the abstraction known by Subject. Its virtual destructor permits safe destruction through the base-class interface. Subject::attach checks whether an observer is already present, and Subject::detach provides explicit unregistration. Subject::setValue completes the state change before notification begins. Client code decides which concrete observers participate and can change that composition during the object's useful lifetime.
The notification method traverses a snapshot of the observer pointers. If a callback registers or removes an observer, that structural change does not invalidate the traversal already in progress. Snapshot iteration helps with reentrant list changes, but it does not make the class thread-safe. Concurrent attachment, removal, state mutation, notification, or destruction still requires a deliberate synchronization policy.
The subject stores non-owning pointers. Every observer must therefore outlive its registration or detach before it is destroyed. In this example, the subject is declared before the observers, and local variables are destroyed in reverse declaration order. A production implementation needs an explicit ownership policy, such as scope-bound subscription objects, framework lifecycle hooks, weak references, or subject-owned callbacks when ownership is intentional.
A common consequence of partitioning a system into cooperating classes is the need to maintain consistency among related objects. Tight coupling occurs when one class depends heavily on another class's concrete structure or behavior, so a change in one frequently forces a change in the other. Observer replaces direct knowledge of concrete dependents with a registration and notification contract, allowing participants to be reused and extended more easily.
Consider application data displayed simultaneously in a spreadsheet, a bar chart, and a pie chart. These views do not need references to one another. Each observes the same data subject and controls only its own presentation. When the application commits a data change, the subject notifies every registered view, and each view refreshes its representation. Another view can be added later without modifying the existing views or placing presentation-specific code in the data model.
The figure illustrates a one-to-many dependency. One data subject supplies state to several observers, each of which presents the same information differently. The subject can support any reasonable number of dependents, and the observers remain unaware of one another. This arrangement is valuable for views, but the same structure can also connect a subject to observers responsible for auditing, caching, validation, or application status.
The example uses the pull model. Its notification supplies the subject, and each observer queries getValue() for the state it needs. This keeps the callback general and avoids constructing a large event for every possible observer. The cost is that an observer must know the relevant subject interface, and several observers may repeat the same queries.
In the push model, the subject passes the changed value or an event object directly to update(...). An observer can then respond without querying the subject. This can make simple observers more efficient, but the subject must decide which data belongs in the event. An oversized event can expose details that most observers do not need, while an event with too little information forces follow-up queries.
A hybrid model carries essential change information together with a subject reference. Observers can use the event immediately and request additional details only when necessary. No model is universally best. The team should select one according to the size of the state, number of observer types, cost of querying, desired contract stability, and need to represent one logical change as an immutable event.
Observer is often described as a publish-subscribe interaction because a subject sends notifications to registered dependents without knowing their concrete classes. In the classic GoF pattern, however, the subject normally maintains or controls access to its observer registrations and invokes observers directly in the same process.
A broader publish-subscribe architecture may place an event channel, broker, or message bus between publishers and subscribers. That intermediary can decouple participants by location, time, and process boundary. It may also provide serialization, persistence, delivery acknowledgements, retries, filtering, or ordering. A vector of observer pointers provides none of those distributed-system guarantees. The patterns are related, but an in-process Observer implementation is not a complete distributed event system.
The subject must complete its transition and restore its invariants before invoking observers. If notification occurs halfway through a change, a pull-model observer can read a mixture of old and new values. When several fields form one logical change, use a transaction or commit step, validate before notification, batch related mutations, or represent the completed change with one immutable event.
Observers should not silently depend on vector or registration order unless that order is part of the documented contract. If one observer must run before another, the system contains a real dependency. Model it explicitly with priorities, separate phases, or direct orchestration instead of relying on an incidental collection order that may change during maintenance.
Registering one observer twice can cause duplicate work and confusing results. The example checks for the pointer before adding it. Another design can return a subscription token whose identity makes registration and cancellation explicit. The important point is to define whether duplicate subscriptions are rejected, combined, or intentionally treated as separate registrations.
A destroyed C++ observer leaves a dangling pointer if it remains registered with a non-owning subject. In Java, a long-lived subject holding strong references can prevent unused listeners from being garbage-collected. Explicit unregistration, scoped subscriptions, weak references, and lifecycle hooks can address these problems, but the correct choice depends on who owns each participant and how long the subscription should remain active.
With synchronous callbacks, an exception or error from one observer can interrupt delivery to later observers. The design must state whether notification stops and propagates the failure, records the error and continues, or isolates observers behind an asynchronous queue. A generic catch block that silently discards failures does not establish a reliable policy.
An observer may change the subject while handling a notification or update another subject that eventually changes the first one again. Such reentrancy can cause deep recursion, repeated work, or an unbounded cycle. Change detection, idempotent updates, queued delivery, clear transition rules, or a carefully designed reentrancy guard can keep the collaboration stable.
Document which thread invokes observers and whether callbacks may block. Concurrent registration, removal, and notification require synchronization or a suitable thread-safe collection. Calling unknown observer code while holding the subject's lock can cause deadlock, so the design of lock scope and callback execution must be coordinated. Observer reduces dependencies on concrete classes, but participants still share a contract, notification semantics, timing assumptions, and a lifecycle policy.