Skip to content

Practice Lab 28 — Notification framework from containers and callables

Difficulty: Advanced · Estimated time: 6 hours · Module 14: STL with OOP

A framework made of an interface, a container, a function object and a lifetime rule. Every piece is standard-library; the design is yours.


At a glance

Difficulty Advanced
Estimated time 6 hours
Module Module 14 — The STL with OOP
Concepts practised Storing polymorphic objects without slicing, function objects, std::function and lambdas, iteration over a container of callables, ordering, lifetime of subscribers
Prerequisite Practice Lab 27
Class design yours — the requirements and the lifetime rule are given

Scenario

A monitoring service must tell interested parts of the system when something happens: a device goes offline, a threshold is crossed, a batch finishes. The subscribers are of four kinds, and they differ in how they are invoked:

  • an object with a method (the audit log),
  • a free function (the existing alarm routine, which cannot be changed),
  • a lambda capturing local state (an ad-hoc counter in main),
  • a plain callable object with its own state (a rate limiter that must count calls).

The subtle requirement is the one that breaks naive implementations: a subscriber must not be notified after it has been destroyed, and unsubscribing must be possible during a notification.


Problem statement

Build a notification framework.

  1. A notification record: an event kind, a source, a severity, a timestamp or counter, and a payload that can be a number or text.
  2. A subscriber interface — and the design decision recorded: is a subscriber an abstract class, a std::function, or both, with an adapter for the other? Argue it.
  3. A registry that accepts all four subscriber kinds above, and can unsubscribe one.
  4. Ordered delivery within a severity or priority, so two subscribers registered at the same priority are notified in a documented order.
  5. Safe unsubscription during notification — demonstrated: a subscriber that unsubscribes itself from inside its own notification, and one that unsubscribes a different subscriber mid-notification. Neither may skip or double-notify anybody.
  6. No notification after destruction — a subscriber object destroyed while registered must not be called. Demonstrate what happens with your design, and record the rule that makes it safe (a destructor that unsubscribes, a token, or a documented ownership requirement).
  7. Filtering: a subscriber may subscribe only to certain event kinds or severities, expressed as a predicate rather than an if inside the subscriber.
  8. A bulk dispatch: a batch of notifications processed with a standard algorithm, and the summary produced with another.

In framework-design.md:

  • The subscriber representation you chose and the alternative's cost, in one paragraph.
  • The container holding subscribers, its ordering rule, and why that container.
  • The lifetime rule, stated as something callers must do — and what the framework does if they do not.
  • The mutate-during-iteration problem: what the standard containers do to a loop that unsubscribes, and how your dispatch avoids it.
  • The filtering predicate's type and where it lives.

Learning objectives

By the end of this lab you will be able to:

  • Store and invoke polymorphic behaviour from four different kinds of callable behind one interface.
  • Choose between an abstract subscriber and a std::function and defend the choice.
  • Iterate a container that is being modified, safely and deliberately.
  • Enforce and document a lifetime rule for subscribers.
  • Express filtering as a predicate instead of a branch inside the subscriber.

Functional requirements

  1. All four subscriber kinds work without modifying the free function.
  2. The audit log records every notification it receives, in the order received.
  3. The rate limiter's own state changes across calls and is observable.
  4. A subscriber added later does not receive earlier notifications; one removed earlier receives none after.
  5. Self-unsubscription during a notification neither skips the next subscriber nor double-notifies.
  6. Unsubscribing another subscriber mid-notification has the same guarantee, and the behaviour is documented for the already-notified and not-yet-notified cases.
  7. A destroyed subscriber that did not unsubscribe is not called — through your documented rule, not luck.
  8. Filtering happens before dispatch, and a filtered-out subscriber does no work.
  9. Dispatch of 1 000 notifications prints a summary: count by kind, count by severity, and per subscriber.
  10. No dangling subscriber is ever invoked; a test or instrumented count proves it.

Constraints

  • Header and source per type family, with a documented build command.
  • The free function must be adapted, not edited — record the adapter.
  • Explicit reference counting of dispatch calls, so "notified twice" is detectable.
  • No warnings.

Starter task

In framework-design.md, before coding:

  1. Decide the subscriber representation and write the alternative's cost in one paragraph.
  2. Write the lifetime rule as an instruction to callers, and one enforcement mechanism you could add.
  3. Predict what happens when a subscriber unsubscribes itself during iteration in each of: a vector, a list, an ordered container. Then check the predictions.
  4. Write the filter predicate's type and one example usage.
  5. Write the ordering rule for same-priority subscribers, and where it is enforced.

