Embercode
✕
Object-Oriented ProgrammingLesson 17 of 17

50 min read

Inventory mini project

Why this matters

The earlier lessons in this module each cover one idea: classes, constructors, inheritance and so on. Real programs combine those ideas in one place. A program that keeps track of stock in a shop is a good size for practice. It has data that must stay correct, operations that can fail, and output that a person reads.

This lesson builds that program in three parts. Each part is one class, and each class gets its own small test program before the next class is added. Testing one piece at a time keeps a larger program manageable. When a test fails, you know which class to look at. The rules for keeping an object valid and the use of exceptions in class design, covered in earlier lessons of this module, are used here without being explained again.

The project

A shop keeps a list of products in stock. Each product is an item. Every item has four things:

  • a SKU, which is a short code that identifies one product, such as P-100 (SKU stands for stock-keeping unit)
  • a name, such as Blue pen
  • a price in cents, such as 1250 for 12.50
  • a quantity, which is the number of units on the shelf

The price is stored as a whole number of cents. A decimal number such as 12.50 is stored in a double as an approximation, and the approximations add up to small errors. For example, adding 0.1 ten times in a double gives a value slightly below 1.0. Whole cents avoid that problem.

The project has three classes:

  • Item holds one product and checks its own rules.
  • Store holds many items, finds them by SKU, and changes their quantities.
  • Report turns a list of items into text for a person to read.

Store uses Item. Report also uses Item, but it does not use Store. It takes a plain list of items, so its test needs only Item and a list built by hand.

The project uses these files:

  • check.hpp, a small helper for the tests
  • item.hpp and item_test.cpp
  • store.hpp and store_test.cpp
  • report.hpp and report_test.cpp
  • main.cpp, which joins the three classes into one program

A header file, with the ending .hpp, holds a class definition. A line such as #include "item.hpp" copies that file's text into the file that asks for it. The line #pragma once at the top of a header tells the compiler to read that header only once per compiled file, so including it twice does not define the class twice.

To compile and run one test, use two commands:

g++ -std=c++20 -Wall -o item_test item_test.cpp
./item_test

g++ is the C++ compiler. -std=c++20 selects the C++20 language rules. -Wall turns on the usual warnings, which are messages about code that compiles but may be wrong. -o item_test sets the name of the program file that the compiler writes. ./item_test runs that program. Every working program in this lesson compiles with no warnings.

The test helper

Each test program uses the helper in check.hpp. It is shown here first, so the tests below make sense.

#pragma once
 
#include <iostream>
#include <string>
 
inline int failures = 0;
 
inline void check(bool ok, const std::string& what) {
    std::cout << (ok ? "PASS " : "FAIL ") << what << "\n";
    if (!ok) {
        ++failures;
    }
}
 
template <typename Error, typename Action>
bool throwsError(Action action) {
    try {
        action();
    } catch (const Error&) {
        return true;
    } catch (...) {
        return false;
    }
    return false;
}
 
inline int finishTests(const std::string& suite) {
    if (failures == 0) {
        std::cout << "all " << suite << " tests passed\n";
        return 0;
    }
    std::cout << failures << " " << suite << " test(s) failed\n";
    return 1;
}

The function check prints PASS or FAIL for one statement that must be true. The function throwsError runs an action and returns true only if the action throws the error type you name. A unit test is a test for one class by itself, and each test program in this lesson is one. The function finishTests returns 0 when every check passed. That number becomes the exit code of the program. An exit code of 0 means success, and a script can read it.

Step 1: the Item class

What an item must always be

An invariant is a rule that is true for every object at all times between calls. For Item, there are three rules. The SKU is not empty. The price is not negative. The quantity is not negative.

The constructor checks the rules when the object is made. The two stock methods check them when the quantity changes. If a rule fails, the code throws an exception. An exception is a signal that stops the normal flow of the code and carries an error object up to the place that catches it. Here the error is a std::invalid_argument or a std::runtime_error, both from the standard library.

The class

#pragma once
 
#include <stdexcept>
#include <string>
#include <utility>
 
class Item {
public:
    Item(std::string sku, std::string name, int priceCents, int quantity)
        : sku_{std::move(sku)}, name_{std::move(name)}, priceCents_{priceCents}, quantity_{quantity} {
        if (sku_.empty()) {
            throw std::invalid_argument("sku must not be empty");
        }
        if (priceCents_ < 0) {
            throw std::invalid_argument("price must not be negative");
        }
        if (quantity_ < 0) {
            throw std::invalid_argument("quantity must not be negative");
        }
    }
 
    const std::string& sku() const { return sku_; }
    const std::string& name() const { return name_; }
    int priceCents() const { return priceCents_; }
    int quantity() const { return quantity_; }
 
    long long totalValueCents() const {
        return static_cast<long long>(priceCents_) * quantity_;
    }
 
    void addStock(int amount) {
        requirePositive(amount);
        quantity_ += amount;
    }
 
    void removeStock(int amount) {
        requirePositive(amount);
        if (amount > quantity_) {
            throw std::runtime_error("not enough stock for " + sku_);
        }
        quantity_ -= amount;
    }
 
private:
    static void requirePositive(int amount) {
        if (amount <= 0) {
            throw std::invalid_argument("amount must be positive");
        }
    }
 
    std::string sku_;
    std::string name_;
    int priceCents_;
    int quantity_;
};

Several parts of this class need a short explanation.

The public: section lists what other code may use. The private: section holds the data. Code outside the class cannot write pen.quantity_ = -5, because the field is private. Every change has to go through addStock or removeStock, and those methods check the rules first.

The text after the colon in the constructor is the member initializer list. It sets each member before the body of the constructor runs. The members are set in the order they are declared in the class, not in the order they are written in the list. The order here matches the declaration order, which avoids a warning. The mistakes section shows what that warning looks like.

The getters (methods that return a field), such as quantity(), are marked const. A const function promises that it will not change the object. That promise lets the compiler reject a mistake, and it lets code that only holds a const reference call the getter.

The function totalValueCents casts the price to long long before the multiplication. A cast converts a value to another type. A long long can hold much larger numbers than an int, so the product of price and quantity has room for large stock counts.

Worked example 1: one item

This program makes one item, changes its quantity, and catches the error from a bad removal. It uses the class above, saved as item.hpp in the same folder.

#include <iostream>
#include <stdexcept>
#include "item.hpp"
 
int main() {
    Item pen{"P-100", "Blue pen", 1250, 10};
    std::cout << "total value: " << pen.totalValueCents() << "\n";
    pen.addStock(5);
    pen.removeStock(4);
    std::cout << "quantity: " << pen.quantity() << "\n";
    try {
        pen.removeStock(50);
    } catch (const std::runtime_error& e) {
        std::cout << "error: " << e.what() << "\n";
    }
    std::cout << "quantity after error: " << pen.quantity() << "\n";
}

Running it prints:

total value: 12500
quantity: 11
error: not enough stock for P-100
quantity after error: 11

The value 12500 is 1250 cents times 10 units. Adding 5 and removing 4 leaves 11. The call that removes 50 throws, so the catch block runs and prints the message. The quantity is still 11 afterwards, because the check happens before the subtraction.

The item test

The next file tests Item on its own. It does not use Store or Report, so a failure here can only come from item.hpp.

#include <stdexcept>
#include "check.hpp"
#include "item.hpp"
 
int main() {
    Item pen{"P-100", "Blue pen", 1250, 10};
    check(pen.sku() == "P-100", "sku is stored");
    check(pen.name() == "Blue pen", "name is stored");
    check(pen.quantity() == 10, "quantity starts at 10");
    check(pen.totalValueCents() == 12500, "total value is price times quantity");
 
    pen.addStock(5);
    check(pen.quantity() == 15, "addStock(5) gives 15");
 
    pen.removeStock(4);
    check(pen.quantity() == 11, "removeStock(4) gives 11");
 
    check(throwsError<std::runtime_error>([&] { pen.removeStock(50); }),
          "removing more than is in stock throws runtime_error");
    check(pen.quantity() == 11, "a failed removal leaves the quantity unchanged");
 
    check(throwsError<std::invalid_argument>([] { Item bad{"", "x", 1, 1}; }),
          "an empty sku is rejected");
    check(throwsError<std::invalid_argument>([] { Item bad{"A", "x", -1, 1}; }),
          "a negative price is rejected");
    check(throwsError<std::invalid_argument>([] { Item bad{"A", "x", 1, -1}; }),
          "a negative quantity is rejected");
    check(throwsError<std::invalid_argument>([] {
              Item good{"A", "x", 1, 1};
              good.addStock(0);
          }),
          "addStock(0) is rejected");
 
    return finishTests("item");
}

