Grade by Grade
C. Designing Modular Programs · Modular and Object-Oriented Design

Encapsulation: Protecting an Object's Data

C1.2 A2.2About 75 minutes
Learning goals
  • I can explain encapsulation and information hiding, and why they make code safer to change.
  • I can mark attributes as internal with a leading underscore and expose them through methods or read-only properties.
  • I can write a property with a setter that validates new values and raises ValueError for bad ones.
  • I can describe a class invariant and design methods so that no call can break it.
  • I can decide when a record (structure) is enough and when a full class is needed, and give a dataclass record a simple invariant with frozen=True and __post_init__.

Words to know

encapsulation
Bundling data with the methods that act on it, and controlling access so the data can only be changed in allowed ways.
information hiding
Keeping an object's internal details private so outside code depends only on its public interface.
public interface
The methods and properties other code is meant to use.
invariant
A condition a class promises is always true for its objects, e.g. "the balance is never negative".
property
A method that is used like an attribute (no parentheses). @property makes a getter; @name.setter adds a checked setter.
accessor / mutator
Other names for getter (reads a value) and setter (changes a value).
name mangling
Python renames an attribute that starts with two underscores, such as __code, to _ClassName__code, which makes accidental access harder.
record (structure)
A simple data type made of named fields, with little or no behaviour. In Python, often a dataclass.
immutable
Cannot be changed after it is created, like a tuple, a string or a frozen dataclass.

Who changed the balance?

With last lesson's TransitCard, any line anywhere in a program can write card.balance = 1000000 or card.balance = -50. The class's careful tap() rule is useless if code can reach around it. In a program of 50 000 lines, finding the line that set a negative balance could take days.

Encapsulation fixes this. The object keeps its data inside and offers a small set of public methods as the only way in. Each method checks the request first. Then the class can make a promise, called an invariant, such as "the balance is never negative", and keep it no matter what the rest of the program does.

You met the start of this idea in Arrays of Objects (Data Types, Files and Expressions), where a store item refused to sell more stock than it had. This lesson turns it into a complete design technique: internal data, read-only and validated properties, and invariants.

SavingsAccount objectinternal data_balance_historydeposit()withdraw()balancehistory()outside codeoutside codeOutside code uses only the public methods (rounded boxes on the edge).The methods check every request before touching the internal data.
Encapsulation: the object's internal data sits inside, and the only ways in are the public methods and properties on its edge. Because every change goes through a method, the class can guarantee its rule that the balance is never negative.

How Python marks private data

Languages such as Java and C++ have keywords (private, public) that the compiler enforces. Python takes a different approach, based on agreement between programmers:

  • One leading underscore, as in self._balance, means "internal: do not use this from outside the class". Python does not stop you, but every Python programmer reads it as a warning sign, and many code checkers flag it.
  • Two leading underscores, as in self.__code, trigger name mangling: Python quietly renames it to _ClassName__code. This prevents accidents and clashes in subclasses, but a determined programmer can still reach it.
  • No underscore means public: part of the interface that other code may rely on.

In UML, - marks private members and + marks public ones, so -_balance: int and +deposit(cents).

Worked example

Example 1: An account that keeps its promise

Write a SavingsAccount class with the invariant balance ≥ 0. Outside code may read the balance but not set it; it may deposit and withdraw positive amounts; a withdrawal larger than the balance must be refused. Keep a history of transactions that callers can read but not damage.

  1. Store the data in internal attributes _balance and _history. The constructor checks the opening amount, so a new object already satisfies the invariant.
  2. Expose the balance with @property. The method balance(self) is now read like an attribute, acct.balance, with no parentheses. With no setter, the assignment acct.balance = 999999 raises AttributeError.
  3. deposit and withdraw validate their arguments and raise ValueError with a clear message when the request would break the rules. Raising an exception is better than silently ignoring the request or printing inside the class, because the caller decides how to respond.
  4. history() returns list(self._history), a copy. If it returned the internal list itself, a caller could append fake transactions or clear it.
  5. Trace: 5000 + 2500 = 7500. Withdrawing 10000 is refused. Withdrawing 1500 leaves 6000.

The program and its output are below. Every path that changes _balance checks the invariant first, so no sequence of public calls can make the balance negative.

class SavingsAccount:
"""A savings account whose balance (in cents) can never go below zero."""
def __init__(self, owner, opening_cents=0):
if opening_cents < 0:
raise ValueError("opening balance cannot be negative")
self.owner = owner
self._balance = opening_cents # internal: change only through methods
self._history = []
@property
def balance(self):
"""The current balance in cents (read-only)."""
return self._balance
def deposit(self, cents):
"""Add a positive amount of money."""
if cents <= 0:
raise ValueError("deposit must be positive")
self._balance += cents
self._history.append(("deposit", cents))
def withdraw(self, cents):
"""Remove money; refuse if it would overdraw the account."""
if cents <= 0:
raise ValueError("withdrawal must be positive")
if cents > self._balance:
raise ValueError("insufficient funds")
self._balance -= cents
self._history.append(("withdraw", cents))
def history(self):
"""Return a copy of the list of transactions."""
return list(self._history)
acct = SavingsAccount("Priya", 5000)
acct.deposit(2500)
try:
acct.withdraw(10000)
except ValueError as err:
print("Refused:", err)
acct.withdraw(1500)
print(acct.balance)
print(acct.history())
try:
acct.balance = 999999
except AttributeError:
print("balance is read-only")