Implementation tasks

  1. Implement the notification record and severity.
  2. Implement the subscriber representation and the registry with its ordering.
  3. Adapt the four subscriber kinds, including the free function.
  4. Implement dispatch with safe mutation — via a copy, a two-phase mark-and-sweep, or a documented alternative — and justify the choice.
  5. Implement filtering as a predicate.
  6. Write the lifetime demonstration: destroy a subscriber without unsubscribing and show the outcome.
  7. Run the 1 000-notification batch and print the summary.

Test cases

# Action Expected result
1 Notify with all four kinds registered All four called, in the documented order
2 A subscriber added after a notification Receives only later ones
3 Unsubscribe, then notify Not called
4 A subscriber unsubscribes itself No skip, no double notification
5 A subscriber unsubscribes another, not yet notified That one is not notified (documented behaviour)
6 A subscriber unsubscribes another, already notified No effect on this round
7 Destroy a registered subscriber without unsubscribing Not called; the rule's outcome recorded
8 Two subscribers at the same priority Documented, repeatable order
9 Filter on one event kind Only matching notifications reach it
10 1 000 notifications Summary: by kind, by severity, per subscriber
11 Count every invocation No subscriber counted twice in one round
12 Rate limiter after 100 calls Its own state reflects all calls

Boundary cases to run deliberately: no subscribers; one subscriber; unsubscribing an unregistered subscriber; a filter that matches nothing; a notification with an empty payload.


Expected behaviour

registered: audit(log), alarm(free), counter(lambda), limiter(object)
notify(device_offline, high) ->
  audit: [high] device_offline src=PLC-3
  alarm: ALARM high device_offline
  counter: 1
  limiter: 1 of 5 allowed
self-unsubscribe during notification:
  audit unsubscribed itself; the remaining three were still called exactly once
another subscriber unsubscribed mid-round:
  limiter removed the counter; counter was already notified this round -> still once
destroyed without unsubscribing:
  audit's object went out of scope; dispatch called 3 subscribers (audit skipped by its rule)
1000 notifications: device_offline 400, threshold 350, batch_done 250
  per subscriber: audit 1000, alarm 1000, counter 1000, limiter 5 (rate-limited)

Extension challenge

Make delivery asynchronous: notifications are queued and drained later, in one place.

Record in framework-design.md:

  • What changes about the lifetime rule once delivery is deferred — and why it becomes stricter.
  • How the queue interacts with filtering: at enqueue or at drain, and what each costs.
  • What happens to a notification queued for a subscriber that unsubscribes before the drain.
  • The change cost: files, operations, lines, and one new failure mode.

Debugging challenge

A subscriber registered twice is notified twice in one round, and one subscriber is skipped entirely when another unsubscribes:

void Notifier::dispatch(const Notification& n) {
    for (auto& sub : subscribers_) {          // vector<unique_ptr<Subscriber>>
        if (sub->interested(n)) {
            sub->notify(n);
            if (sub->done()) {
                subscribers_.erase(find_subscriber(sub.get()));   // during the loop
            }
        }
    }
}

Reproduce it — register a self-unsubscribing subscriber before another one — and answer:

  1. What does erasing during the loop do to the current and next elements, in this container?
  2. Which iterator becomes invalid, and which element is skipped as a result?
  3. Why does the same bug not show with a hash container, and what new problem appears there instead?
  4. Which of the twelve test cases would catch it, and what would the counts show?

Reflection questions

  1. Did you choose an abstract subscriber or a callable, and what did the other cost? Would you choose the same again for a framework with 50 subscriber types?
  2. Which subscriber kind was hardest to adapt, and why?
  3. The lifetime rule is a requirement on callers. What is the difference between a rule you document and one the framework cannot break? Which did you implement?
  4. Unsubscribing mid-round: which of the two possible behaviours (this round or next round) is right, and who should decide?
  5. Filtering with a predicate instead of a branch inside the subscriber — what did that buy, and what does it cost at registration?
  6. The rate limiter's state changed across calls. Who owns that state, and who is responsible for resetting it?

Completion checklist

  • [ ] It builds from a documented command with -Wall -Wextra -Werror and no warnings
  • [ ] All four subscriber kinds work, with the free function adapted rather than edited
  • [ ] framework-design.md records the representation choice and the lifetime rule
  • [ ] Self-unsubscription during dispatch is safe — no skip, no double notification
  • [ ] Unsubscribing another subscriber mid-round behaves as documented
  • [ ] A destroyed subscriber is never invoked, through the documented rule
  • [ ] Filtering is a predicate, and a filtered-out subscriber does no work
  • [ ] The 1 000-notification summary is printed
  • [ ] Invocation counts prove no double notification
  • [ ] All twelve test cases run, with output recorded
  • [ ] The debugging challenge was explained before being fixed
  • [ ] I can state the ordering rule and the container that provides it

Related: Lab index · Module 14 — The STL with OOP · Previous: Practice Lab 27 · Next: Practice Lab 29 — A plugin registry behind one interface