Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

A flow of five yes-or-no questions about classes A and B, each answer leading to one of the six UML relationships.1. Is every A also a B,keeping all of B's promises?Generalization:solid line,hollow triangle at B2. Does A promise B'soperations withoutinheriting B's code?Realization:dashed line,hollow triangle at B3. Does A keep areference to B afterthe call that gave it?Dependency:dashed arrowfrom A to B4. When A is deleted,must its Bs go too,with no other owner?Composition:filled diamondat A5. Is A a whole thatgroups Bs which alsolive on their own?Aggregation:hollow diamondat AAssociation:solid line, arrowheadat B if only A navigatesyesnoyesnonoyesyesnoyesno

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.

  1. Is every A also a B, keeping all of B's promises? Yes: generalization, a solid line with a hollow triangle at B.
  2. Does A promise B's operations without inheriting B's code? Yes: realization, a dashed line with a hollow triangle at B.
  3. 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.
  4. When A is deleted, must its Bs go too, with no other owner? Yes: composition, a filled diamond at A.
  5. 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:

Deleting the whole, and watching the parts Python · ownership/ownership.py
"""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

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 1 and OrderLine 1..*: every order has one or more lines, and every line belongs to exactly one order.
  • OrderLine 0..* and Product 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.

Class diagram of a shop (Catalogue, Product, Order, OrderLine) and a club (Club, Team, Player) with multiplicities.Catalogue+find(sku)ProductProduct+skustr+namestrOrder+numberint+add(product, qty)None+quantities()listOrderLine+qtyintClub+namestrTeam+namestrPlayer+namestr10..*11..*0..*110..*10..*0..*0..*

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.

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 Student-Course link checked after every step Python · enrolment/enrolment.py
"""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

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:

The deletion test in C++23, Rust and Go

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.

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.

  1. Question 1 of 9 A hotel Booking creates the Night objects of a stay, no other object refers to them, and cancelling the booking removes them. Which relationship is Booking to Night?

    Choose one answer.

    Show the answer to question 1

    Answer: Composition, with the filled diamond at Booking

    The booking makes the nights, nobody else holds them, and they go when the booking goes: that is the deletion test's answer for composition. The filled diamond sits at the whole, Booking.

  2. Question 2 of 9 A library Branch lists the members registered there. A member can be registered at several branches and keeps their account when a branch closes. Which relationship fits best?

    Choose one answer.

    Show the answer to question 2

    Answer: Aggregation (or a plain association), with the rule about closing a branch written beside it

    The members exist without the branch and may belong to several branches, so the branch does not own them. UML leaves the meaning of the hollow diamond to the modeller, so state the rule in words either way.

  3. Question 3 of 9 InvoiceMailer.send(invoice, client) uses the SmtpClient it is given for that one call and keeps no reference to it. What do you draw from InvoiceMailer to SmtpClient?

    Choose one answer.

    Show the answer to question 3

    Answer: A dependency, a dashed arrow towards SmtpClient

    The client is a parameter used during one call and is not stored, so the link is temporary: a dependency. It would become an association the moment the mailer kept the client in a field.

  4. Question 4 of 9 UpiPayment provides the pay(amount) operation that the PaymentMethod interface declares, and inherits no code from it. Which line joins them?

    Choose one answer.

    Show the answer to question 4

    Answer: A dashed line with a hollow triangle at PaymentMethod

    Implementing an interface is realization: a dashed line whose hollow triangle points at the interface. A solid line with the same triangle would be generalization, inheritance from a class.

  5. Question 5 of 9 Each valid passport is issued to exactly one person, and a person holds at most one valid passport. Which multiplicities go on the association "holds a valid passport"?

    Choose one answer.

    Show the answer to question 5

    Answer: 1 at the Person end and 0..1 at the Passport end

    The multiplicity at an end counts the objects at that end for one object at the other end. One passport has exactly 1 person, so 1 goes at the Person end; one person has 0 or 1 valid passports, so 0..1 goes at the Passport end.

  6. Question 6 of 9 An association between OrderLine and Product has an open arrowhead at the Product end and no mark at the OrderLine end. What does it say?

    Choose one answer.

    Show the answer to question 6

    Answer: An order line can navigate to its product; the diagram says nothing about the other direction

    An open arrowhead marks an end that can be navigated to, and a small x would mark one that cannot. With no mark at all, the diagram makes no claim about navigating from a product to its lines.

  7. Question 7 of 9 Which of these shapes of code fit a composition between a whole and its parts?

    Choose every answer that is right.

    Show the answer to question 7

    Answer:

    • The parts are never handed out, or only as copies or ids
    • A C++ member of type std::vector<std::unique_ptr<Part>>
    • The whole creates its parts inside its own methods

    Composition means the whole controls its parts' lifetime: it makes them, keeps them to itself and frees them with itself, which a unique_ptr member enforces. Parts that the caller also keeps, or indexes into someone else's collection, describe aggregation or association instead.

  8. Question 8 of 9 Both Student and Course store their side of an enrolment. Which design keeps the two sides consistent?

    Choose one answer.

    Show the answer to question 8

    Answer: One function creates the link and one removes it, each updating both ends, and no other code writes either list

    A two-way association has one invariant (a link is seen from both ends), so it needs one writer per change. A nightly comparison only finds the damage after the fact. The third option is consistent too, but it no longer stores the course's side: it is the one-direction alternative, not a way to keep two stored ends in step.

  9. Question 9 of 9 What does this Go program print?

    Read the code, then choose one answer.

    type Line struct{ Item string; Qty int }
    type Order struct{ Number int; Lines []Line }
    
    a := Order{Number: 1, Lines: []Line{{"tea", 2}}}
    b := a
    b.Lines[0].Qty = 9
    fmt.Println(a.Lines[0].Qty)
    Show the answer to question 9

    Answer: 9, because b and a share the array behind the slice

    Assigning a struct copies its fields, and a slice field is only a header with a pointer to the array, a length and a capacity. Both orders point at the same array until one of them gets its own copy with slices.Clone.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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_ptr and Rust’s owned fields enforce it, while Python and Go rely on the code never handing parts out.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.