Running it prints:

PASS sku is stored
PASS name is stored
PASS quantity starts at 10
PASS total value is price times quantity
PASS addStock(5) gives 15
PASS removeStock(4) gives 11
PASS removing more than is in stock throws runtime_error
PASS a failed removal leaves the quantity unchanged
PASS an empty sku is rejected
PASS a negative price is rejected
PASS a negative quantity is rejected
PASS addStock(0) is rejected
all item tests passed

The first block checks that the setup is correct. The next block checks that each change works. The removal check proves two things: the bad call throws, and the quantity is unchanged afterwards. The last block checks the rejected inputs. Each case is wrapped in a lambda, which is a short function written inline, such as [] { Item bad{"", "x", 1, 1}; }. The lambda is passed to throwsError, which runs it and reports whether the expected error came out. The lambda [&] { ... } uses &, which lets it use pen from the surrounding code.

Step 2: the Store class

Keeping items by SKU

The store keeps its items in a std::map with the type std::map<std::string, Item>. A map holds pairs of a key and a value, and it keeps the pairs sorted by key. Here the key is the SKU and the value is the Item. Given a SKU, the map finds the item in about log n steps, where n is the number of items stored. That is fast even for thousands of items.

#pragma once
 
#include <map>
#include <ranges>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "item.hpp"
 
class Store {
public:
    void add(Item item) {
        std::string key = item.sku();
        if (items_.contains(key)) {
            throw std::invalid_argument("duplicate sku: " + key);
        }
        items_.emplace(std::move(key), std::move(item));
    }
 
    void restock(const std::string& sku, int amount) {
        find(sku).addStock(amount);
    }
 
    void sell(const std::string& sku, int amount) {
        find(sku).removeStock(amount);
    }
 
    int quantity(const std::string& sku) const {
        return find(sku).quantity();
    }
 
    std::vector<Item> items() const {
        std::vector<Item> result;
        result.reserve(items_.size());
        for (const Item& item : items_ | std::views::values) {
            result.push_back(item);
        }
        return result;
    }
 
private:
    Item& find(const std::string& sku) {
        auto it = items_.find(sku);
        if (it == items_.end()) {
            throw std::out_of_range("unknown sku: " + sku);
        }
        return it->second;
    }
 
    const Item& find(const std::string& sku) const {
        auto it = items_.find(sku);
        if (it == items_.end()) {
            throw std::out_of_range("unknown sku: " + sku);
        }
        return it->second;
    }
 
    std::map<std::string, Item> items_;
};

The method add copies the SKU into key before it moves the item. A move hands over the contents of an object without copying them. After a move, the moved-from object is still valid but its contents are not guaranteed, so reading item.sku() after the move could give an empty string. Copying the key first keeps the map correct.

The method contains checks whether a key is already in the map. It was added in C++20. Without it, the same check would use find and compare the result with end.

The method find returns a reference to the stored item. A reference is another name for the same object, not a copy, so changing it through the reference changes the item inside the store. There are two versions of find. The non-const version lets restock and sell change the item. The const version is used by quantity, which only reads the item. If the SKU is not in the map, both versions throw std::out_of_range.

The method items returns a copy of every item as a std::vector. A vector is a list that can grow. The function std::views::values walks only the values of the map, so the loop does not have to handle the keys. Because the function returns copies, code that receives the list cannot change the items inside the store.

Worked example 2: a store that refuses bad sales

This program does one restock, one successful look-up, and two failures. Each failure is caught, and the quantity is checked afterwards.

#include <iostream>
#include <stdexcept>
#include "store.hpp"
 
