Object-oriented Python · about 45 min

Class attributes, properties and encapsulation

Why this matters

As classes grow, two questions come up. Where should data that belongs to the type rather than to each object live (a counter, a default, a constant)? And how do you keep an object's data valid, for example a temperature below absolute zero or a negative price, without forcing every caller to use clumsy get_x()/set_x() methods? Python answers with class attributes, properties, and the classmethod/staticmethod decorators. It also takes a distinctive stance on privacy that you need to understand to read other people's code.

Class attributes

Anything assigned directly in the class body (not inside a method) is a class attribute, stored in the class's __dict__ and shared by all instances:

class Circle:
    pi = 3.14159              # class attribute

    def __init__(self, r):
        self.r = r            # instance attribute

    def area(self):
        return self.pi * self.r ** 2

c = Circle(2)
print(c.area(), Circle.pi, c.pi)
12.56636 3.14159 3.14159

self.pi works because lookup falls through from the instance to the class (lesson 1). Class attributes are good for constants and defaults.

The rule that causes most bugs

Recall: reading an attribute falls through to the class; assigning always creates or updates the attribute on the instance.

class Config:
    retries = 3

a = Config()
b = Config()
a.retries = 5          # creates a.__dict__['retries']; class unchanged
print(a.retries, b.retries, Config.retries)
5 3 3

That is fine, even useful (a per-instance override). The danger is mutable class attributes, because mutating is not assigning:

class Basket:
    items = []                 # ONE list, on the class
    def add(self, x):
        self.items.append(x)   # reads self.items -> the class's list, mutates it

a, b = Basket(), Basket()
a.add("apple")
print(b.items)
['apple']

Every basket shares one list. The fix is to create per-instance state in __init__: self.items = []. The same trap with augmented assignment on an immutable class attribute goes the other way: self.count += 1 reads the class's count, adds 1, and then assigns the result to a new instance attribute, so the class counter never changes. To update a class attribute, assign through the class: Config.count += 1 or type(self).count += 1.

Properties: attributes with code behind them

Suppose a Product has a price that must never be negative. In Java you would hide the field and write getPrice()/setPrice(). In Python you start with a plain attribute, and if you later need validation, you turn it into a property without changing any calling code:

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price           # goes through the setter below

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("price cannot be negative")
        self._price = value

p = Product("pen", 2.5)
print(p.price)
p.price = 3
print(p.price)
p.price = -1
2.5
3
Traceback (most recent call last):
  ...
ValueError: price cannot be negative

How it works underneath: @property replaces the function with a property object stored in the class. A property is a descriptor, an object with __get__/__set__ methods that Python calls during attribute access. Lookup actually checks the class for such data descriptors before the instance __dict__, so p.price runs the getter and p.price = 3 runs the setter. The real value lives in p._price.

A property without a setter is read-only, which is ideal for computed values:

class Rectangle:
    def __init__(self, w, h):
        self.w, self.h = w, h

    @property
    def area(self):
        return self.w * self.h

r = Rectangle(3, 4)
print(r.area)      # no parentheses: it looks like data
r.w = 10
print(r.area)      # always up to date
r.area = 5
12
40
AttributeError: property 'area' of 'Rectangle' object has no setter

classmethod and staticmethod

A normal method receives the instance. Two decorators change that.

A classmethod receives the class as its first argument, conventionally cls. Its most common use is an alternative constructor:

class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_iso(cls, text):
        y, m, d = (int(part) for part in text.split("-"))
        return cls(y, m, d)

    def __repr__(self):
        return f"Date({self.year}, {self.month}, {self.day})"

print(Date.from_iso("2026-11-01"))
Date(2026, 11, 1)

Using cls(...) rather than Date(...) means a subclass calling from_iso gets an instance of the subclass. You have used classmethods already: dict.fromkeys(...) and int.from_bytes(...) are both alternative constructors.

A staticmethod receives neither instance nor class. It is a plain function that lives in the class's namespace because it belongs there logically:

class Date:
    ...
    @staticmethod
    def is_leap(year):
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

print(Date.is_leap(2024), Date.is_leap(1900))   # True False

Rule of thumb: needs the instance? normal method. Needs the class (to construct, or to read class attributes)? classmethod. Needs neither? staticmethod, or simply a module-level function.

Encapsulation, the Python way

Python has no private keyword. Instead:

class Vault:
    def __init__(self):
        self.__code = 1234

v = Vault()
print(v.__dict__)
print(v._Vault__code)
v.__code
{'_Vault__code': 1234}
1234
AttributeError: 'Vault' object has no attribute '__code'

Mangling prevents accidents, not determined access. The Python community's phrase is "we are all consenting adults here": mark internals with _, provide properties where invariants matter, and trust callers.

Common mistakes

Recap

Going further: read the "Descriptor HowTo Guide" in the Python documentation, which shows how property, classmethod and staticmethod are implemented in pure Python.

Exercises

Exercise 1 · quiz

The shared list

What does this print?

class Basket:
    items = []
    def add(self, x):
        self.items.append(x)

a = Basket()
b = Basket()
a.add("apple")
print(len(b.items))
Answer
  • 1

self.items.append(x) reads self.items (lookup falls through to the class, since the instance has no items) and then mutates that list in place. There is only one list, stored on the class, so b.items is the same list and has length 1. Assignment would have created an instance attribute; mutation does not. Create per-instance containers in __init__.

Exercise 2 · write code

A validated property

In solution.py, write a class Temperature:

File: solution.py

Starting point

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
Hint 1

Define the getter with @property def celsius(self), then the setter with @celsius.setter def celsius(self, value).

Hint 2

Inside the setter, assign to self._celsius (with the underscore). Assigning to self.celsius there would call the setter again forever.

Hint 3

from_fahrenheit receives the class as cls and returns cls(...).

Reference solution
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError(f"{value} is below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self.celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9

    @classmethod
    def from_fahrenheit(cls, f):
        return cls((f - 32) * 5 / 9)

Exercise 3 · write code

Counting instances with a class attribute

In solution.py, write a class User:

File: solution.py

Starting point

class User:
    count = 0

    def __init__(self, name, email):
        self.name = name
        self.email = email
        self.count += 1
        self.id = self.count
Hint 1

self.count += 1 reads the class attribute but then creates an instance attribute, leaving User.count at 0. Write User.count += 1 (or type(self).count += 1).

Hint 2

Validate before incrementing, so a bad email leaves the count unchanged.

Hint 3

In from_string, s.split(" <", 1) separates the name from email>; strip the final >.

Reference solution
class User:
    count = 0

    def __init__(self, name, email):
        if not User.valid_email(email):
            raise ValueError(f"invalid email: {email!r}")
        self.name = name
        self.email = email
        User.count += 1
        self.id = User.count

    @staticmethod
    def valid_email(text):
        if text.count("@") != 1:
            return False
        local, domain = text.split("@")
        return len(local) > 0 and "." in domain

    @classmethod
    def from_string(cls, s):
        name, rest = s.split(" <", 1)
        return cls(name, rest.rstrip(">"))

Exercise 4 · quiz

Name mangling

Inside class Vault: a method does self.__code = 1234. From outside the class, which expression reads that value?

Answer
  • v._Vault__code

Names starting with two underscores (and not ending with two) inside a class body are mangled by the compiler to _ClassName__name. It is not real privacy, just a way to avoid accidental clashes with attributes of subclasses. Python's privacy is a convention: a single leading underscore means "internal, do not touch", and everyone respects it.