Skip to content

Module 9 — Lab 9: the library becomes a system

Levels: [L4 Analyze → L6 Build] · Extends: Lab 8 · Time: 6–8 hours


The scenario

The catalogue has grown into a system. There are items, there are members, and there are loans — and the desk has three complaints that are all really one question:

"When a member leaves the university, does their borrowing history go with them?" "The same item appears in two members' lists, and nobody can say which is real." "We cannot print the overdue report because the loan desk and the member register each hold a copy of the other."

Those are ownership questions. The system currently answers them by accident, in whichever direction the headers happen to point. Your task is to answer them deliberately, in a diagram, before you write the code.

The deliverable starts with a drawing. The ownership diagram is handed in with the program, and it is marked: every arrow labelled with the relationship, every multiplicity stated, and every arrow carrying a lifetime sentence. A diagram without lifetimes is a picture of a bug waiting to happen.


What you start from

  • Your Lab 8 items, with the LoanPolicy abstraction.
  • examples/module-09/01-three-kinds-of-relationship/main.cpp — the three relationships with counters that prove them.
  • examples/module-09/04-exposing-a-collection-safely/main.cpp — the access forms, including the one that breaks everything.
  • examples/module-09/05-extension-by-composition/main.cpp — a small interface over a container, and the invariant that needs two doors closed.

Requirements

  1. An ownership diagram, in ASCII or Markdown, with:
  2. every class as a box, its members' forms marked (value, T*, T&, container);
  3. every relationship labelled composition / aggregation / association / dependency;
  4. multiplicities (1, 0..1, 0..*);
  5. one lifetime sentence per non-owning arrow — "the Library must outlive the Loan because …". (Interacts with Module 6: ownership diamonds and inheritance arrows use different notation on purpose.)
  6. A Library that composes its items and members — it creates them, keeps them, and destroys them, demonstrable with counters, exactly as Example 1 does.
  7. A Loan that borrows, holding non-owning references or pointers to one Member and one Item, with the lifetime requirement written in a comment at the declaration. It must not delete either.
  8. A cycle broken by a forward declaration. A member must be able to say which loan it currently holds, and a loan must know its member. Deliver both headers, the source file that includes them, and a comment naming what the forward declaration does not allow.
  9. An invariant that spans a collection: a member may hold at most N loans, and the library must be able to answer "how many active loans does this member have?" without scanning every loan in the system. Every way to change the collection must maintain it — list the ways in a comment.
  10. No door to the internals: no public member returns a non-const reference to a container, and no caller can add or remove an item without going through the library. Demonstrate that the attack from Example 4 does not compile.
  11. At least one deliberate refactor, reported: something you first wrote as inheritance or as a plain pointer member, and changed after drawing the diagram. Say what the diagram made visible.
  12. It must build with no warnings using the course's compile command: g++ -std=c++17 -Wall -Wextra -Werror.

What to deliver

  • A program that: adds items and members to the library; lends an item; refuses a loan beyond the limit; reports active loans per member without scanning; returns an item; prints the library's state; and prints counters proving that the items and members died with the library while nothing borrowed was deleted.
  • The ownership diagram, with lifetimes.
  • The refactor note (three sentences): what you changed, what the diagram showed, what it cost.
  • The interface audit: one line per public operation of Library, saying what it can change and which invariant it must maintain.

Self-assessment checklist

It builds and runs

  • [ ] Compiles with g++ -std=c++17 -Wall -Wextra -Werror with no warnings
  • [ ] A loan can be created, reported and returned, and the member's count follows
  • [ ] The loan limit is enforced, and the refusal is visible to the caller
  • [ ] Items, members and loans are all destroyed exactly once, and the counters agree
  • [ ] Nothing borrowed is deleted by the borrower

It is correct

  • [ ] Every non-owning member has a written lifetime requirement
  • [ ] The cycle is broken, and the program includes the headers in the documented order
  • [ ] The active-loan count is maintained by every operation that can change it — the list is in a comment, and it is complete
  • [ ] Boundary cases work: a member with no loans, a loan returned twice, an item already on loan, a member at the limit
  • [ ] The failure path is exercised: a loan refused by the limit, and an item requested while already out

It is designed

  • [ ] I can say, for every member, whether it owns, borrows or merely uses
  • [ ] Every member's form (value / pointer / reference / container) is justified in a comment
  • [ ] No public operation hands out a mutable reference to a container
  • [ ] The ownership diagram's lifetimes match the constructors and destructors in the code
  • [ ] At least one design decision changed because of the diagram

It is verified

  • [ ] I ran the counters rather than reasoning that destruction must be correct
  • [ ] I attempted the Example 4 attack against my interface, and recorded that it does not compile
  • [ ] I predicted the destruction order before running the program
  • [ ] The refactor note names what the diagram made visible, not just what changed

Common ways this lab goes wrong

Symptom Cause What to do
Items are deleted twice, or a crash at exit The Loan deletes the item it borrowed A borrower never deletes. Delete in the owner's destructor, or nowhere
The member's loan count drifts A second door changes the loans: a friend, an accessor, or an inherited container List every mutation path and close the ones the class cannot police
error: 'Loan' does not name a type in a header that includes the other header The include cycle, with the guard already fired Forward-declare in the header, include in the source file
error: field 'loan_' has incomplete type 'Loan' A value member declared from a forward declaration Include the header, or hold a pointer instead
error: use of deleted function 'Member& Member::operator=(…)' A reference member for the current loan Decide what assignment means: reseat nothing and copy the rest, or keep the class non-assignable deliberately
The overdue report prints nothing The report holds a snapshot taken at construction, not the library Hold the thing, or name the member for the copy it is
The diagram has arrows but no lifetimes It was drawn as a picture rather than as a design Add one sentence per non-owning arrow: what keeps it alive
The refactor note says "composition is better" The artefact was written as a slogan Name the specific defect the diagram exposed, and the line that changed