int main() {
    Store store;
    store.add(Item{"N-200", "Notebook", 4500, 3});
    store.restock("N-200", 7);
    std::cout << "N-200: " << store.quantity("N-200") << "\n";
    try {
        store.sell("N-200", 99);
    } catch (const std::runtime_error& e) {
        std::cout << "sale refused: " << e.what() << "\n";
    }
    try {
        store.sell("Z-999", 1);
    } catch (const std::out_of_range& e) {
        std::cout << "lookup failed: " << e.what() << "\n";
    }
    std::cout << "N-200 still: " << store.quantity("N-200") << "\n";
}

Running it prints:

N-200: 10
sale refused: not enough stock for N-200
lookup failed: unknown sku: Z-999
N-200 still: 10

The restock adds 7 to the 3 units on hand, which gives 10. The sale of 99 is refused by the Item underneath the store. The lookup of Z-999 fails in the store itself, because no item has that SKU. The last line shows that neither failed call changed the quantity.

The store test

The store test uses Item as well. Item already has its own tests, so if this test fails, the cause is most likely in store.hpp.

#include <stdexcept>
#include "check.hpp"
#include "store.hpp"
 
int main() {
    Store store;
    store.add(Item{"P-100", "Blue pen", 1250, 10});
    store.add(Item{"N-200", "Notebook", 4500, 3});
    check(store.quantity("P-100") == 10, "add keeps the item and its quantity");
    check(store.items().size() == 2, "items() lists both items");
 
    check(throwsError<std::invalid_argument>([&] { store.add(Item{"P-100", "Other", 1, 1}); }),
          "adding a duplicate sku throws invalid_argument");
    check(store.quantity("P-100") == 10, "the duplicate does not replace the original");
 
    store.restock("N-200", 7);
    check(store.quantity("N-200") == 10, "restock adds 7 to 3 to give 10");
 
    store.sell("P-100", 4);
    check(store.quantity("P-100") == 6, "sell removes 4 to give 6");
 
    check(throwsError<std::runtime_error>([&] { store.sell("N-200", 99); }),
          "selling more than in stock throws runtime_error");
    check(throwsError<std::out_of_range>([&] { store.sell("Z-999", 1); }),
          "selling an unknown sku throws out_of_range");
    check(throwsError<std::out_of_range>([&] { store.quantity("Z-999"); }),
          "asking for an unknown sku throws out_of_range");
 
    return finishTests("store");
}

Running it prints:

PASS add keeps the item and its quantity
PASS items() lists both items
PASS adding a duplicate sku throws invalid_argument
PASS the duplicate does not replace the original
PASS restock adds 7 to 3 to give 10
PASS sell removes 4 to give 6
PASS selling more than in stock throws runtime_error
PASS selling an unknown sku throws out_of_range
PASS asking for an unknown sku throws out_of_range
all store tests passed

The duplicate-SKU check also reads the original item afterwards. That second check matters. A broken add that replaced the old item but still threw would pass the throw check, so only the quantity check after it catches the bug.

Step 3: the Report class

Printing money from cents

The report must print prices such as 12.50. The helper formatMoney turns a number of cents into text. It uses std::format, a standard function that builds text from a pattern.

inline std::string formatMoney(long long cents) {
    return std::format("{}.{:02}", cents / 100, cents % 100);
}

The division cents / 100 gives the whole part, and the remainder cents % 100 gives the cents. In the pattern, the first {} is replaced by the whole part. The {:02} is replaced by the cents, written with at least two digits and a leading zero if needed. So 1205 becomes 12.05, not 12.5. Without the 02, the output would be wrong for amounts such as 12.05.

Report takes a list of items

The Report class keeps its own copy of the item list. Its constructor is marked explicit, which stops the compiler from turning a plain std::vector<Item> into a Report without being asked. An explicit constructor must be called on purpose.

Worked example 3: sorting and the low-stock line

The report must print the items sorted by name, no matter the order they were added. It must also list the items whose quantity is below a threshold. This example shows both steps on a small list.

std::vector<Item> sorted = items_;
std::ranges::sort(sorted, {}, &Item::name);

The std::ranges::sort function sorts the copy. The third argument, &Item::name, is called a projection. It is a function that is applied to each item before the items are compared, so the sort compares names. The middle argument {} means "use the default comparison", which is the normal less-than order for strings. A projection lets you sort objects by one field without writing a comparison function.

