Skip to content

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:

  1. Construct a product from a code, a name, a price and a maximum stock level.
  2. restock(quantity) and sell(quantity), each refusing an invalid quantity.
  3. set_price(new_price), refusing a price that would break the invariant.
  4. below_reorder_threshold(), and an operation to set the threshold that refuses a value above the maximum.
  5. code(), name(), price(), stock(), and a describe().
  6. A Catalogue that 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

  1. Construction refuses an empty code, an empty name, a non-positive price and a non-positive maximum.
  2. A code never changes: there is no operation that changes it, and code() returns a value that cannot be used to change it.
  3. restock refuses zero and negative quantities; stock never exceeds the maximum, and a restock that would exceed it is refused (naming both numbers) rather than clamped.
  4. sell refuses zero, negative, and more than the stock on hand, naming both the requested and available quantities.
  5. The reorder threshold starts at a documented default, and cannot be set above the maximum.
  6. below_reorder_threshold() is true when stock is strictly below the threshold, false otherwise.
  7. describe() is one line containing code, name, price, stock, maximum and threshold.
  8. The catalogue refuses a duplicate code and a 51st product, with messages that name the code or the limit.
  9. find reports absence in a documented way and never returns a reference to a temporary.
  10. Every query that does not change the object is const, and a const Product& can be printed.

Constraints

  • Three files: product.h, catalogue.h with its .cpp, and main.cpp; build with an explicit list.
  • No public data members, and no operation named set_stock. Stock changes only through restock and sell.
  • No friend, no inheritance, no templates.
  • Integer pence for money; int for stock.
  • Every refusal prints a message on std::cout that contains both numbers when a comparison failed.
  • No warnings.

Starter task

In invariants.md, before writing code:

  1. Write the four invariants as sentences. For each, name the operations that could break it and the one place you will enforce it.
  2. 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.
  3. code() returns a std::string. What does that copy, and what would returning a const std::string& cost instead? Choose one and justify it in a sentence.
  4. Write the test you will run to try to break the stock invariant from main, and predict its result.

Implementation tasks

  1. Implement Product with its refusals, and keep every check inside the operation that owns it.
  2. Implement Catalogue with the fixed array, the duplicate check and the find.
  3. 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 a const Product&.
  4. Write the three break-attempts from your starter task as actual code in main, and record what happened.
  5. 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 Product or 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:

  1. Which check is missing, and what does the omission let through?
  2. Why does the second if not protect the invariant on its own?
  3. Rewrite the operation with a single refusal path, and say why one if is better than two here.
  4. Which test case in the table would have caught this, and did yours?

Reflection questions

  1. Which invariants in your Product have exactly one enforcement point, and which have two? The ones with two are the risky ones — which are they?
  2. 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?
  3. How would a caller check a price before trying to set it, and is that a design smell or a service?
  4. A set_price operation refused a negative price. What is the difference between refusing and clamping, and which one would the accountant prefer?
  5. 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?
  6. 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 -Werror and no warnings
  • [ ] No public data members, and no set_stock
  • [ ] invariants.md states 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