Part 2 · Intermediate Python · Module 10
Classes & Objects
Every value you have used so far, from 3 to "hello" to [1, 2], is an object built from a class. In this module you learn to write your own classes, so that data and the operations that keep it valid travel together as one unit.
- Explain what a class, an instance, an attribute, and a method are, and how they relate
- Write
__init__methods and explain exactly whatselfis - Tell class attributes from instance attributes and avoid the shared-mutable-attribute trap
- Protect invariants with
@propertygetters, setters, and validation - Use
@classmethodfor alternative constructors and@staticmethodfor helpers - Apply Python's encapsulation conventions (
_name,__namemangling) and inspect__dict__ - Design a small class from written requirements and draw it as a UML class diagram
Why object-oriented programming?
Imagine you are tracking bank accounts with the tools from Part 1. You might store each account as a dictionary and write functions that operate on it:
account = {"owner": "Ada", "balance": 100.0}
def deposit(acct, amount):
if amount <= 0:
raise ValueError("deposit must be positive")
acct["balance"] += amount
deposit(account, 50)
account["balance"] = -1_000_000 # nothing stops this!
This works, but it has three problems that grow with the size of a program:
- Nothing ties the data to its rules. The
depositfunction checks that amounts are positive, but any other line of code can write straight intoaccount["balance"]and bypass the check. - The shape is implicit. Is the key
"balance"or"bal"? A typo creates a new key silently instead of failing. - Related functions are scattered. Nothing groups
deposit,withdraw, andstatementtogether; a reader has to hunt for them.
Object-oriented programming (OOP) answers all three by bundling state (data) and behavior (functions) into one unit called an object. The blueprint for a kind of object is a class. The class decides what data each object holds and which operations are allowed, so the rules live right next to the data they protect. The set of rules that must always hold for an object (for example, "the balance is never negative") is called its invariant, and a well-designed class makes it impossible to break its invariant through its public interface.
OOP is not the only good style. Python happily mixes plain functions, modules, and classes, and you will learn functional techniques in M18. Reach for a class when you have state that must stay consistent plus operations on that state. If you only have data, a dataclass or dict is often enough (M13); if you only have behavior, a function is enough.
Remember: a class bundles data with the rules and operations for that data, so invariants are enforced in one place.
Classes and instances
A class statement creates a new type. Calling the class like a function creates an instance of that type: a new, separate object. You already know this pattern: list() creates a new list, and int("5") creates an int. Your own classes work the same way.
class Dog:
"""A dog with a name.""" # a docstring, just like functions
rex = Dog() # call the class to make an instance
fido = Dog() # a second, independent instance
print(type(rex)) # <class '__main__.Dog'>
print(isinstance(rex, Dog)) # True
print(rex is fido) # False: two distinct objects
Vocabulary you will use for the rest of the course:
- Class: the blueprint, such as
Dog. By convention class names useCapWords(PEP 8). - Instance (or object): one concrete thing built from the class, such as
rex. - Attribute: a name attached to an object, read with a dot:
rex.name. - Method: a function defined inside a class and called through an instance:
rex.bark().
Python is flexible: you can attach attributes to an instance at any time. That flexibility is useful for exploration but dangerous in real code, because each instance could end up with a different shape. Run this to see it:
The fix is to give every instance the same set of attributes the moment it is created. That is the job of __init__.
Remember: the class is the blueprint, calling it creates an instance, and attributes are names hanging off an object.
__init__ and self
When you call Dog("Rex", 3), Python does two things. First it creates a brand-new, empty instance. Then it calls the class's __init__ method, passing the new instance as the first argument, followed by your arguments. __init__ is called an initializer (people often say "constructor", which is close enough). Its job is to put the new object into a valid starting state.
class Dog:
def __init__(self, name, age):
self.name = name # store on THIS instance
self.age = age
def describe(self):
return f"{self.name} is {self.age}"
rex = Dog("Rex", 3)
print(rex.describe()) # Rex is 3
The first parameter, self, is the instance being worked on. It is not a keyword; it is an ordinary parameter name that every Python programmer agrees to use. Inside a method, self.name = name means "attach an attribute called name to this particular object." Without self., name would just be a local variable that vanishes when __init__ returns.
Where do the attributes actually live? Each instance has its own dictionary, __dict__, that maps attribute names to values. Step through the tracer below and watch two instances being built. Notice that self and the variable that eventually receives the object carry the same "obj N" identity tag: they are the same object.
Three things to take from the trace:
__init__runs once per instance, withselfbound to the new object.rex.birthday()changed only Rex'sage; Fido's dictionary is untouched. Each instance has independent state.__init__must returnNone. If you writereturn somethingin it, Python raisesTypeError, because the call to the class already returns the new instance.
Returning self from a method, as increment does, enables method chaining: a.increment().increment(). It is a stylistic choice; many methods that mutate an object return None instead (like list.append) to make it obvious that they mutate.
Remember: __init__ receives the brand-new instance as self; anything you want to keep must be stored as self.something.
Attributes and methods in depth
A method is just a function stored on the class. When you look it up through an instance, Python produces a bound method: a small object that remembers both the function and the instance. Calling the bound method calls the function with the instance inserted as the first argument. These two lines do exactly the same thing:
rex.describe() # the normal way
Dog.describe(rex) # what Python does for you
This is why self must be the first parameter. There is no hidden magic: the dot lookup simply pre-fills that argument.
Attribute lookup order
When you read obj.attr, Python first checks the instance's own __dict__. If the name is not there, it checks the class (and later, in M12, the parent classes). Only if all of those fail do you get AttributeError. Writing obj.attr = value always writes into the instance's __dict__; it never modifies the class. That asymmetry (reads fall through to the class, writes stay on the instance) is the key to the next section.
Methods that keep invariants
Good methods do more than read and write fields. They express operations in the problem's own language, and they validate inputs so the object can never enter an invalid state. Compare account.balance -= 50 with account.withdraw(50): the method can refuse an overdraft, record a transaction, and raise a clear error.
class Thermostat:
def __init__(self, target):
self.target = target
def raise_by(self, degrees):
if degrees <= 0:
raise ValueError("degrees must be positive")
self.target += degrees
Remember: obj.method(x) is Class.method(obj, x). Reads search the instance, then the class; writes go to the instance.
Class attributes vs instance attributes
A name assigned directly in the class body (not inside a method) is a class attribute. It lives once, in the class's own namespace, and every instance can read it through the lookup fallback you just learned. Class attributes are perfect for constants and shared configuration:
class Circle:
PI = 3.14159 # class attribute: shared, defined once
def __init__(self, radius):
self.radius = radius # instance attribute: one per object
def area(self):
return self.PI * self.radius ** 2
Because writes go to the instance, assigning c.PI = 3 does not change the class. It creates a new instance attribute that shadows (hides) the class attribute for that one object. Other circles still see Circle.PI.
The shared-mutable trap
Shadowing protects you when you assign. It does not protect you when you mutate. If a class attribute holds a mutable object such as a list, then self.items.append(x) reads the class's list (via the fallback) and modifies it in place. Every instance shares that single list. This is the class-level cousin of the mutable default argument bug from M05.
In the trace, a.items and b.items carry the same identity tag: one list. In FixedBasket, each instance builds its own list inside __init__, so the tags differ. The references visualizer shows the same idea with plain names: two names, one object.
A legitimate use of a shared mutable class attribute is counting or registering instances. Note that you must update it through the class name; self.count += 1 would read the class value and then write a new instance attribute, leaving the class counter unchanged.
Remember: class attributes are shared; instance attributes are per-object. Never put a per-object mutable container in the class body.
Properties: attribute syntax, method power
Suppose your Temperature class started with a plain self.celsius attribute, and hundreds of lines elsewhere read and write t.celsius. Later you realize that values below absolute zero (−273.15 °C) must be rejected. In Java you would have needed getCelsius()/setCelsius() methods from day one. In Python you don't: you can swap the plain attribute for a property later without changing any calling code.
A property is a method that is accessed like an attribute. The @property decorator turns a method into a getter; @name.setter attaches a setter that runs on assignment. (A decorator is a function that wraps another function; you will build your own in M15. For now, treat @property as special syntax.)
class Temperature:
ABSOLUTE_ZERO = -273.15
def __init__(self, celsius):
self.celsius = celsius # goes through the setter below!
@property
def celsius(self):
"""Temperature in degrees Celsius."""
return self._celsius
@celsius.setter
def celsius(self, value):
if value < self.ABSOLUTE_ZERO:
raise ValueError(f"{value} is below absolute zero")
self._celsius = float(value)
@property
def fahrenheit(self): # read-only, computed on demand
return self._celsius * 9 / 5 + 32
The real value is stored in self._celsius (the underscore says "internal", see the next sections). The public name celsius is the property. Two details make this robust:
__init__assignsself.celsius, notself._celsius, so even the initial value is validated. There is exactly one place where the rule lives.fahrenheithas no setter, so it is read-only: assigning to it raisesAttributeError. It is computed, so it can never fall out of sync withcelsius.
Remember: a property lets you add validation or computation behind attribute syntax, so you can start simple and tighten the rules later without breaking callers.
@classmethod and @staticmethod
Ordinary methods receive the instance. Sometimes you need a method that works with the class itself, before any instance exists. The most common case is an alternative constructor: a second way to build an object from a different kind of input. The built-ins are full of them: dict.fromkeys(...), int.from_bytes(...), datetime.fromisoformat(...).
A @classmethod receives the class as its first argument, conventionally named cls. Calling cls(...) inside it builds an instance. Using cls rather than the hard-coded class name means subclasses automatically get instances of the subclass (you will see why that matters in M12).
A @staticmethod receives neither the instance nor the class. It is a plain function that lives inside the class's namespace because it belongs there conceptually, such as a validation helper.
| Kind | First argument | Typical use |
|---|---|---|
| Instance method | self (the instance) | Read or change one object's state |
@classmethod | cls (the class) | Alternative constructors, class-wide configuration |
@staticmethod | none | Helpers related to the class that need no state |
Remember: use @classmethod (with cls) for alternative constructors; use @staticmethod sparingly for stateless helpers.
Encapsulation, name mangling, and __dict__
Encapsulation means hiding an object's internal details behind a public interface so that callers depend only on what you promise. Many languages enforce it with private keywords. Python instead relies on conventions and trusts programmers: "we are all consenting adults here."
| Spelling | Meaning | Enforced? |
|---|---|---|
name | Public: part of the interface; callers may use it | n/a |
_name | Internal: "please don't touch, it may change without notice" | No (convention only; also hidden from from module import *) |
__name | Class-private: renamed to _ClassName__name to avoid clashes in subclasses | Partly (name mangling) |
__name__ | Special ("dunder") names reserved for Python's protocols | Don't invent your own |
Name mangling happens at compile time: inside a class body, any identifier written __secret (two leading underscores, at most one trailing) is rewritten to _ClassName__secret. Its purpose is not security; it prevents a subclass from accidentally overwriting a parent's internal attribute with the same name. Anyone can still reach the value via the mangled name.
__dict__ (or the built-in vars(obj)) is an invaluable debugging tool: it shows exactly what an instance stores. The class has its own __dict__ holding methods and class attributes, which is why vars(Vault) lists check.
Remember: _name means internal by convention; __name is mangled to _Class__name to avoid subclass clashes, not to provide security. vars(obj) shows what an object really stores.
Designing a class from requirements
Writing the syntax is the easy part. The hard part, and the skill that separates professionals, is deciding what the class should be. Here is a repeatable process, applied to a real requirement:
"A library lends books to members. A member can borrow at most three books at a time. Each loan records the date it was borrowed. Members can return books, and staff need to see which books a member currently has."
- Find the nouns. library, book, member, loan, date. Nouns that have their own data and rules become classes:
Book,Member,Loan. Simple values (a date, a title) stay as attributes. - Find the verbs. borrow, return, list current books. Verbs become methods on the class that owns the data they change:
Member.borrow(book),Member.return_book(book),Member.current_books(). - State the invariants. "At most three active loans." "A book can't be returned if it wasn't borrowed." Each becomes a check in a method that raises a clear exception.
- Choose the public interface. What must callers be able to do? Everything else gets an underscore. Here the list of loans is internal (
_loans); callers usecurrent_books(). - Draw it. A UML class diagram shows each class as a box with attributes and methods, and lines for relationships.
+marks public,-marks internal.
classDiagram
class Member {
+str name
+int MAX_LOANS$
-list~Loan~ _loans
+borrow(book, on) Loan
+return_book(book) None
+current_books() list~Book~
}
class Loan {
+Book book
+date borrowed_on
}
class Book {
+str isbn
+str title
}
Member "1" --> "0..3" Loan : holds
Loan "*" --> "1" Book : of
The $ after MAX_LOANS marks a class (static) attribute. The arrow labels give multiplicity: one member holds zero to three loans. Translating the diagram into code is now mechanical:
Remember: nouns become classes, verbs become methods, invariants become checks, and the public interface is the smallest set of operations callers need.
Practice
Each exercise runs automated tests against your code. Work through them in order; they build from syntax to design.
Write a class Rectangle whose initializer takes width and height and stores them as attributes with those names. Add methods area() and perimeter(), and a method is_square() that returns True when width equals height.
Hint
Inside every method, reach the data through self.width and self.height.
Show solution
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def is_square(self):
return self.width == self.height
The Playlist class below has the shared-mutable bug: every playlist shares one list of songs. Fix it so each playlist has its own songs. Also keep a class attribute created that counts how many playlists have ever been made (starting at 0), and give each playlist a name. add(song) appends a song; songs must be a list attribute.
Hint
Create the list in __init__ with self.songs = []. For the counter, write Playlist.created += 1; self.created += 1 creates a shadowing instance attribute.
Show solution
class Playlist:
created = 0
def __init__(self, name):
self.name = name
self.songs = []
Playlist.created += 1
def add(self, song):
self.songs.append(song)
Write Temperature(celsius) with a celsius property that raises ValueError for values below -273.15 (including in the initializer), and a fahrenheit property with both a getter (c * 9 / 5 + 32) and a setter that converts back and stores Celsius (so setting an impossible Fahrenheit also raises ValueError).
Hint
Store the value in self._celsius. The Fahrenheit setter can simply do self.celsius = (value - 32) * 5 / 9, reusing the Celsius validation.
Show solution
class Temperature:
ABSOLUTE_ZERO = -273.15
def __init__(self, celsius):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < self.ABSOLUTE_ZERO:
raise ValueError(f"{value} C is below absolute zero")
self._celsius = float(value)
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5 / 9
Write a class Color with attributes r, g, b (ints 0–255). Add:
- a
@staticmethodclamp(value)that limits an int to the range 0–255; - an initializer that clamps each component;
- a
@classmethodfrom_hex(text)that accepts"#ff8800"or"ff8800"and returns aColor(useint(part, 16)); - a method
to_hex()returning lowercase"#rrggbb".
Hint
text.lstrip("#") removes the hash. Slice pairs with text[0:2], text[2:4], text[4:6]. Format with f"{n:02x}".
Show solution
class Color:
def __init__(self, r, g, b):
self.r = self.clamp(r)
self.g = self.clamp(g)
self.b = self.clamp(b)
@staticmethod
def clamp(value):
return max(0, min(255, int(value)))
@classmethod
def from_hex(cls, text):
digits = text.lstrip("#")
if len(digits) != 6:
raise ValueError(f"expected 6 hex digits, got {text!r}")
r, g, b = (int(digits[i:i + 2], 16) for i in (0, 2, 4))
return cls(r, g, b)
def to_hex(self):
return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
Design a Cart class. Requirements:
add(name, price, qty=1)adds an item; adding the same name again increases its quantity.pricemust be >= 0 andqty>= 1, otherwise raiseValueError.remove(name)removes an item entirely; raiseKeyErrorif it isn't there.totalis a read-only property: the sum of price × quantity.countis a read-only property: total number of units.items()returns a new list of(name, price, qty)tuples in insertion order, so callers can't mutate the cart's internals.
Hint
Store {name: [price, qty]} in self._items. Use @property without a setter for total and count. Dicts keep insertion order.
Show solution
class Cart:
def __init__(self):
self._items = {}
def add(self, name, price, qty=1):
if price < 0:
raise ValueError("price must be non-negative")
if qty < 1:
raise ValueError("qty must be at least 1")
if name in self._items:
self._items[name][1] += qty
else:
self._items[name] = [price, qty]
def remove(self, name):
if name not in self._items:
raise KeyError(name)
del self._items[name]
@property
def total(self):
return sum(price * qty for price, qty in self._items.values())
@property
def count(self):
return sum(qty for _, qty in self._items.values())
def items(self):
return [(name, price, qty) for name, (price, qty) in self._items.items()]
Mini-project: a BankAccount with history
Quiz
Recap
- A class bundles state and behavior; its methods protect the object's invariants.
- Calling a class creates an instance and runs
__init__(self, ...)to set its initial state. obj.method(x)isClass.method(obj, x);selfis the instance.- Attribute reads check the instance, then the class; writes always go to the instance.
- Class attributes are shared. Never store a per-instance mutable container in the class body.
@propertyadds validation or computation behind attribute syntax; omit the setter for read-only values.@classmethodreceivesclsand is the idiom for alternative constructors;@staticmethodreceives nothing._namemeans internal;__nameis mangled to_Class__name;vars(obj)reveals what is stored.- Design from requirements: nouns to classes, verbs to methods, invariants to checks, minimal public interface.
Going further
- Python Tutorial: Classes
- Built-in
property,classmethod,staticmethod - Language reference: private name mangling
- PEP 8: Designing for inheritance (public vs non-public names)
- Descriptor HowTo Guide (how properties and methods really work)
- Next: M11 The Python Data Model