The low-stock names are collected in a loop, sorted, and joined with commas. The full class follows.

#pragma once
 
#include <algorithm>
#include <cstddef>
#include <format>
#include <string>
#include <utility>
#include <vector>
#include "item.hpp"
 
inline std::string formatMoney(long long cents) {
    return std::format("{}.{:02}", cents / 100, cents % 100);
}
 
class Report {
public:
    explicit Report(std::vector<Item> items) : items_{std::move(items)} {}
 
    long long totalValueCents() const {
        long long total = 0;
        for (const Item& item : items_) {
            total += item.totalValueCents();
        }
        return total;
    }
 
    std::vector<std::string> lowStockNames(int threshold) const {
        std::vector<std::string> names;
        for (const Item& item : items_) {
            if (item.quantity() < threshold) {
                names.push_back(item.name());
            }
        }
        std::ranges::sort(names);
        return names;
    }
 
    std::string render(int lowStockThreshold) const {
        std::vector<Item> sorted = items_;
        std::ranges::sort(sorted, {}, &Item::name);
 
        std::string text;
        for (const Item& item : sorted) {
            text += std::format("{:<12} {:>4} x {:>7} = {:>9}\n",
                                item.name(), item.quantity(),
                                formatMoney(item.priceCents()),
                                formatMoney(item.totalValueCents()));
        }
        text += std::format("Total value: {}\n", formatMoney(totalValueCents()));
 
        std::vector<std::string> low = lowStockNames(lowStockThreshold);
        if (low.empty()) {
            text += "Low stock: none\n";
            return text;
        }
        text += std::format("Low stock (under {}): ", lowStockThreshold);
        for (std::size_t i = 0; i < low.size(); ++i) {
            if (i > 0) {
                text += ", ";
            }
            text += low[i];
        }
        text += "\n";
        return text;
    }
 
private:
    std::vector<Item> items_;
};

In the format pattern {:<12}, the text is padded on the right to 12 characters. In {:>4}, the number is padded on the left to 4 characters. These widths make the columns line up when the lines are printed one under another.

The report test

The report test builds its items by hand. It never creates a Store, so a failure can only come from report.hpp or from item.hpp.

#include <string>
#include <vector>
#include "check.hpp"
#include "report.hpp"
 
int main() {
    check(formatMoney(0) == "0.00", "formatMoney(0) is 0.00");
    check(formatMoney(1250) == "12.50", "formatMoney(1250) is 12.50");
    check(formatMoney(1205) == "12.05", "formatMoney(1205) keeps the leading zero in the cents");
 
    std::vector<Item> items{
        Item{"N-200", "Notebook", 4500, 3},
        Item{"P-100", "Blue pen", 1250, 10},
        Item{"S-300", "Stapler", 800, 0},
    };
    Report report{items};
 
    check(report.totalValueCents() == 26000, "total value is 3*45.00 + 10*12.50 + 0*8.00 = 260.00");
    check(report.lowStockNames(5) == std::vector<std::string>{"Notebook", "Stapler"},
          "low stock lists items under 5, sorted by name");
    check(report.lowStockNames(1) == std::vector<std::string>{"Stapler"},
          "threshold 1 lists only the item with no stock");
 
    std::string text = report.render(5);
    check(text.find("Blue pen") < text.find("Notebook"), "render sorts the lines by name");
    check(text.find("Total value: 260.00") != std::string::npos, "render prints the total");
    check(text.find("Low stock (under 5): Notebook, Stapler") != std::string::npos,
          "render prints the low-stock line");
 
    Report empty{std::vector<Item>{}};
    check(empty.totalValueCents() == 0, "an empty report has a total of 0");
    check(empty.render(5).find("Low stock: none") != std::string::npos,
          "an empty report says there is no low stock");
 
    return finishTests("report");
}

Running it prints:

PASS formatMoney(0) is 0.00
PASS formatMoney(1250) is 12.50
PASS formatMoney(1205) keeps the leading zero in the cents
PASS total value is 3*45.00 + 10*12.50 + 0*8.00 = 260.00
PASS low stock lists items under 5, sorted by name
PASS threshold 1 lists only the item with no stock
PASS render sorts the lines by name
PASS render prints the total
PASS render prints the low-stock line
PASS an empty report has a total of 0
PASS an empty report says there is no low stock
all report tests passed

