Practice Lab 24 — Ring buffer with non-type parameters¶
Difficulty: Advanced · Estimated time: 5 hours · Module 12: Templates and Generic Programming
Capacity as part of the type. A small, exact container where every decision has a visible consequence, and where the compiler will help you if you let it.
At a glance¶
| Difficulty | Advanced |
| Estimated time | 5 hours |
| Module | Module 12 — Templates and Generic Programming |
| Concepts practised | Non-type template parameters, fixed capacity, specialisation, generic versus concrete trade-offs, constexpr, type safety at compile time |
| Prerequisite | Practice Lab 23 |
| Class design | the container's operations are specified; its internals and choices are yours |
Scenario¶
An embedded logger captures sensor readings at a fixed rate. It must never allocate — the device has no heap worth the name — and it must keep the most recent N readings, discarding the oldest when full. A telemetry link occasionally asks for the last N in order.
The logger is used with exactly two capacities: 64 readings for a fast channel and 4096 for a slow one, and the capacity is a compile-time constant in both cases. Nothing at run time decides it.
Written badly, this container is where students discover that a "full" ring buffer has four states confused for two, and that a copy of the buffer-with-index is a bug waiting to happen.
Problem statement¶
Implement a RingBuffer<T, N>: a fixed-capacity container with no dynamic allocation.
- Operations:
push(overwriting the oldest when full, as a documented choice),pop_oldest,pop_newest,size,capacity,empty,full,at(index)with a documented indexing meaning, and iteration in oldest-to-newest order. - Two capacities in use, both as non-type parameters.
- A specialisation for one element type: for example a
boolorcharspecialisation with a documented difference — or a full specialisation that stores counts rather than elements, if you prefer. The specialisation must be justified in the notes; a specialisation that behaves identically is a defect. - A
constexprcapacity query, soRingBuffer<int, 64>::capacity()is usable in a constant expression. - A compile-time failure: attempting to instantiate with capacity 0 must fail with a clear message.
- Overwrite accounting:
overwritten()counts how many elements were discarded, because the logger's diagnostics need it.
In ring-notes.md:
- The four states of a ring buffer, and how your implementation stores them (two indices, a count, or a flag).
- Why no allocation is required, and which of the standard containers could not be used here and why.
- The specialisation's difference, and what it cost in lines and in surprise.
- The cost of capacity being part of the type: which operations become free, and which become impossible.
Learning objectives¶
By the end of this lab you will be able to:
- Write a class template with a non-type parameter and use it for two instantiations.
- Implement a fixed-capacity container correctly, including the full/empty distinction and overwrite policy.
- Specialise a template where the element type genuinely allows a better representation.
- Make an invalid instantiation fail with a message that names the problem.
- Explain what putting a value into the type buys and what it forbids.
Functional requirements¶
- Capacity is a compile-time constant, and
capacity()is usable inconstexprcontexts. - Empty and full are distinguishable at all times, including with capacity 1 — the case that breaks two-index implementations.
pushon a full buffer discards the oldest and incrementsoverwritten().pop_oldestandpop_newestboth refuse an empty buffer with a documented result.- Iteration is oldest-to-newest, and is stable across a wrap-around.
at(index)has a documented meaning — oldest-relative is the sensible choice; say which you picked.- The container works for a non-copyable element type if the operations used permit it, or you document which operation forces a copy.
sizeof(RingBuffer<T, N>)does not depend on the heap; the container contains its storage.- Capacity 0 does not compile, and the message names the capacity requirement.
- The specialisation is exercised in the program, and produces the documented, different behaviour.
Constraints¶
- Header-only template, plus a small
mainper instantiation and one that must fail to compile. - No dynamic allocation anywhere — no
new, nostd::vector, nostd::deque. - No exceptions in the hot operations: refusals are reported through documented results.
- No
staticmutable state in the container. - No warnings in the programs that should compile.
Starter task¶
In ring-notes.md, before coding:
- Write the four states (empty, partly full, exactly full, wrapped) and the data you will store to tell them apart. Predict what capacity 1 does to your design.
- Decide the overwrite policy and write it as one sentence at the class.
- Decide what
at(index)means, and what an out-of-range index does. - Write the specialisation you intend, and the sentence that justifies it being different.
- Predict
sizeoffor two instantiations, then measure.
Implementation tasks¶
- Implement
RingBuffer<T, N>with the state model from your notes. - Implement both pops and the iteration.
- Add
overwritten()and use it in a scripted sequence that wraps twice. - Implement the specialisation and demonstrate its difference.
- Add the capacity-0 failure and capture the message.
- Write
mainper instantiation, and the failing program as a separate file. - Print
sizeofand theconstexprcapacity check.
Test cases¶
| # | Action | Expected result |
|---|---|---|
| 1 | Push 5 into capacity 64 | size 5, not full, empty false |
| 2 | Fill exactly to capacity | full true, size == capacity |
| 3 | Push one more | oldest discarded, overwritten() 1, iteration order correct |
| 4 | Wrap twice with 130 pushes into capacity 64 | overwritten() equals the expected count |
| 5 | pop_oldest after wrapping |
The oldest surviving element |
| 6 | pop_newest |
The most recently pushed |
| 7 | Pop from empty | Refused, documented result |
| 8 | Capacity 1: push, push, iterate | size 1, the newest element, overwritten() 1 |
| 9 | at(0) and at(size-1) |
Oldest and newest, per your documented meaning |
| 10 | constexpr capacity check |
Compiles as a constant expression |
| 11 | sizeof for two instantiations |
Recorded numbers; no heap member |
| 12 | Capacity 0 | Does not compile; message captured |
| 13 | The specialisation's program | The documented, different behaviour |
Boundary cases to run deliberately: capacity 1 (again, deliberately); push then immediately pop;
iteration right after a wrap; a RingBuffer of a struct larger than the capacity is long.
Expected behaviour¶
RingBuffer<int, 4>
push 1..4 -> size 4, full
push 5 -> overwritten 1, iteration oldest->newest: 2 3 4 5
push 6 -> overwritten 2, iteration: 3 4 5 6
pop_oldest -> 3
pop_newest -> 6
capacity as a constant expression: 4
sizeof(RingBuffer<int, 4>) = 24
RingBuffer<int, 1>
push 7, push 8 -> size 1, iteration: 8, overwritten 1
capacity 0:
error: static_assert failed: RingBuffer capacity must be at least 1
Extension challenge¶
Add iteration by chunks of the most recent K elements (for a telemetry request), where K is a run-time
value, not a template parameter.
Record in ring-notes.md:
- Why
Kshould not be a template parameter (one sentence about what that would do to instantiations). - How the chunk iteration is expressed, and whether it needs a copy.
- What happens when
Kexceedssize(), and whenKexceedscapacity(). - The change cost: operations, lines, and any new requirement on
T.
Debugging challenge¶
The buffer reports full when it is empty, but only after exactly one wrap:
Reproduce it — capacity 4, push 4, pop 1, push 1, then query — and answer:
- Why are full and empty indistinguishable with two indices alone, and what is the third piece of information every correct ring buffer keeps?
- Which two operations produced the same index pair in your reproduction?
- Write the corrected predicate pair, and say which one is now obviously derived from the other.
- Which test case in the table catches this, and did the capacity-1 case catch it first?
Reflection questions¶
- Where is your "full" state kept, and how did capacity 1 influence that choice?
- Capacity is part of the type. Which operations became
constexprfor free, and which became impossible (for example, changing capacity at run time)? - Your specialisation behaves differently. What would a user be surprised by, and how did you document it?
- The container performs no allocation. Which of the standard containers could have been used instead, and what would each have cost you here?
overwritten()is a diagnostic. What else would an embedded logger want to know that this container cannot tell it?- If a second channel needed a capacity chosen from a configuration file, what would you change, and what would you lose?
Completion checklist¶
- [ ] The compiling programs build with
-Wall -Wextra -Werrorand no warnings - [ ] No dynamic allocation — searched for and recorded
- [ ] Empty and full are correct, including at capacity 1
- [ ] Overwrite accounting is exact across two wraps
- [ ] Both pops refuse an empty buffer with a documented result
- [ ] Iteration is oldest-to-newest and correct after wrapping
- [ ] Capacity is a
constexprquery usable in a constant expression - [ ] Capacity 0 fails to compile, with the message captured
- [ ] The specialisation exists and differs in a documented way
- [ ]
sizeofrecorded for two instantiations - [ ] All thirteen test cases run, with output recorded
- [ ] The debugging challenge was explained before being fixed
Related: Lab index · Module 12 — Templates · Previous: Practice Lab 23 · Next: Practice Lab 25 — Order validation with an exception hierarchy