Beginner · Lesson 36 of 94

Encapsulation, methods, and properties

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Encapsulation is the first pillar and the most practical one: an object should control how its own data changes. If any code anywhere can write account.balance = -500, then no method in the class can trust the balance. The fix is to route every change through a method that checks it, deposit and withdraw, and to signal that the raw attribute is not for outside use.

Python signals that with a leading underscore: self._balance. It is a convention, not a lock. Other code can still read and write _balance, but every Python developer reads the underscore as "internal, do not touch from outside", and linters and editors honor it. A double underscore, __balance, goes further and mangles the name so accidental access from outside fails; it is rarer and mostly used to avoid clashes in subclasses. In practice, one underscore is the everyday tool.

Encapsulation, methods, and properties: outside code → methods check and change → self._balance
Encapsulation, methods, and properties · Figure 1 · Open for full size

Sometimes you want attribute syntax with method behavior. A property does that. Decorating a method with @property makes account.balance call it without parentheses, so you can compute a value on demand, like full_name from first and last, or expose a read-only view of _balance. Adding a @balance.setter method lets account.balance = 200 run validation before storing. To callers it looks like a plain attribute; inside, you are in control. This is also how you add validation later to an attribute that started out plain, without changing any caller.

You want Write Callers use
computed value, no storage @property def area(self) shape.area
read-only view of internal state @property def balance(self) acct.balance (no assignment)
validated assignment @balance.setter def balance(self, v) acct.balance = 200
an action with side effects a normal method acct.withdraw(30)

Attributes defined directly in the class body, outside any method, are class attributes: one value shared by every instance, read through self or through the class. They are right for constants and defaults, interest_rate = 0.02, and for counters that span all instances. Assigning self.interest_rate = 0.05 on an instance creates an instance attribute that shadows the shared one for that object only, which is the intended way to override a default. The trap is a mutable class attribute like a list: every instance shares the same list, and one instance's append shows up in all of them.

Encapsulation, methods, and properties: class BankAccount; interest_rate = 0.02 (shared) → ada: _balance 150 → linus: _balance 20; interest_rate = 0.05 (own override)
Encapsulation, methods, and properties · Figure 2 · Open for full size

Code

Routing changes through methods, and what the underscore protects against. The invalid deposit is refused; the attribute itself is still reachable, which is why the convention matters.

# encapsulated.py
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance += amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("insufficient funds")
        self._balance -= amount

    def __repr__(self):
        return f"BankAccount({self.owner!r}, {self._balance})"


acct = BankAccount("ada", 100)
acct.deposit(50)
try:
    acct.deposit(-20)
except ValueError as err:
    print("refused:", err)
print(acct)
print(acct._balance, "<- reachable, but the underscore says don't")
Output
refused: deposit must be positive
BankAccount('ada', 150)
150 <- reachable, but the underscore says don't

Properties: a computed value, a read-only view, and a validated setter. To the caller they all look like attributes.

# properties.py
class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def area(self):
        return self._width * self._height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("width must be positive")
        self._width = value


r = Rectangle(3, 4)
print(r.area)
r.width = 10
print(r.width, r.area)
r.area = 100
Output
12
10 40
Traceback (most recent call last):
  File "properties.py", line 25, in <module>
    r.area = 100
    ^^^^^^
AttributeError: property 'area' of 'Rectangle' object has no setter

The setter refusing a bad value, so the object can never hold an invalid width, however it is assigned.

# setter_validation.py
class Rectangle:
    def __init__(self, width, height):
        self.width = width          # goes through the setter, so it is validated too
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError(f"width must be positive, got {value}")
        self._width = value


r = Rectangle(3, 4)
for attempt in (5, 0, -2):
    try:
        r.width = attempt
        print("width is now", r.width)
    except ValueError as err:
        print("rejected:", err)
Output
width is now 5
rejected: width must be positive, got 0
rejected: width must be positive, got -2

Class attributes versus instance attributes, including the shared-list trap and its fix.

# class_attrs.py
class BankAccount:
    interest_rate = 0.02
    count = 0

    def __init__(self, owner):
        self.owner = owner
        BankAccount.count += 1


ada = BankAccount("ada")
linus = BankAccount("linus")
linus.interest_rate = 0.05
print(ada.interest_rate, linus.interest_rate, BankAccount.interest_rate)
print(BankAccount.count)


class Bad:
    tags = []                 # shared by every instance: a trap

    def add(self, tag):
        self.tags.append(tag)


class Good:
    def __init__(self):
        self.tags = []        # one list per instance

    def add(self, tag):
        self.tags.append(tag)


a, b = Bad(), Bad()
a.add("x")
print("Bad:", b.tags)
c, d = Good(), Good()
c.add("x")
print("Good:", d.tags)
Output
0.02 0.05 0.02
2
Bad: ['x']
Good: []

Try it yourself

Create thermostat.py with a Thermostat class that stores _target in Celsius. Add a target property with a setter that only accepts values between 5 and 30, a read-only target_fahrenheit property computed from it, and a class attribute unit = "C". Show a valid assignment, a rejected one, and the computed conversion. Then add a class attribute instances = 0 that counts how many thermostats have been created.

Common mistakes

What's next

Your objects now guard their own state. Next you will learn the two other kinds of method a class can have: class methods and static methods.

Course overview