Practice Lab 7 — Product catalogue with enforced invariants¶
Difficulty: Beginner · Estimated time: 3 hours · Module 4: Encapsulation and Access Control
An invariant is a sentence that must always be true of an object. This lab is about the difference between writing that sentence in a comment and making it impossible to break.
At a glance¶
| Difficulty | Beginner |
| Estimated time | 3 hours |
| Module | Module 4 — Encapsulation and Access Control |
| Concepts practised | Private data, validating operations, invariants, interface design, reading access versus changing access, const correctness |
| Prerequisite | Practice Lab 6 |
| Class design | given — the invariants are stated; you choose the operations |
Scenario¶
An online shop keeps a product catalogue. Each product has a code, a name, a price in whole pence, a stock level, and a reorder threshold. Two rules come from the accountant and cannot be negotiated:
- A price is never negative, and never zero.
- Stock never goes below zero.
Two more come from experience: a product's reorder threshold is never above its maximum stock level, and a product's code never changes after it is created.
The current prototype stores everything in public fields, and the shop has already lost a weekend to a negative stock level that was written directly into the structure.
Problem statement¶
Design and implement a Product type whose invariants cannot be broken from outside:
- Construct a product from a code, a name, a price and a maximum stock level.
restock(quantity)andsell(quantity), each refusing an invalid quantity.set_price(new_price), refusing a price that would break the invariant.below_reorder_threshold(), and an operation to set the threshold that refuses a value above the maximum.code(),name(),price(),stock(), and adescribe().- A
Cataloguethat holds products (at most 50), refuses a duplicate code, and can find a product by code.
Then, in invariants.md, write each invariant as a sentence, and next to it the single place in your
code that enforces it. If an invariant has no single place, your design is not finished.
Learning objectives¶
By the end of this lab you will be able to:
- State an object's invariants as sentences, and point at the one operation that enforces each.
- Choose the operations a caller actually needs, rather than exposing the data and adding setters.
- Explain why a setter for every field is equivalent to public data with extra steps.
- Return information from a query without handing out modifiable access to the object's state.
- Write a test that tries to break an invariant and fails to — which is the only evidence that it is enforced.
Functional requirements¶
- Construction refuses an empty code, an empty name, a non-positive price and a non-positive maximum.
- A code never changes: there is no operation that changes it, and
code()returns a value that cannot be used to change it. restockrefuses zero and negative quantities; stock never exceeds the maximum, and a restock that would exceed it is refused (naming both numbers) rather than clamped.sellrefuses zero, negative, and more than the stock on hand, naming both the requested and available quantities.- The reorder threshold starts at a documented default, and cannot be set above the maximum.
below_reorder_threshold()is true when stock is strictly below the threshold, false otherwise.describe()is one line containing code, name, price, stock, maximum and threshold.- The catalogue refuses a duplicate code and a 51st product, with messages that name the code or the limit.
findreports absence in a documented way and never returns a reference to a temporary.- Every query that does not change the object is
const, and aconst Product&can be printed.
Constraints¶
- Three files:
product.h,catalogue.hwith its.cpp, andmain.cpp; build with an explicit list. - No public data members, and no operation named
set_stock. Stock changes only throughrestockandsell. - No
friend, no inheritance, no templates. - Integer pence for money;
intfor stock. - Every refusal prints a message on
std::coutthat contains both numbers when a comparison failed. - No warnings.
Starter task¶
In invariants.md, before writing code:
- Write the four invariants as sentences. For each, name the operations that could break it and the one place you will enforce it.
- Which operations does a caller genuinely need? Write the list, then delete any that exist only because a field is private. Say which you deleted.
code()returns astd::string. What does that copy, and what would returning aconst std::string&cost instead? Choose one and justify it in a sentence.- Write the test you will run to try to break the stock invariant from
main, and predict its result.
Implementation tasks¶
- Implement
Productwith its refusals, and keep every check inside the operation that owns it. - Implement
Cataloguewith the fixed array, the duplicate check and the find. - Write
main: build three products, sell one out of stock, restock past the maximum, set a bad price, set a bad threshold, print the catalogue, and print aconst Product&. - Write the three break-attempts from your starter task as actual code in
main, and record what happened. - Run every test case and record the output.
Test cases¶
| # | Action | Expected result |
|---|---|---|
| 1 | Construct with price 0 | Refused |
| 2 | Construct with a negative maximum | Refused |
| 3 | sell more than the stock |
Refused, naming requested and available |
| 4 | sell exactly the stock |
Accepted; stock becomes 0 |
| 5 | sell after case 4 |
Refused |
| 6 | restock past the maximum |
Refused, naming the resulting level and the maximum |
| 7 | restock exactly to the maximum |
Accepted |
| 8 | set_price negative |
Refused; the old price remains |
| 9 | Set the threshold above the maximum | Refused |
| 10 | Add a duplicate code to the catalogue | Refused, naming the code |
| 11 | below_reorder_threshold at exactly the threshold |
false (the rule is strictly below) |
| 12 | Try to break stock from main without using an operation |
Does not compile, and the message is recorded |
Boundary cases to run deliberately: stock 0; stock exactly at the maximum; threshold 0; a name with spaces; a code with leading zeros.
Expected behaviour¶
$ ./catalogue
P-001 Compilers price 4500 pence stock 3/10 threshold 2
P-002 Algorithms price 3800 pence stock 0/5 threshold 1
sell P-002 1 -> refused: requested 1, available 0
sell P-001 3 -> accepted, stock 0
restock P-001 11 -> refused: would reach 11, maximum is 10
set price P-001 -100 -> refused: a price must be positive
break attempt: catalogue.find("P-001").price() = 4500 (read-only access) pence
Extension challenge¶
Add a discount to a product: a percentage that applies to the price, with these rules: the discount is never above 60%, a discounted price is still never zero, and the original price is recoverable.
Record in invariants.md:
- The new invariant sentences, and where each is enforced.
- What
price()now means, and what you would name the two prices to keep callers from guessing. - Whether the discount belongs on
Productor on a separate object, and what each choice costs a caller. - Which single operation had to change, and how many exist now.
Debugging challenge¶
The shop still reaches a negative stock level, and the code looks correct:
void sell(int quantity) {
if (quantity < 0) {
std::cout << "refused: negative quantity\n";
}
if (quantity <= stock_) { // "we have enough"
stock_ -= quantity;
}
}
Reproduce it and answer:
- Which check is missing, and what does the omission let through?
- Why does the second
ifnot protect the invariant on its own? - Rewrite the operation with a single refusal path, and say why one
ifis better than two here. - Which test case in the table would have caught this, and did yours?
Reflection questions¶
- Which invariants in your
Producthave exactly one enforcement point, and which have two? The ones with two are the risky ones — which are they? code()returns by value. If you had returned a reference, what could a caller do with it, and why does that matter more for a length than for a number?- How would a caller check a price before trying to set it, and is that a design smell or a service?
- A
set_priceoperation refused a negative price. What is the difference between refusing and clamping, and which one would the accountant prefer? - Your catalogue holds products by value. What happens to a product's identity when it is copied into the catalogue, and is that the behaviour you want?
- Which operation in your design would you remove if the shop added a second kind of product? Answer with the module you have not reached yet in mind.
Completion checklist¶
- [ ] It builds from a documented command with
-Wall -Wextra -Werrorand no warnings - [ ] No public data members, and no
set_stock - [ ]
invariants.mdstates each invariant with its single enforcement point - [ ] At least three break-attempts from outside were written, run and recorded
- [ ] Refusals name both numbers when a comparison failed
- [ ] A
const Product&can be printed - [ ] All twelve test cases run, with output recorded
- [ ] Boundary cases run, including stock exactly at the maximum and at the threshold
- [ ] The debugging challenge was explained before being fixed
- [ ] I can state each invariant out loud without looking at the code
Related: Lab index · Module 4 — Encapsulation · Previous: Practice Lab 6 · Next: Practice Lab 8 — Appointment slot with validated booking