Reference solution

There is no published file for this lab, on purpose: it is a system you design, and the decisions are the deliverable. What follows is the part the module's examples do not show together — a three-way ownership arrangement with a broken cycle and a collection invariant that survives every mutation path.

// The parts to compare your version against.

// ── member.h ────────────────────────────────────────────────────────────────
#pragma once
#include <string>
#include <vector>

class Loan;                     // forward declaration: enough for a pointer member

class Member {
public:
    explicit Member(std::string name) : name_(std::move(name)) {}

    const std::string& name() const { return name_; }

    // The invariant lives here, and only here: activeLoanCount() is never
    // recomputed, so every operation that changes the vector must maintain it.
    std::size_t activeLoanCount() const { return activeLoanCount_; }
    std::size_t loanLimit() const { return loanLimit_; }
    bool mayBorrowMore() const { return activeLoanCount_ < loanLimit_; }

    void noteLoan() { ++activeLoanCount_; }         // called by the Library only
    void noteReturn() { --activeLoanCount_; }       // …and these two are the door

    // The current loan, borrowed and never owned. Live only while the Library
    // keeps it: the Library owns every Loan it creates and destroys them at exit.
    const Loan* currentLoan() const { return currentLoan_; }
    void setCurrentLoan(const Loan* loan) { currentLoan_ = loan; }

private:
    std::string name_;
    std::size_t loanLimit_ = 3;
    std::size_t activeLoanCount_ = 0;
    const Loan* currentLoan_ = nullptr;            // non-owning: never deleted here
};

// ── loan.h ──────────────────────────────────────────────────────────────────
#pragma once
#include <string>

class Item;                     // both members need only a declaration
class Member;

class Loan {
public:
    Loan(std::string id, Member& member, Item& item)
        : id_(std::move(id)), member_(member), item_(item) {}
    // Member& and Item&: neither can be null, neither can be reseated, and this
    // class deletes neither. Both must outlive this Loan — the Library guarantees
    // it, because the Library owns the members, the items and the loans.

    const std::string& id() const { return id_; }
    Member& member() const { return member_; }
    Item& item() const { return item_; }

private:
    std::string id_;
    Member& member_;
    Item& item_;
};

And the library, where the invariant's door is closed by making the operations the only way in and out:

class Library {
public:
    bool addItem(std::string id, std::string title);      // composes: creates and owns
    bool addMember(std::string name);                     // composes: creates and owns

    // The only two ways a loan appears or disappears, and therefore the only two
    // places the member's active count changes.
    bool lend(std::string loanId, Member& member, Item& item);
    bool giveBack(const std::string& loanId);

    std::size_t activeLoansFor(const Member& member) const;   // O(1): the member knows

    // Read-only views. No container reference is ever returned.
    std::size_t itemCount() const { return items_.size(); }
    const Item& itemAt(std::size_t index) const { return items_.at(index); }
    void forEachLoan(const std::function<void(const Loan&)>& visit) const;

private:
    std::vector<Item> items_;          // owned: destroyed with the library
    std::vector<Member> members_;      // owned: destroyed with the library
    std::vector<Loan> loans_;          // owned, and each holds borrows into the two above
};

Four things to look for when comparing with your version:

  • Whether the lifetimes still hold when the library dies. loans_ is declared last, so it is destroyed first — before the members and items it refers to. If your declarations put loans_ before members_, the loans are destroyed after their referred-to objects, which happens to be harmless here (a destructor that only releases references) and would be a real bug the moment a Loan destructor used member_. Say which order you chose and why.
  • Whether the active count can drift. In the sketch, noteLoan() and noteReturn() are public, so a caller can desynchronise the count in two lines — exactly the defect Mistake 5 describes. The safer version makes them private and declares Library a friend, or returns the count from the library by scanning. Whichever you chose, the audit must say which operations can change the count.
  • Whether the cycle is genuinely broken. loan.h includes neither member.h nor item.h; it declares them. That is only possible because both members are references. If yours is a value member, the include is required in the header and the cycle returns — which is the reason the member form and the include structure are one decision, not two.
  • Whether the borrowing member's identity is stable. This is the one deliberate trap in the lab. std::vector reallocates when it grows, and reallocation invalidates every pointer and reference to its elements — the elements are moved, and a reference cannot be reseated, so a Loan& recorded before the move keeps pointing at freed storage. It is easy to verify in five lines: remember const Member* borrowed = &members[0];, push another element, and compare borrowed == &members[0] afterwards. On this course's toolchain that prints false. So if your Loan stores references into a std::vector that can grow, adding a member can dangle every existing loan. Choose deliberately and say so: reserve enough capacity, use a std::deque (no reallocation), index into the container instead of referring to elements, or own members through std::unique_ptr (Module 11) so their addresses never change. Finding this without being told is the point of the lab; either way, the refactor note must say which you chose.

Next: Challenge problems — three open problems, including an audit of a design you were handed and an evaluation of "composition over inheritance". · All modules