The empty-report case is the edge case most often missed. A loop over an empty list does nothing, so the total is 0 without any special code. The low-stock line still needs its own branch, which is why the test checks it.

Step 4: join the pieces

The last file is main.cpp. It uses all three classes together. It only builds the store, changes it, and prints the report. It does not repeat any of the checks, because those are already done by the three tests.

#include <iostream>
#include "report.hpp"
#include "store.hpp"
 
int main() {
    Store store;
    store.add(Item{"P-100", "Blue pen", 1250, 10});
    store.add(Item{"N-200", "Notebook", 4500, 3});
    store.add(Item{"S-300", "Stapler", 800, 0});
 
    store.sell("P-100", 4);
    store.restock("S-300", 2);
    store.sell("N-200", 3);
 
    Report report{store.items()};
    std::cout << report.render(5);
    return 0;
}

Compile and run it the same way as the tests:

g++ -std=c++20 -Wall -o inventory main.cpp
./inventory

The output is:

Blue pen        6 x   12.50 =     75.00
Notebook        0 x   45.00 =      0.00
Stapler         2 x    8.00 =     16.00
Total value: 91.00
Low stock (under 5): Notebook, Stapler

Check the numbers by hand. Blue pen started with 10 units and sold 4, so 6 remain. Six units at 12.50 is 75.00. Notebook started with 3 and sold 3, so none remain. Stapler started with 0 and received 2, so 2 remain, and 2 units at 8.00 is 16.00. The total is 75.00 plus 0.00 plus 16.00, which is 91.00. With a threshold of 5, Notebook (0 units) and Stapler (2 units) are low. Blue pen has 6 units, which is not under 5, so it is not listed.

Common mistakes with real error messages

A getter without const, used through a const reference

A const reference promises that the code using it will not change the object. If the function called through that reference does not make the same promise, the compiler stops. The sample below has a getter that is missing const.

#include <iostream>
 
class Counter {
public:
    int value() { return value_; }
private:
    int value_ = 3;
};
 
void show(const Counter& c) {
    std::cout << c.value() << "\n";
}
 
int main() {
    Counter c;
    show(c);
}

Compiling it gives:

mistakes/m1.cpp: In function ‘void show(const Counter&)’:
mistakes/m1.cpp:11:25: error: passing ‘const Counter’ as ‘this’ argument discards qualifiers [-fpermissive]
   11 |     std::cout << c.value() << "\n";

The fix is to add const after value(), which makes the promise true. Every getter in Item has const for this reason, so Report can call them through a const Item&.

An exception that nothing catches

An exception that no catch block handles ends the program. The line below asks for a SKU that the store does not have, and the call is not inside a try block.

#include "store.hpp"
 
int main() {
    Store store;
    store.add(Item{"P-100", "Blue pen", 1250, 10});
    store.sell("Z-999", 1);
}

Running it prints:

terminate called after throwing an instance of 'std::out_of_range'
  what():  unknown sku: Z-999

The exit code is 134. The fix is to wrap the call in try and catch, as in worked example 2, or to check the SKU before the call. A program that reads SKUs from a person should always catch the error, because a person can type a code that does not exist.

Members initialized in the wrong order

The member initializer list is written in one order, but the members are set in the order they are declared.

#include "item.hpp"
 
class Pen {
public:
    Pen(int price, int qty) : qty_{qty}, price_{price} {}
private:
    int price_;
    int qty_;
};
 
int main() {
    Pen p{1, 2};
}

The compiler accepts this, but -Wall prints three warnings:

mistakes/m3.cpp: In constructor ‘Pen::Pen(int, int)’:
mistakes/m3.cpp:8:9: warning: ‘Pen::qty_’ will be initialized after [-Wreorder]
mistakes/m3.cpp:7:9: warning:   ‘int Pen::price_’ [-Wreorder]
mistakes/m3.cpp:5:5: warning:   when initialized here [-Wreorder]

The fix is to write the list in the same order as the declarations, so price_{price} comes before qty_{qty}. A warning like this is worth fixing at once, because it often means a member is set from another member that is not ready yet.