savings.py: encapsulated data, a read-only property and validating methods.

Refused: insufficient funds
6000
[('deposit', 2500), ('withdraw', 1500)]
balance is read-only

Output of the program above (what Python prints).

Properties with setters

Sometimes outside code should be able to change a value, but only to valid values. A property can have a setter: a method that runs whenever someone assigns to the attribute. The caller writes the natural t.target = 22, and the setter checks the value before storing it in the internal _target.

A neat detail: in __init__, write self.target = target (no underscore). That assignment goes through the setter, so the starting value is checked by the same code as every later change. One rule, one place.

class Thermostat:
"""A thermostat whose target is always between 10 and 30 degrees C."""
MIN, MAX = 10, 30
def __init__(self, target=20):
self.target = target # uses the setter below, so it is checked
@property
def target(self):
"""The target temperature in degrees C."""
return self._target
@target.setter
def target(self, value):
if not (Thermostat.MIN <= value <= Thermostat.MAX):
raise ValueError(f"target must be {Thermostat.MIN} to {Thermostat.MAX}")
self._target = value
t = Thermostat()
t.target = 22
print(t.target)
try:
t.target = 35
except ValueError as err:
print(err)
print(t.target)

A validated property. The bad value 35 is refused and the old value 22 is kept.

22
target must be 10 to 30
22

Output of the program above (what Python prints).

class Locker:
def __init__(self, code):
self.__code = code # two underscores: name mangling
def opens_with(self, guess):
return guess == self.__code
lk = Locker(4071)
print(lk.opens_with(1234), lk.opens_with(4071))
print(hasattr(lk, "__code"), lk._Locker__code)

Name mangling: __code is stored as _Locker__code. It is hidden from casual access, not truly secret, so never rely on it for security.

False True
False 4071

Output of the program above (what Python prints).

Why encapsulation makes change safe

Suppose the bank later decides to store the balance as a list of transactions and compute it on demand. With encapsulation, you rewrite the inside of SavingsAccount and the balance property, and no other file changes, because outside code only ever used the public interface. Without it, every file that touched acct.balance directly would have to be found and fixed. Information hiding is what lets large teams change one module without breaking the others.

Records and structures: a design choice

In One-Dimensional Arrays of Records (Data Types, Files and Expressions) you wrote records with dataclasses, named tuples and dictionaries. The question for this unit is a design one: when is a record (a structure of named fields, like a struct in C) enough, and when does the data need a fully encapsulated class?

A record fits data where almost any combination of field values is valid and the main operations are create, read, compare and print. Two dataclass features let a record still protect itself a little: frozen=True makes it immutable, so nobody can change it after it is checked, and a __post_init__ method runs straight after the generated constructor, so it can validate the fields and raise ValueError. Together they give a record a simple invariant without hand-writing a whole class.

You write@dataclass(frozen=True)class Book:title: strauthor: stryear: intPython addsGenerated for you__init__(title, author, year)store the fields__repr__()readable printout__eq__(other)compare field by fieldfrozen=Trueassigning a field is an error
A dataclass is Python's quick way to write a record (a structure of named fields). From three typed field lines, Python writes the constructor, a readable printout and an equality test, and frozen=True makes the record read-only.
Worked example

Example 2: A book record

Use a frozen dataclass to store books for a school library display. Its invariant: the title is not blank and the year is positive. Show that equal records compare equal, that a field cannot be changed, and that an invalid book is refused.

  1. Import dataclass and FrozenInstanceError from dataclasses. Decorate the class with @dataclass(frozen=True) and list three typed fields; the constructor is generated.
  2. Add __post_init__(self). Python calls it at the end of the generated __init__, so every new Book is checked. Because the record is frozen, the check cannot be undone later by changing a field.
  3. print(a) uses the generated __repr__, which shows every field by name.
  4. == uses the generated __eq__, which compares the fields in order. A plain class without __eq__ would compare identity (are they the same object?) and give False.
  5. Assigning a.year = 2000 raises FrozenInstanceError, and Book(" ", "Unknown", 2020) is refused by __post_init__ because the title is only spaces.

The code and output are below. 2012 − 1908 = 104 years between the two books.

from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Book:
"""A read-only record describing one book.
Invariant: the title is not blank and the year is positive.
"""
title: str
author: str
year: int
def __post_init__(self):
"""Runs right after the generated __init__: check the fields."""
if not self.title.strip():
raise ValueError("title cannot be blank")
if self.year <= 0:
raise ValueError("year must be positive")
a = Book("Anne of Green Gables", "L. M. Montgomery", 1908)
b = Book("Indian Horse", "Richard Wagamese", 2012)
print(a)
print(a == Book("Anne of Green Gables", "L. M. Montgomery", 1908))
print(b.year - a.year)
try:
a.year = 2000
except FrozenInstanceError:
print("Book records are read-only")
try:
Book(" ", "Unknown", 2020)
except ValueError as err:
print("Refused:", err)

