AmouAI Hub/Courses/Programming Fundamentals/Day 13
Encapsulation, Properties & Composition
Why hiding data matters, how @property keeps it Pythonic, and how to decide between has-a and is-a before you commit.
By the end of today you can
- State an invariant for a class, and enforce it in one place
- Explain what Python's
_nameconvention does and does not do - Use
@propertyto add validation without changing how callers write code - Say why Java needs getters everywhere and Python does not
- Tell "has-a" from "is-a", and default to the first
- Recognise the inheritance that should have been composition
- Delegate a method to a held object
▶Today's videos
Watch each video, then work the matching sections below. Watching alone will not do it.
@property added without changing a single call site.__len__, __iter__ and __contains__ making your class feel built-in · when inheritance genuinely earns its keep.1Invariants
The sentence about your object that must never stop being true.
An invariant is a rule that holds for the whole life of an object. "read is never negative and never exceeds pages." "A balance is never below zero." "An email address contains an @."
Yesterday's Book had an invariant and did not enforce it:
b = Book('Think Python', 'Downey', 292)
b.read = -50 # nothing stops this
b.pages = 0 # nor this
print(b.progress()) # ZeroDivisionError, far from the real mistakeThe crash happens in progress(). The mistake happened wherever pages was set to zero — possibly a hundred lines and three functions earlier. Every minute you spend debugging that is a minute the invariant would have saved you.
Enforce it in one place
class Book:
def __init__(self, title, author, pages):
if pages <= 0:
raise ValueError(f"pages must be positive, got {pages}")
self.title = title
self.author = author
self.pages = pages
self._read = 0
def read_pages(self, n):
if n < 0:
raise ValueError("cannot un-read pages")
self._read = min(self._read + n, self.pages)raise is properly Day 15's material. What matters today is the shape: check at the boundary, fail immediately, and say what was wrong. The message includes the offending value, because "invalid pages" tells a future debugger nothing.
The underscore convention
| Name | Means | Enforced? |
|---|---|---|
read | Public. Use it freely. | — |
_read | Internal. Do not touch from outside. | No — it is a convention |
__read | Name-mangled to _Book__read | Partially — inconvenient, not private |
_read is a note to the next programmer: this is not part of the interface; I may change it. Python trusts you to read the note. The community phrase is "we are all consenting adults here" — and it is why Python needs far less ceremony than Java, which you will see for yourself on Day 18.
2@property
Add validation without changing a single call site.
Here is the problem @property solves. You shipped book.read as a plain attribute. Now you need validation. In most languages that means changing every caller to book.get_read(). In Python it does not.
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
self._read = 0
@property
def read(self):
"""Pages read so far."""
return self._read
@read.setter
def read(self, value):
if value < 0:
raise ValueError("read cannot be negative")
if value > self.pages:
raise ValueError("read cannot exceed pages")
self._read = value
@property
def progress(self):
"""Percentage read. Computed, never stored."""
return self._read / self.pages * 100Start with a plain public attribute. Add @property the moment you need validation or a computed value. You never need speculative getters and setters, because adding them later costs nothing. Writing get_x/set_x pairs in Python before you need them is importing a Java habit that Python has designed away.
progress is worth a second look: it is computed on every access and never stored, so it cannot go stale. Any value you can derive from other values is a candidate — storing it means keeping two things in sync, and that is where bugs live.
3Composition versus inheritance
Two ways to reuse a class. One of them is almost always right.
| Composition — "has-a" | Inheritance — "is-a" | |
|---|---|---|
| Looks like | self.engine = Engine() | class Car(Vehicle): |
| Coupling | Loose — only the methods you call | Tight — everything, including future changes |
| Change at runtime | Yes, swap the held object | No, fixed at definition |
| Test in isolation | Easy — pass a fake | Harder — you inherit the parent too |
Say the sentence out loud. **"A Car is a Vehicle" — that works, so inheritance is defensible. "A Car is an Engine"** — nonsense; a car has an engine. If "is-a" sounds forced, it is composition.
Delegation
When you compose, you often want to expose one method of the held object. That is delegation, and it is one line:
class ContactBook:
def __init__(self):
self._contacts = []
def add(self, contact):
self._contacts.append(contact)
def __len__(self): # len(book) works
return len(self._contacts)
def __iter__(self): # for c in book: works
return iter(self._contacts)
def __contains__(self, name): # 'Ada' in book works
return any(c.name == name for c in self._contacts)__len__, __iter__ and __contains__ let len(), for and in work on your object. Your ContactBook now behaves the way a Python programmer expects, without exposing the list it happens to use inside — which means you could swap that list for a dict tomorrow and no caller would notice.
Prefer composition. Reach for inheritance when the "is-a" sentence is honest and you want polymorphism — one call, different behaviour by type. That is tomorrow, and it is the case where inheritance genuinely earns its keep.
>_Python playground
A real Python interpreter running inside your browser. Nothing is installed, nothing is uploaded, nothing can break.
input(), comma separated →
✓Exercise set
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Day 14 — Inheritance & Polymorphism
One call, different behaviour — the hierarchy Week 4 rebuilds in Java.