Inheritance & Hierarchies
How one class can extend another, reuse and specialise behaviour, and how to model generalisation hierarchies in UML and SysML v2.
Hide your data, design a clean public interface, and introduce the ShoppingCart — the third class in our bookshop domain.
In Module 1 we built a Book class with fully public attributes. Any code anywhere can directly overwrite them — no rules, no validation:
book1.price = -9.99 # invalid: price cannot be negative book1.stock = "lots" # invalid: stock must be an integer book1.isbn = "" # invalid: ISBN cannot be blank # Object is now in a broken state. No error raised. # The bug surfaces elsewhere, much later.
Encapsulation solves this by enforcing a clear boundary:
| Concern | Meaning |
|---|---|
| Public interface | The operations the outside world is allowed to call. Stable, intentional, documented. |
| Private implementation | Internal data and helper logic. Hidden, changeable without breaking callers. |
Python does not enforce access control at the language level. Instead it uses naming conventions that communicate intent clearly:
| Convention | Visibility | Meaning |
|---|---|---|
name | Public | Part of the class's official interface. Safe to use from anywhere. |
_name | Protected | Internal use within the class and subclasses. External access is discouraged. |
__name | Private | Triggers name-mangling. Strongly signals: internal detail, do not touch. |
In UML diagrams these map to the standard visibility markers: + (public), # (protected), − (private).
class Book: def __init__(self, isbn, title, author, price, stock=0): self.isbn = isbn # public — read externally is fine self.title = title # public self.author = author # public self._price = price # protected — use the property instead self._stock = stock # protected — use the property instead
The @property decorator lets you attach validation logic to what looks like a simple attribute access from the outside. The caller never needs to change their code:
# ── price property ─────────────────────────────────── @property def price(self) -> float: return self._price @price.setter def price(self, value: float) -> None: if value < 0: raise ValueError(f"Price cannot be negative: {value}") self._price = value # ── stock property ─────────────────────────────────── @property def stock(self) -> int: return self._stock @stock.setter def stock(self, value: int) -> None: if not isinstance(value, int) or value < 0: raise ValueError("Stock must be a non-negative integer") self._stock = value
book1.price = 39.99 # writes via setter — transparent book1.price = -5.00 # ValueError: Price cannot be negative: -5.0 book1.stock = "lots" # ValueError: Stock must be a non-negative integer
A class invariant is a condition that must be true for every valid instance at all times — before any method call and after every method call. For Book:
price ≥ 0 — a book cannot have a negative price.stock ≥ 0 and stock is an integer.isbn is non-empty — every book must have an identifier.def __init__(self, isbn: str, ...): if not isbn or not isbn.strip(): raise ValueError("ISBN cannot be blank") self.isbn = isbn
The third domain class. It holds a list of (Book, quantity) pairs and demonstrates encapsulation applied to a mutable collection:
class ShoppingCart: """A shopping basket belonging to one customer.""" def __init__(self, customer): self._customer = customer self._items: list[tuple] = [] def add_item(self, book, quantity: int = 1) -> None: if quantity <= 0: raise ValueError("Quantity must be positive") if not book.is_available(): raise ValueError(f"{book.title} is out of stock") for i, (b, q) in enumerate(self._items): if b.isbn == book.isbn: self._items[i] = (b, q + quantity) return self._items.append((book, quantity)) def get_total(self) -> float: return sum(b.price * q for b, q in self._items) def item_count(self) -> int: return sum(q for _, q in self._items) @property def items(self) -> list: return list(self._items) # copy, not a reference
items returns list(self._items) — a copy of the internal list. If we returned self._items directly, callers could mutate the list from outside the class, bypassing all validation.
Book (with properties) and ShoppingCart. The «property» stereotype marks controlled attribute access.A method should only call methods on: the object itself, parameters passed in, objects it creates locally, and direct attributes of self. Use only one dot — don't reach through one object to call methods on another.
# BAD — violates Law of Demeter (two hops: cart → book → str) for book, qty in cart.items: print(book.author.upper()) # GOOD — ask the book to do the work for book, qty in cart.items: print(book.get_details())
class Customer: def __init__(self, customer_id: str, name: str, email: str): self._customer_id = customer_id self._name = name self._email = "" self.email = email # validates via setter @property def email(self) -> str: return self._email @email.setter def email(self, value: str) -> None: if "@" not in value: raise ValueError(f"Invalid email: {value}") self._email = value def update_email(self, new_email: str) -> None: self.email = new_email # goes through the setter
Customer owns one ShoppingCart. Multiplicity 1 — 1..1.SysML v2 supports UML visibility markers directly on features. The mapping is direct:
| Python convention | SysML v2 |
|---|---|
name — public | public attribute name : Type; |
_name — protected | protected attribute _name : Type; |
__name — private | private attribute __name : Type; |
+ public method | public action def method; |
− private method | private action def method; |
part def Book { public attribute isbn : String; public attribute title : String; public attribute author : String; private attribute _price : Real := 0.0; private attribute _stock : Integer := 0; public attribute price : Real := _price; // derived (≈ @property) public action def get_details : String; public action def is_available : Boolean; public action def apply_discount { in percent : Real; } } part def ShoppingCart { private attribute _items : OrderedSet (Book, Integer); private ref part _customer : Customer; public action def add_item { in book : Book; in qty : Integer; } public action def remove_item { in book : Book; } public action def get_total : Real; public action def item_count : Integer; }
public attributeprotected attributeprivate attributederived attributeSeparates public interface from private implementation. External code depends only on the interface.
name public, _name protected, __name private. Convention-based, respected by all serious Python code.
Adds validation to what looks like plain attribute access. Use when you need to enforce invariants or compute values.
Conditions that must hold for every valid instance at all times. Encapsulation is the mechanism that enforces them.
When exposing mutable internal state via a property, return a copy — not a reference — to prevent external mutation.
Only call methods on self, parameters, locally created objects, and direct attributes. Use one dot, not a chain.
How one class can extend another, reuse and specialise behaviour, and how to model generalisation hierarchies in UML and SysML v2.