Calling a member without parentheses

A getter is a function, so it needs () even when it takes no arguments.

#include "item.hpp"
 
int main() {
    Item pen{"P-100", "Blue pen", 1250, 10};
    pen.quantity = 5;
}

The compiler says:

mistakes/m5.cpp:5:9: error: invalid use of member function ‘int Item::quantity() const’ (did you forget the ‘()’ ?)

The line was meant to change the stock. Quantity cannot be set directly. Use pen.addStock(...) or pen.removeStock(...), which check the rules before the change.

Practice

Try each exercise before you read the answer. Use the test files to check your code, and add a check for each new behaviour.

  1. Add a method void remove(const std::string& sku) to Store. It removes the item with that SKU, and it throws std::out_of_range if the SKU is not in the store. Add one check for each case in store_test.cpp. Hint: std::map::erase takes a key and returns the number of items it removed.

Show the answer

void remove(const std::string& sku) {
    if (items_.erase(sku) == 0) {
        throw std::out_of_range("unknown sku: " + sku);
    }
}

When the SKU exists, erase removes it and returns 1, so the function ends normally. When the SKU does not exist, erase returns 0, so the function throws. A test program that removes N-200 once and then tries again prints items left: 1 and then error: unknown sku: N-200.

  1. In Item::removeStock, change if (amount > quantity_) to if (amount >= quantity_). Run item_test. Does any existing check fail? Write the check that would reveal the change.

Show the answer

No existing check fails. The tests never remove exactly all of the stock. The check that reveals the change is pen.removeStock(11); followed by check(pen.quantity() == 0, "removing all stock leaves 0");, placed after the quantity is 11. With the changed line, the call throws, and a program without a catch stops with this output:

terminate called after throwing an instance of 'std::runtime_error'
  what():  not enough stock for P-100

Testing the edge where the request equals the stock is what catches this kind of off-by-one change.

  1. Add a method int totalUnits() const to Report. It returns the sum of the quantities of all items. Build a report from the three items in report_test.cpp, and check that it returns 13.

Show the answer

int totalUnits() const {
    int total = 0;
    for (const Item& item : items_) {
        total += item.quantity();
    }
    return total;
}

The quantities are 3, 10 and 0. Their sum is 13. A test program that prints the value shows total units: 13.

  1. Build a report from a list in reverse name order: Stapler, Blue pen, Notebook. Check that the first line of render starts with Blue pen. Explain why the input order does not matter.

Show the answer

Report report{std::vector<Item>{
    Item{"S-300", "Stapler", 800, 0},
    Item{"P-100", "Blue pen", 1250, 10},
    Item{"N-200", "Notebook", 4500, 3},
}};
std::cout << report.render(5);

The output is:

Blue pen       10 x   12.50 =    125.00
Notebook        3 x   45.00 =    135.00
Stapler         0 x    8.00 =      0.00
Total value: 260.00
Low stock (under 5): Notebook, Stapler

The input order does not matter because render sorts its own copy of the list by name before it prints anything. The list the caller passes in is not changed.

  1. Predict the output of main.cpp after you add store.sell("S-300", 1); on the line just before the Report line. Write your prediction first, then run the program.

Show the answer

The output is:

Blue pen        6 x   12.50 =     75.00
Notebook        0 x   45.00 =      0.00
Stapler         1 x    8.00 =      8.00
Total value: 83.00
Low stock (under 5): Notebook, Stapler

Stapler goes from 2 units to 1, so its line value is 8.00 and the total drops from 91.00 to 83.00. The low-stock line does not change, because Stapler is still under 5.

Summary

  • A class with private data and checked methods keeps its own rules true, and any bad call throws instead of corrupting the object.
  • Store keeps items in a std::map keyed by SKU, and it returns copies of its items when asked for a list.
  • Report takes a list of items and does not depend on Store, so it can be tested with a list built by hand.
  • Money is stored as whole cents, and std::format prints it with two decimal digits.
  • Test one class at a time. When a test fails, the class that failed is the one to look at first.
Check yourself: Cleanup and project (8 questions) →Practice this topic: coding problems for Object-Oriented Programming →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← RAII and try-with-resources