Day 13 / 25 Encapsulation, Properties & Composition 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Programming Fundamentals/Day 13

Week 3 · Algorithms & Object-Oriented Python · 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.

Study time
4 hours
Reading
Focus
invariants · @property · has-a

By the end of today you can

  1. State an invariant for a class, and enforce it in one place
  2. Explain what Python's _name convention does and does not do
  3. Use @property to add validation without changing how callers write code
  4. Say why Java needs getters everywhere and Python does not
  5. Tell "has-a" from "is-a", and default to the first
  6. Recognise the inheritance that should have been composition
  7. 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.

13A
Invariants & Encapsulation (120 min)
Stating the sentence that must never stop being true · enforcing it in one place · why the distance between mistake and crash is the real cost · the underscore convention and what it does not do · @property added without changing a single call site.
13B
Composition Over Inheritance (120 min)
Has-a versus is-a, said out loud · the classic Car/Engine mistake · delegation · __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:

broken.py
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 mistake
The real cost is the distance

The 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

guarded.py
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

NameMeansEnforced?
readPublic. Use it freely.
_readInternal. Do not touch from outside.No — it is a convention
__readName-mangled to _Book__readPartially — inconvenient, not private
Python has no private, and that is deliberate

_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.

property.py
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 * 100
The rule this gives you

Start 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 likeself.engine = Engine()class Car(Vehicle):
CouplingLoose — only the methods you callTight — everything, including future changes
Change at runtimeYes, swap the held objectNo, fixed at definition
Test in isolationEasy — pass a fakeHarder — you inherit the parent too
The test that settles it

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:

delegate.py
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)
Dunder methods make your class feel built-in

__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.

The default to hold

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.

scratch.pypython not loaded
Values for input(), comma separated →
Output appears here. The first run takes a few seconds while Python loads.

Exercise set

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset day

Day 14 — Inheritance & Polymorphism

One call, different behaviour — the hierarchy Week 4 rebuilds in Java.

Continue →