A frozen dataclass record that validates itself.

Book(title='Anne of Green Gables', author='L. M. Montgomery', year=1908)
True
104
Book records are read-only
Refused: title cannot be blank

Output of the program above (what Python prints).

Use a record (dataclass) when...Use a full class with encapsulation when...
the data is just a group of named valuesthere are rules (invariants) the data must obey
any combination of field values is validonly some changes are allowed, in some orders
you mostly create, read, compare and print itcallers need operations such as withdraw or tap
examples: a book, a race result, a point (x, y)examples: a bank account, a thermostat, a game board
Choosing between a record and a class. A dataclass can also have methods and validation (in a __post_init__ method), so this is a spectrum, not a hard line.
Just for fun

Python's unofficial motto about private attributes is "we are all consenting adults here": the underscore is a polite "please do not touch" sign, not a locked door. Name mangling is more like a door with a sticky note on it saying where the key is.

Hands-on activity

Guard an invariant

You need: a computer with Python, and a family member to play "the attacker".

  1. Write a class LibraryCard with internal attributes _holder, _books_out (a list) and a class attribute LIMIT = 5. Invariant: a card never has more than LIMIT books out, and never the same book twice.
  2. Give it methods borrow(title) and give_back(title) that raise ValueError when a request would break the invariant, and a read-only property count.
  3. Give the code to your family member (or pretend to be one). Their job: using only public methods and properties, try to break the invariant. Your job: fix any hole they find.
  4. Then let them use anything, including underscores. Show them that card._books_out.append("x") breaks it. Discuss: why is the underscore a convention and not a lock, and why does that still help in a team?
  5. Bonus: make books() return a copy, and show that changing the copy does not change the card.

What to look for: every method checks before changing anything, error messages explain the problem, and books() returns list(self._books_out).

Your turn

1

Define encapsulation in your own words.

2

What does a single leading underscore, as in self._balance, tell other programmers?

3

With the SavingsAccount class, what happens on each line?

a = SavingsAccount("Jo", 1000)

a.withdraw(1000)

a.withdraw(1)

4

Why does history() return list(self._history) instead of self._history?

5

State the invariant of the Thermostat class.

6

Write a frozen dataclass Result with fields name: str and seconds: int. Create the result for Ben, 298 seconds.

7

Why is raise ValueError(...) better than print("error") inside withdraw?

More practice: warm-up

1

Which decorator turns a method into a read-only attribute?

2

What exception type is normally raised for an invalid argument value?

3

In UML, what symbol marks a private member?

4

What module provides @dataclass?

5

True or false: Python stops outside code from reading obj._data.

More practice: on your own

1

Add a property balance_dollars to SavingsAccount that returns the balance as a string like "$60.00".

2

Predict:

t = Thermostat(25)

try:

    t.target = 5

except ValueError:

    t.target = 10

print(t.target)

3

Predict the result of Thermostat(40), and explain why.

4

Two dataclass records Result("Ana", 300) and Result("Ana", 300) are compared with == and with is. What are the results?

5

Give one reason Python uses an underscore convention instead of enforced private attributes, and one risk of that choice.

Challenge

1

Write a class Fraction that stores a numerator and a non-zero denominator, always in lowest terms with a positive denominator (that is the invariant). Use math.gcd. What does str(Fraction(6, -8)) give with your __str__?

2

A teammate's dataclass Point(x: float, y: float) is not frozen, and points are used as keys in a dictionary of map markers. Why should it be frozen?

3

Encapsulation is sometimes described as "making the right thing easy and the wrong thing hard". Using SavingsAccount, give one example of each.

Check your understanding

Quiz

1.

What is a class invariant?

2.

With @property on balance and no setter, what does acct.balance = 5 do?

3.

In __init__, the Thermostat writes self.target = target rather than self._target = target. Why?

4.

Python stores self.__code inside class Locker as which name?

5.

Which is the best fit for a frozen dataclass?

6.

Why should a method return a copy of an internal list rather than the list itself?

7.

Book("Indian Horse", "Richard Wagamese", 2012) == Book("Indian Horse", "Richard Wagamese", 2012) for the dataclass Book gives:

For parents and teachers

Big idea: C1.2 asks students to apply data encapsulation using classes, records and structures; A2.2 names encapsulation as a key concept for reusable design. The lesson teaches Python's conventions (underscores, properties) and records (dataclasses). Python enforces privacy less strictly than Java or C++, which students may meet in university; the design idea is the same.

How to help: The "attacker" activity is a great conversation: you try to break the rules, your teen defends them. Ask, "What promise does your class make, and which method keeps it?"

Common difficulties:

  • Writing self.balance inside the property getter named balance, which calls itself forever (RecursionError). Inside the class, use the internal name _balance.
  • Forgetting that a property is used without parentheses: acct.balance, not acct.balance().
  • Printing error messages inside classes instead of raising exceptions.
  • Thinking an underscore makes data secure. It is a convention, not a security feature.