Low-Level Design (Object-Oriented Design) Module 3 – Class relationships and UML
Class relationships in UML and code
Association, aggregation, composition, dependency, realization and generalization: their UML lines, multiplicities and what ownership means in code.
What you will learn
- Distinguish association, aggregation, composition, dependency, realization and generalization
- Draw each relationship with correct UML notation, multiplicity and navigability
- Map ownership to C++ smart pointers, Rust ownership and Go struct fields
- Maintain a two-way association with one function and a test of its invariant
Before you start
What you’ll learn · Before you start
What you will learn
- Distinguish association, aggregation, composition, dependency, realization and generalization
- Draw each relationship with correct UML notation, multiplicity and navigability
- Map ownership to C++ smart pointers, Rust ownership and Go struct fields
- Maintain a two-way association with one function and a test of its invariant
Before you start
On this page
Two classes in a design can be related in six ways. Generalization: every A is a kind of B. Realization: A implements an interface B. Dependency: A uses B only while a call runs. Association: A keeps a reference to B. Aggregation: A is a whole that groups Bs which also live on their own. Composition: A owns its Bs, and they go when A goes. UML gives each one its own line, so a reader can tell them apart at a glance, and adds a multiplicity at each end of an association to say how many objects take part.
In code, most of the difference comes down to two questions: does A keep the reference after the call, and who decides when B is deleted? This lesson answers both for each relationship, draws them with multiplicity and navigability, runs the deletion test in Python, C++, Rust and Go, and keeps a two-way link correct with one function.
Six relationships, one line each
The relationships fall into two groups. Generalization and realization relate the classes themselves: no link exists between two objects, because a savings account simply is an account. The other four describe objects that hold or use other objects while the program runs.
| Relationship | A and B in one sentence | Drawn in UML as | Who decides when B is deleted |
|---|---|---|---|
| Generalization | A SavingsAccount is an Account |
Solid line, hollow triangle at the general class | Nobody: one object is both |
| Realization | HourlyFee implements FeePolicy |
Dashed line, hollow triangle at the interface | Nobody: one object is both |
| Dependency | ReceiptPrinter.print() uses the Formatter it is given |
Dashed arrow from A to B | Whoever passed B in; A never stores it |
| Association | An OrderLine refers to its Product |
Solid line, arrowhead at an end you can navigate to | Someone else, here the catalogue |
| Aggregation | A Team groups Players |
Hollow diamond at the whole | Someone else, here the club |
| Composition | An Order owns its OrderLines |
Filled diamond at the whole | A: the parts go with it |
When you are unsure, ask the questions in a fixed order. The first two separate the class relationships, the third separates temporary use from a stored link, and the last two sort stored links by ownership:
Five questions that pick the line between two classes
Text description of the diagram
A top-to-bottom flow of five questions about two classes, A and B. Each "yes" ends at a relationship; each "no" goes on to the next question.
- Is every A also a B, keeping all of B's promises? Yes: generalization, a solid line with a hollow triangle at B.
- Does A promise B's operations without inheriting B's code? Yes: realization, a dashed line with a hollow triangle at B.
- Does A keep a reference to B after the call that gave it one? No: dependency, a dashed arrow from A to B. Yes: go on to question 4.
- When A is deleted, must its Bs go too, with no other owner? Yes: composition, a filled diamond at A.
- Is A a whole that groups Bs which also live on their own? Yes: aggregation, a hollow diamond at A. No: association, a solid line with an arrowhead at B when only A navigates to B.
The arrows of a dependency, a generalization and a realization all point from the class that depends on the other (the client, the subclass or the implementation) to the class it depends on. The diamond is the exception: it sits at the whole, the end that owns or groups.
The deletion test
Association, aggregation and composition all mean that A holds Bs, so wording alone rarely decides between them. A question about deletion does: if the parking lot is deleted, are its spots deleted too? Spots belong to one lot and make no sense without it, so that is composition. If a team is disbanded, do its players disappear? No, they stay in the club and may join another team, so that is aggregation.
UML 2.5.1 is strict about composition and loose about aggregation. A composite object, in its words, “has responsibility for the existence and storage of the composed objects”: each part sits inside one composite at most, at any moment, and destroying the composite destroys its parts as well. For shared aggregation, the hollow diamond, the specification says that its precise meaning “varies by application area and modeler”. So whenever you draw a hollow diamond, say in words what it means for your design, or draw a plain association and state the rule beside it.
The test can be run. CPython frees most objects as soon as the last reference to them disappears, and
weakref.finalize calls a function at that moment, so the program below deletes an order, disbands a team and
reports what was freed:
"""The deletion test, run: delete the whole and see which parts go with it.
An Order creates its OrderLines (composition), a Team lists Players that the
club's roster keeps (aggregation), and each line refers to a Product of the
catalogue (association). weakref.finalize reports when CPython frees an object.
"""
import weakref
freed = []
def watch(obj, label):
"""Records the label when obj is freed."""
weakref.finalize(obj, freed.append, label)
def report(event):
print(f"{event}; freed: {', '.join(sorted(freed)) or 'nothing'}")
freed.clear()
class Product:
def __init__(self, sku, name):
self.sku = sku
self.name = name
class OrderLine:
def __init__(self, product, qty):
self.product = product # association: the catalogue keeps the product
self.qty = qty
class Order:
"""Composition: the order makes its lines, and only the order holds them."""
def __init__(self, number):
self.number = number
self._lines = []
def add(self, product, qty):
line = OrderLine(product, qty)
watch(line, f"line {product.name} x{qty}")
self._lines.append(line)
def quantities(self):
"""Plain values: callers learn what is ordered but hold no line."""
return [(line.product.sku, line.qty) for line in self._lines]
def lines(self):
"""A tuple stops callers adding lines, yet it hands out the lines themselves."""
return tuple(self._lines)
class Player:
def __init__(self, name):
self.name = name
class Team:
"""Aggregation: the team groups players who exist without it."""
def __init__(self, name, players):
self.name = name
self.players = list(players)
catalogue = {"P1": Product("P1", "tea"), "P2": Product("P2", "rice")}
order = Order(101)
order.add(catalogue["P1"], 2)
order.add(catalogue["P2"], 1)
print("order 101:", order.quantities())
del order
report("deleted order 101")
print("the catalogue still has:", ", ".join(sorted(p.name for p in catalogue.values())))
roster = [Player(name) for name in ("Asha", "Ravi", "Meera")]
for player in roster:
watch(player, f"player {player.name}")
team = Team("Blue", roster[:2])
del team
report("disbanded team Blue")
print("the roster still has:", ", ".join(p.name for p in roster))
order = Order(102)
order.add(catalogue["P1"], 3)
kept = order.lines()[0] # a caller holds on to a line
del order
report("deleted order 102")
print(f"the line it handed out is still alive: {kept.product.name} x{kept.qty}") Output
order 101: [('P1', 2), ('P2', 1)]
deleted order 101; freed: line rice x1, line tea x2
the catalogue still has: rice, tea
disbanded team Blue; freed: nothing
the roster still has: Asha, Ravi, Meera
deleted order 102; freed: nothing
the line it handed out is still alive: tea x3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ownership.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Deleting order 101 freed both of its lines, while the products stayed in the catalogue: the lines were composed,
the products only associated. Disbanding the team freed no player, because the roster still held them all. The last
case is the one interviews probe. lines() returns a tuple, so no caller can add a line behind the order’s back, but
the tuple holds the line objects themselves. A caller kept one, and that line outlived its order.
In a garbage-collected language, composition is therefore a rule about references rather than something the
language enforces: only the whole keeps its parts. quantities() keeps that rule by handing out plain values. When
callers really need part objects, give them immutable copies or identifiers.
Multiplicity and navigability
A multiplicity at an association end says how many objects at that end are linked to one object at the other end.
It is written lower..upper: 1 is short for 1..1, 0..1 means optional, and * means “any number”, the same
as 0..*. The order model reads in both directions:
Order 1andOrderLine 1..*: every order has one or more lines, and every line belongs to exactly one order.OrderLine 0..*andProduct 1: every line refers to exactly one product, and a product appears in any number of lines.
Multiplicity is part of the contract, so the code should enforce what the diagram says. 1..* means that an empty
order cannot exist, which forces a choice: create an order with its first line, or allow 0..* while a cart is being
filled and check the rule when the order is placed. Leaving an end unlabelled leaves the reader guessing, so label
both ends of every association you draw.
Navigability says which object can reach the other. In UML 2.5.1 an open arrowhead marks an end that can be
navigated to, and a small x marks one that cannot; an end with neither mark says nothing either way. In code, navigability is simply
who holds the reference. An OrderLine holds its Product, but a product keeps no list of the lines that mention
it, so the arrowhead sits at Product only.
Composition, aggregation and association, with a multiplicity at each end
Text description of the diagram
Two groups of classes side by side.
The shop: Catalogue has the operation find(sku) returning a Product. Product has the attributes sku and name. Order has the attribute number and the operations add(product, qty) and quantities(). OrderLine has the attribute qty. - Catalogue to Product: composition, a filled diamond at Catalogue; one catalogue owns zero or more products (1 at the catalogue, 0..* at the product). - Order to OrderLine: composition, a filled diamond at Order; one order owns one or more lines (1 at the order, 1..* at the line). - OrderLine to Product: an association with an arrowhead at Product, so a line can reach its product but a product does not know its lines; each line refers to exactly 1 product, and a product appears in 0..* lines.
The club: Club, Team and Player each have a name. - Club to Team and Club to Player: composition, filled diamonds at Club; one club owns zero or more teams and zero or more players. - Team to Player: aggregation, a hollow diamond at Team; a team groups zero or more players, and a player may be in zero or more teams. Disbanding a team leaves its players in the club.
Prefer one direction. Each direction you store is a reference that must stay correct whenever links are created, changed or deleted, so a two-way association doubles the invariants your code must maintain. Store the second direction only when an operation genuinely needs to navigate that way and a lookup elsewhere would be too slow or too awkward.
Two-way links need one writer
Sometimes both directions earn their place: a student’s timetable needs the student’s courses, and a course roster
needs its students. Then one function must create the link and one must remove it, and nothing else may write either
end. The program below states the association’s invariant as check_links() (every link is seen from both ends, and
no course is over capacity) and checks it after every step of a seeded random sequence of 2,000 enrolments and
drops:
"""A two-way Student-Course association, kept in step by one pair of functions.
check_links() states the association's invariant: every link is seen from both
ends, and no course is over its capacity. A seeded random run of operations checks
it after each step, first with drop() and then with a version that forgets one end.
"""
import random
class Course:
def __init__(self, code, capacity):
self.code = code
self.capacity = capacity
self._students = []
def students(self):
return tuple(self._students)
class Student:
def __init__(self, name):
self.name = name
self._courses = []
def courses(self):
return tuple(self._courses)
def enrol(student, course):
"""The only way to make a link: both ends change, or neither does."""
if course in student._courses or len(course._students) >= course.capacity:
return False
student._courses.append(course)
course._students.append(student)
return True
def drop(student, course):
if course not in student._courses:
return False
student._courses.remove(course)
course._students.remove(student)
return True
def drop_one_end(student, course):
"""The bug: only the student's end is updated."""
if course not in student._courses:
return False
student._courses.remove(course)
return True
def check_links(students, courses):
"""The first broken rule, or None when the association is consistent."""
for s in students:
for c in s.courses():
if s not in c.students():
return f"{s.name} lists {c.code}, but {c.code} does not list {s.name}"
for c in courses:
for s in c.students():
if c not in s.courses():
return f"{c.code} lists {s.name}, but {s.name} does not list {c.code}"
if len(c.students()) > c.capacity:
return f"{c.code} has {len(c.students())} students for {c.capacity} places"
return None
def random_run(drop_fn, steps, seed=7):
rng = random.Random(seed)
students = [Student(name) for name in ("asha", "ravi", "meera", "john")]
courses = [Course("DBMS", 2), Course("OS", 3), Course("CN", 2)]
for step in range(1, steps + 1):
s, c = rng.choice(students), rng.choice(courses)
action = rng.choice(("enrol", "drop"))
(enrol if action == "enrol" else drop_fn)(s, c)
problem = check_links(students, courses)
if problem:
return f"step {step} ({action} {s.name}, {c.code}): {problem}"
sizes = ", ".join(f"{c.code} {len(c.students())}/{c.capacity}" for c in courses)
return f"{steps} steps, consistent after every one (now {sizes})"
print("with drop(): ", random_run(drop, 2000))
print("with drop_one_end():", random_run(drop_one_end, 2000)) Output
with drop(): 2000 steps, consistent after every one (now DBMS 1/2, OS 2/3, CN 2/2) with drop_one_end(): step 6 (drop ravi, DBMS): DBMS lists ravi, but ravi does not list DBMS
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 enrolment.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With drop(), the invariant held after every one of the 2,000 steps. drop_one_end() updates only the student, and
the same seeded sequence caught it at step 6, with a message that names both ends. A random sequence finds the
half-updated case quickly because it tries orders of operations that nobody would think to write as separate tests,
and a fixed seed makes every failure repeatable.
An alternative to storing both ends
If only one operation navigates the second way, store one direction and answer the other with a query, such as a registry that finds the students of a course. Nothing can then drift, at the price of a slower lookup.
Ownership in C++, Rust and Go
Each language expresses ownership with different tools, and they do not all check it for you:
| Language | Composition (owns its parts) | Aggregation or association (refers) | A link that must not keep B alive |
|---|---|---|---|
| Python, Go | A field the whole never hands out | A field holding an object made elsewhere | weakref in Python, weak.Pointer in Go |
| C++ | A member by value, or std::unique_ptr<B> |
A raw pointer or reference that owns nothing | std::weak_ptr<B> to an object owned by std::shared_ptr |
| Rust | An owned field such as Vec<B> |
An id or index into the collection that owns the Bs | std::rc::Weak<B> |
In C++, a std::unique_ptr owns the object it points to and deletes it when the pointer goes out of scope, so a
std::vector<std::unique_ptr<OrderLine>> member makes the order the owner of its lines. The C++ Core Guidelines
reserve raw pointers for links that own nothing (rule R.3), which is how the team below points at the club’s
players. A std::weak_ptr refers to an object shared through std::shared_ptr without keeping it alive, and
expired() reports when it is gone.
Rust builds ownership into the language: every value has exactly one owner, and the value is dropped when its owner
goes out of scope. An order that holds a Vec<OrderLine> owns its lines. Long-lived links to things owned elsewhere
are usually ids or indexes rather than references, and Rc::downgrade gives a Weak that returns None from
upgrade() once the value has been dropped.
Go is garbage collected and its types carry no notion of ownership. A slice field holds only a pointer to an
array, its length and its capacity, so copying a struct copies that header and both structs then share one array of
lines. Write the ownership rule in a comment, and copy deliberately with slices.Clone, which makes a shallow copy.
The session builds and runs each program; the C++ one is built with AddressSanitizer and UndefinedBehaviorSanitizer switched on, and the second Rust file is meant to fail:
session · compiled/ownership_session.py
from session import run, versions
# ownership.cpp is built with AddressSanitizer and UndefinedBehaviorSanitizer on;
# keep_line.rs is expected not to compile, and its error is part of the lesson.
versions("clang++ --version", "rustc --version", "go version")
run(
"""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined ownership.cpp -o ownership && ./ownership
rustc --edition 2024 ownership.rs -o ownership_rs && ./ownership_rs
rustc --edition 2024 keep_line.rs -o keep_line
go run ownership.go
""",
may_fail=("rustc --edition 2024 keep_line.rs -o keep_line",),
) Output
# Apple clang version 21.0.0 (clang-2100.3.34.2)
# rustc 1.99.0 (b940084d7)
# go version go1.27.1 darwin/arm64
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined ownership.cpp -o ownership && ./ownership
leaving the scope of order 101:
freeing order 101 and its 2 lines
freed line rice x1
freed line tea x2
disbanding team Blue:
freed team Blue
the roster still has 3 players
a weak link to a shared team:
freed team Green
link.expired() after the last owner let go: true
end of main:
freed player Meera
freed player Ravi
freed player Asha
$ rustc --edition 2024 ownership.rs -o ownership_rs && ./ownership_rs
dropping order 101:
freeing order 101 and its 2 lines
freed line tea x2
freed line rice x1
disbanding team Blue:
freed team Blue (members: 2)
the roster still has 3 players
a weak link to a shared team:
freed team Green (members: 1)
link.upgrade() after the last owner let go: None
end of main:
freed player Asha
freed player Ravi
freed player Meera
$ rustc --edition 2024 keep_line.rs -o keep_line
error[E0505]: cannot move out of `order` because it is borrowed
--> keep_line.rs:13:10
|
11 | let order = Order { lines: vec![OrderLine { item: "tea" }] };
| ----- binding `order` declared here
12 | let first = &order.lines[0];
| ----------- borrow of `order.lines` occurs here
13 | drop(order);
| ^^^^^ move out of `order` occurs here
14 | println!("still holding {}", first.item);
| ---------- borrow later used here
|
note: if `OrderLine` implemented `Clone`, you could clone the value
--> keep_line.rs:2:1
|
2 | struct OrderLine {
| ^^^^^^^^^^^^^^^^ consider implementing `Clone` for this type
...
12 | let first = &order.lines[0];
| ----------- you could clone this value
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0505`.
[exit status 1]
$ go run ownership.go
order 101 tea after changing order 102: x9
order 101 tea after changing order 103: x9
team gone: true, roster still has 3 players
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ownership_session.py
compiled/ownership.cpp
// Ownership in C++23. A std::unique_ptr member owns its object (composition), a raw
// pointer only points at an object someone else owns (aggregation), and a
// std::weak_ptr refers to a shared object without keeping it alive.
#include <memory>
#include <print>
#include <string>
#include <utility>
#include <vector>
struct OrderLine {
std::string item;
int qty;
OrderLine(std::string i, int q) : item(std::move(i)), qty(q) {}
~OrderLine() { std::println(" freed line {} x{}", item, qty); }
};
class Order {
public:
explicit Order(int number) : number_(number) {}
~Order() { std::println(" freeing order {} and its {} lines", number_, lines_.size()); }
void add(std::string item, int qty) { lines_.push_back(std::make_unique<OrderLine>(std::move(item), qty)); }
private:
int number_;
std::vector<std::unique_ptr<OrderLine>> lines_; // the order owns its lines
};
struct Player {
std::string name;
explicit Player(std::string n) : name(std::move(n)) {}
~Player() { std::println(" freed player {}", name); }
};
struct Team {
std::string name;
std::vector<const Player*> members; // the roster owns the players; these only point
Team(std::string n, std::vector<const Player*> m) : name(std::move(n)), members(std::move(m)) {}
~Team() { std::println(" freed team {}", name); }
};
int main() {
std::println("leaving the scope of order 101:");
{
Order order{101};
order.add("tea", 2);
order.add("rice", 1);
}
std::vector<std::unique_ptr<Player>> roster; // the club owns its players
for (const char* name : {"Asha", "Ravi", "Meera"}) roster.push_back(std::make_unique<Player>(name));
std::println("disbanding team Blue:");
{
Team blue{"Blue", {roster[0].get(), roster[1].get()}};
}
std::println("the roster still has {} players", roster.size());
std::println("a weak link to a shared team:");
auto green = std::make_shared<Team>("Green", std::vector<const Player*>{roster[2].get()});
std::weak_ptr<Team> link = green;
green.reset();
std::println(" link.expired() after the last owner let go: {}", link.expired());
std::println("end of main:");
} compiled/ownership.rs
// Ownership in Rust. A Vec field owns its elements (composition), an index or id
// refers to a value that another collection owns (aggregation), and Weak refers to a
// shared value without keeping it alive.
use std::rc::{Rc, Weak};
struct OrderLine {
item: &'static str,
qty: u32,
}
impl Drop for OrderLine {
fn drop(&mut self) {
println!(" freed line {} x{}", self.item, self.qty);
}
}
struct Order {
number: u32,
lines: Vec<OrderLine>, // the order owns its lines
}
impl Drop for Order {
fn drop(&mut self) {
println!(" freeing order {} and its {} lines", self.number, self.lines.len());
}
}
struct Player {
name: &'static str,
}
impl Drop for Player {
fn drop(&mut self) {
println!(" freed player {}", self.name);
}
}
struct Team {
name: &'static str,
members: Vec<usize>, // positions in the roster, which owns the players
}
impl Drop for Team {
fn drop(&mut self) {
println!(" freed team {} (members: {})", self.name, self.members.len());
}
}
fn main() {
println!("dropping order 101:");
let order = Order {
number: 101,
lines: vec![OrderLine { item: "tea", qty: 2 }, OrderLine { item: "rice", qty: 1 }],
};
drop(order);
let roster = vec![Player { name: "Asha" }, Player { name: "Ravi" }, Player { name: "Meera" }];
println!("disbanding team Blue:");
let blue = Team { name: "Blue", members: vec![0, 1] };
drop(blue);
println!("the roster still has {} players", roster.len());
println!("a weak link to a shared team:");
let green = Rc::new(Team { name: "Green", members: vec![2] });
let link: Weak<Team> = Rc::downgrade(&green);
drop(green);
println!(" link.upgrade() after the last owner let go: {:?}", link.upgrade().map(|t| t.name));
println!("end of main:");
} compiled/keep_line.rs
// A reference into an order may not outlive the order: the compiler refuses this.
struct OrderLine {
item: &'static str,
}
struct Order {
lines: Vec<OrderLine>,
}
fn main() {
let order = Order { lines: vec![OrderLine { item: "tea" }] };
let first = &order.lines[0];
drop(order);
println!("still holding {}", first.item);
} compiled/ownership.go
// Go's types say nothing about ownership. Copying a struct copies its slice header,
// so the copy and the original share one array of lines until someone clones it.
package main
import (
"fmt"
"slices"
)
type OrderLine struct {
Item string
Qty int
}
// Order owns its lines: a rule the code keeps by convention, not one the compiler checks.
type Order struct {
Number int
Lines []OrderLine
}
type Player struct{ Name string }
// Team points at players that the club's roster keeps.
type Team struct {
Name string
Members []*Player
}
func main() {
a := Order{Number: 101, Lines: []OrderLine{{"tea", 2}, {"rice", 1}}}
b := a // a copy of the struct, sharing a's lines
b.Number = 102
b.Lines[0].Qty = 9
fmt.Printf("order %d tea after changing order %d: x%d\n", a.Number, b.Number, a.Lines[0].Qty)
c := a
c.Number = 103
c.Lines = slices.Clone(a.Lines) // its own array of lines
c.Lines[0].Qty = 5
fmt.Printf("order %d tea after changing order %d: x%d\n", a.Number, c.Number, a.Lines[0].Qty)
roster := []*Player{{"Asha"}, {"Ravi"}, {"Meera"}}
blue := &Team{Name: "Blue", Members: roster[:2]}
blue = nil // the team is gone; the players are still reachable from the roster
fmt.Printf("team gone: %v, roster still has %d players\n", blue == nil, len(roster))
} compiled/session.py
"""Replays commands the way a terminal shows them: "$ " and the command, then all it printed.
Standard output and standard error are merged in the order they were written. A command
that exits with a status other than 0 is followed by "[exit status N]"; unless the script
lists it in may_fail, the session then exits with status 1, so a run that could not build
a program is never recorded as if it had worked.
The compilers are the pinned ones: Go 1.27.1 from the mise installation next to the
Python that runs this file, Rust 1.99.0 from its rustup toolchain and the system's Apple
clang. They live in the home folder, which learn:verify's sandbox does not let a run read,
so the sessions of this folder are recorded with LEARN_VERIFY_NETWORK=allow. They read
only this folder and the compilers, write only inside the run's own folder and temporary
home, and use no network.
"""
import os
import pwd
import re
import subprocess
import sys
from pathlib import Path
# <mise>/installs/python/3.14.8/bin/python3 -> <mise>/installs
INSTALLS = Path(sys.executable).resolve().parents[3]
HOME = Path(pwd.getpwuid(os.getuid()).pw_dir)
COMPILERS = [
INSTALLS / "go" / "1.27.1" / "bin",
HOME / ".rustup" / "toolchains" / "1.99.0-aarch64-apple-darwin" / "bin",
]
# rustc prints its build date in its version line; pages show no dates.
BUILD_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}(?=\))")
def _env():
env = dict(os.environ)
env["PATH"] = os.pathsep.join([*map(str, COMPILERS), env.get("PATH", "")])
env["GOTOOLCHAIN"] = "local" # never fetch another Go
env["GOTELEMETRY"] = "off"
env["GOFLAGS"] = "-trimpath"
return env
def _shell(command):
return subprocess.run(command, shell=True, env=_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def versions(*commands):
"""Prints the first line of each version command, as a comment."""
for command in commands:
done = _shell(command)
lines = done.stdout.splitlines()
if done.returncode != 0 or not lines:
sys.exit(f"{command} did not run: a pinned compiler is missing on this computer")
print("#", BUILD_DATE.sub("", lines[0]))
def run(script, may_fail=()):
"""Runs each line of the script as one shell command."""
unexpected = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
done = _shell(command)
print(done.stdout, end="")
if done.returncode != 0:
print(f"[exit status {done.returncode}]")
if command not in may_fail:
unexpected += 1
if unexpected:
sys.exit(1) In C++ and Rust, the order’s lines are freed the moment the order is, and disbanding a team frees the team alone. The
two languages free the lines in different orders here, a reminder not to depend on the order in which parts are
freed. The weak links report the shared team as gone, with expired() returning true and upgrade() returning
None.
keep_line.rs keeps a reference to a line and then drops the order. Rust refuses to compile it, with error E0505:
the order cannot be moved while it is borrowed. The C++ version of the same mistake would compile and leave a
dangling pointer, which AddressSanitizer reports as a use after free only if a test happens to run that path. In Go,
changing a line of order 102 changed order 101 as well, until slices.Clone gave order 103 its own lines.
Practise
Implement a two-way doctor-patient link, 0..1 doctor for each patient and 0..* patients for each doctor, so that
both ends stay consistent. The sample tests check each rule, then run seeded random sequences of assignments and
discharges and check both ends after every call.
Exercise · Medium · Python
Keep both ends of a doctor-patient link in step
A clinic's model links doctors and patients in both directions: a patient has at most one doctor (0..1), and a doctor has any number of patients (0..*). Each end keeps its own reference, Patient.doctor and Doctor.patients(), so the two ends can drift apart unless one place changes both.
Finish assign(doctor, patient) and discharge(patient) in care.py so that, after any sequence of calls:
- If a doctor lists a patient, that patient's
doctoris that doctor, and no doctor lists a patient twice. - If a patient has a doctor, that doctor lists the patient.
- Assigning a patient who already has another doctor moves them: the old doctor no longer lists them, and the new doctor lists them last.
- Assigning a patient to the doctor they already have changes nothing, and discharging a patient without a doctor does nothing.
Doctor.patients()returns the patients in the order they were assigned, as a tuple, so callers cannot change the list behind it.
The sample tests check each rule on its own, then run seeded random sequences of 300 assignments and discharges across three doctors and eight patients, checking rules 1 and 2 after every call. The starter code updates only the patient's end, so most of them fail at first.
Starter code · care.py
"""Doctors and patients: a two-way association, 0..1 doctor per patient, 0..* patients per doctor."""
class Doctor:
def __init__(self, name):
self.name = name
self._patients = []
def patients(self):
"""The doctor's patients, in the order they were assigned."""
return tuple(self._patients)
class Patient:
def __init__(self, name):
self.name = name
self._doctor = None
@property
def doctor(self):
"""The patient's doctor, or None."""
return self._doctor
def assign(doctor, patient):
"""Makes doctor the patient's doctor, moving the patient from any other doctor."""
patient._doctor = doctor # only one end: finish this
def discharge(patient):
"""Ends the patient's assignment, if there is one."""
patient._doctor = None # only one end: finish this The sample tests · test_care.py
import random
from care import Doctor, Patient, assign, discharge
def check_links(doctors, patients):
for d in doctors:
listed = d.patients()
assert len({id(p) for p in listed}) == len(listed), f"{d.name} lists a patient twice"
for p in listed:
assert p.doctor is d, f"{d.name} lists {p.name}, but {p.name}'s doctor is {getattr(p.doctor, 'name', None)}"
for p in patients:
if p.doctor is not None:
assert any(x is p for x in p.doctor.patients()), f"{p.name}'s doctor is {p.doctor.name}, who does not list {p.name}"
def test_assign_links_both_ends():
"""rules 1 and 2: assign() links the patient and the doctor"""
rao, asha = Doctor("Dr Rao"), Patient("Asha")
assign(rao, asha)
assert asha.doctor is rao
assert rao.patients() == (asha,)
def test_reassign_moves_the_patient():
"""rule 3: a patient with another doctor moves, and is listed last"""
rao, sen = Doctor("Dr Rao"), Doctor("Dr Sen")
asha, ravi = Patient("Asha"), Patient("Ravi")
assign(sen, ravi)
assign(rao, asha)
assign(sen, asha)
assert rao.patients() == ()
assert sen.patients() == (ravi, asha)
assert asha.doctor is sen
def test_same_doctor_twice_changes_nothing():
"""rule 4: assigning the same doctor again keeps one link in its place"""
rao = Doctor("Dr Rao")
asha, ravi = Patient("Asha"), Patient("Ravi")
assign(rao, asha)
assign(rao, ravi)
assign(rao, asha)
assert rao.patients() == (asha, ravi)
def test_discharge_clears_both_ends():
"""rule 4: discharge() unlinks both ends, and a second discharge does nothing"""
rao, asha = Doctor("Dr Rao"), Patient("Asha")
assign(rao, asha)
discharge(asha)
discharge(asha)
assert asha.doctor is None
assert rao.patients() == ()
def test_patients_is_a_tuple():
"""rule 5: patients() hands out a tuple"""
rao = Doctor("Dr Rao")
assign(rao, Patient("Asha"))
assert isinstance(rao.patients(), tuple)
def test_random_sequences_keep_both_ends_in_step():
"""rules 1 and 2 hold after every call of 20 seeded random sequences"""
for seed in range(1, 21):
rng = random.Random(seed)
doctors = [Doctor(f"Dr {n}") for n in ("Rao", "Sen", "Iyer")]
patients = [Patient(n) for n in ("Asha", "Ravi", "Meera", "John", "Fatima", "Arjun", "Divya", "Kabir")]
for step in range(1, 301):
p = rng.choice(patients)
if rng.random() < 0.7:
d = rng.choice(doctors)
assign(d, p)
call = f"assign({d.name}, {p.name})"
else:
discharge(p)
call = f"discharge({p.name})"
try:
check_links(doctors, patients)
except AssertionError as error:
raise AssertionError(f"seed {seed}, step {step}, after {call}: {error}") from None A hint
Make assign() and discharge() the only code that writes either end. In assign(), deal with three cases in order: the patient already has this doctor (return), the patient has a different doctor (remove them from that doctor's list first), and finally link both ends.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
9 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between a dependency and an association between two classes?
Both say that one class uses another; the difference is how long the link lasts. A dependency is temporary: A uses B only during a call, because B is a parameter, a local variable, a return type or the target of a static call, and A keeps no reference afterwards. UML draws it as a dashed arrow from A to B. An association is structural: A keeps a reference to B in a field, so the link outlives the call and is part of every A’s state. UML draws it as a solid line, with a multiplicity at each end and an arrowhead where you can navigate. A quick test: if B appears among A’s fields, it is an association; if B appears only in a method’s parameters, locals or return type, it is a dependency.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Model a hospital's wards, beds and patients as classes. Which links are composition, which are aggregation or plain association, and what must your code do differently for each when a ward closes for repairs?
-
Mid to senior
In production, some students list a course whose roster does not list them. The Student–Course link is stored on both sides. How do you find the cause, repair the data and make the drift impossible?
Key takeaways
- Generalization and realization relate classes; dependency, association, aggregation and composition relate objects at run time.
- A dependency lasts one call; an association is a stored reference. Aggregation and composition are associations that add a whole and its parts.
- Decide between association, aggregation and composition with the deletion test, and say in words what a hollow diamond means, because UML leaves it open.
- Label both ends of every association with a multiplicity, and store a second direction only when an operation needs it.
- A two-way link needs one writer and an invariant checked by tests.
- Ownership is a rule about references:
std::unique_ptrand Rust’s owned fields enforce it, while Python and Go rely on the code never handing parts out.
References
- Unified Modeling Language (UML) 2.5.1, formal specification (Object Management Group)
- Data model: objects, values and types (reference counting in CPython) (Python Software Foundation)
- weakref.finalize (Python Software Foundation)
- std::unique_ptr (cppreference.com)
- std::weak_ptr (cppreference.com)
- C++ Core Guidelines, R.3: A raw pointer (a T*) is non-owning (Standard C++ Foundation)
- The Rust Programming Language: What is ownership? (The Rust Project)
- Rust standard library: struct std::rc::Weak (The Rust Project)
- Rust compiler error index: E0505 (The Rust Project)
- Go Slices: usage and internals (The Go Authors)
- Package slices: func Clone (The Go Authors)
- Package weak: weak pointers (The Go Authors)
- A Guide to the Go Garbage Collector (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress