Classes I: Objects and Encapsulation
A function packages a piece of logic. A class packages data together with the logic that acts on it. Once the things in your program start having their own state and behavior, it is time for classes.
By the end of this lecture you should be able to
- Explain the relationship between a class and an object, and why binding data to functions helps
- Write a class with __init__ and instance methods
- Say what self is and why it must be written
- Distinguish instance variables from class variables
- Use a leading underscore to signal "this is internal"
Contents
First, try it with what you already have
Suppose you want to represent points on a plane and measure distances. With what you know now:
def make_point(x: float, y: float) -> dict[str, float]:
return {"x": x, "y": y}
def distance(p1: dict[str, float], p2: dict[str, float]) -> float:
return ((p1["x"] - p2["x"]) ** 2 + (p1["y"] - p2["y"]) ** 2) ** 0.5
a = make_point(0, 0)
b = make_point(3, 4)
print(distance(a, b))
It works. But several things chafe:
- The data and the operations are separate.
make_pointanddistancemerely happen to both deal with points; nothing in the language says they belong together - Nothing is protected. Anyone can write
a["x"] = "abc"ordel a["y"], anddistancefalls over afterward - It reads awkwardly.
distance(a, b)says “distance(a, b)”, not “a’s distance to b” - It is verbose. Every access is
p["x"]
When a thing in your program has both its own data and operations that belong to it, package them together. That is a class.
Classes and objects
class Point:
pass
p = Point()
print(p)
print(type(p))
- A class is a template, describing what things of this kind look like and can do
- An object is one thing built from that template, also called an instance
The relationship is that of “dog” to “your dog”. Point is the concept; p is one particular point.
You have in fact been using objects all along — L2 said type() returns the type, and every type is a class:
print(type(42), type("abc"), type([1, 2]), type({}))
"abc".upper() calls a method on a str object. You simply had not noticed.
__init__: doing something at creation time
The Point() above produces an empty object. We want the coordinates set as it is built:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
p = Point(3, 4)
print(p.x, p.y)
__init__ is a special method that Python calls automatically when you write Point(3, 4). The double underscores on both sides are a convention meaning “this is for the interpreter, not for you to call directly”.
Its job is to initialize the new object — put every attribute it should have in place.
What self actually is
This is the lecture’s main obstacle, and it repays some attention.
Observation one: declared, but never passed
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def show(self) -> None: # declared with a parameter
print(f"({self.x}, {self.y})")
p = Point(3, 4)
p.show() # called with none
show(self) declares one parameter and p.show() supplies zero. Where did it go?
Observation two: p.show() really is Point.show(p)
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(f"({self.x}, {self.y})")
p = Point(3, 4)
p.show() # how you normally write it
Point.show(p) # exactly equivalent, and this is what is happening
p.show() is syntactic sugar. Python passes whatever is left of the dot as the first argument.
So self is “the object this method was called on”.
Observation three: why it has to exist
All objects of a class share one copy of the method code:
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(f"({self.x}, {self.y})")
a = Point(0, 0)
b = Point(3, 4)
print("same underlying function?", a.show.__func__ is b.show.__func__)
a.show()
b.show()
One piece of code, two different outputs — because self points somewhere different each time. Without self, a method would not know who it is working for.
The name self is not a keyword and could technically be changed. But everyone writes self, and changing it only confuses your reader — exactly as with args and kwargs in L4.
Filling the class out
class Point:
"""A point on a plane."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def distance_to(self, other: "Point") -> float:
"""Distance to another point."""
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
def move(self, dx: float, dy: float) -> None:
"""Translate this point."""
self.x += dx
self.y += dy
def show(self) -> None:
print(f"({self.x}, {self.y})")
a = Point(0, 0)
b = Point(3, 4)
print(a.distance_to(b))
a.move(1, 1)
a.show()
print(a.distance_to(b))
Set against the dictionary version:
a.distance_to(b)reads as “a’s distance to b”- data and operations sit together, so the class tells you what a point can do
- adding a capability means adding a method, not hunting through a file
Encapsulation
Binding data to the behavior that acts on it is called encapsulation.
The problem it solves is scale. In a program of a few thousand lines, if all the data is loose dictionaries and lists and all the functions sit on one flat plane, you cannot tell what a change will affect.
With classes, the boundaries are visible:
- want to know what a point can do → read the
Pointclass - want to change how points work → change only inside
Point - want to use a point → you need the method names, not the internal storage
This is the same argument L3 made for functions, one scale up: a function packages a piece of logic; a class packages a set of related data and logic.
A more substantial example
class BankAccount:
"""A simplified bank account."""
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner
self.balance = balance
self.history: list[str] = []
def deposit(self, amount: float) -> None:
if amount <= 0:
print("deposits must be positive")
return
self.balance += amount
self.history.append(f"deposited {amount}")
def withdraw(self, amount: float) -> bool:
if amount > self.balance:
print(f"insufficient funds: wanted {amount}, have {self.balance}")
return False
self.balance -= amount
self.history.append(f"withdrew {amount}")
return True
def statement(self) -> str:
lines = [f"{self.owner}'s account, balance {self.balance}"]
lines.extend(" " + h for h in self.history)
return "\n".join(lines)
acc = BankAccount("Alex", 100)
acc.deposit(50)
acc.withdraw(30)
acc.withdraw(1000) # refused
print(acc.statement())
Notice the check inside withdraw — this is precisely what encapsulation buys. If the balance were merely a variable lying around, anyone could set it negative. Inside the class, every change goes through a method, and the method can hold the line.
Instance variables and class variables
Attributes like self.x are instance variables — one per object, independent of each other.
Sometimes you want something shared by every object, which goes directly in the class body:
class Dog:
species = "Canis familiaris" # class variable: shared by all dogs
def __init__(self, name: str) -> None:
self.name = name # instance variable: each dog's own
a = Dog("Rex")
b = Dog("Biscuit")
print(a.name, b.name) # different
print(a.species, b.species) # the same
print(Dog.species) # reachable from the class too
Counting is a common use:
class Student:
count = 0 # how many have been created
def __init__(self, name: str) -> None:
self.name = name
Student.count += 1 # note Student.count, not self.count
Student("Alex"); Student("Blake"); Student("Chen")
print("students created:", Student.count)
class BadBasket:
items: list[str] = []
def add(self, x: str) -> None:
self.items.append(x)
class GoodBasket:
def __init__(self) -> None:
self.items: list[str] = []
def add(self, x: str) -> None:
self.items.append(x)
b1, b2 = BadBasket(), BadBasket()
b1.add("apple")
print("BadBasket b2.items =", b2.items, "← leaked")
g1, g2 = GoodBasket(), GoodBasket()
g1.add("apple")
print("GoodBasket g2.items =", g2.items, "← correct")
Private, by convention
Some attributes are internal detail that outsiders should leave alone. Python’s answer is convention, not enforcement:
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = celsius # single underscore: internal, please don't
def to_fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
def set_celsius(self, value: float) -> None:
if value < -273.15:
print("below absolute zero; ignored")
return
self._celsius = value
t = Temperature(25)
print(t.to_fahrenheit())
t.set_celsius(-300) # blocked
t.set_celsius(100)
print(t.to_fahrenheit())
A leading underscore _name means: “this is internal; do not rely on it staying the same.” Python will not stop you touching t._celsius, but doing so ignores the author’s warning — and when they change the internals, your code breaks.
A double underscore __name triggers name mangling, which makes access more awkward:
class Secret:
def __init__(self) -> None:
self.__hidden = 42
s = Secret()
print(s.__hidden)
class Secret:
def __init__(self) -> None:
self.__hidden = 42
s = Secret()
print(s._Secret__hidden) # still reachable; the name was just rewritten
So it is not truly private either. In practice a single underscore is enough; double underscores exist mainly to stop a subclass overwriting something accidentally (L10 returns to this with inheritance).
When a class is the right answer
Not everything needs one:
| Use a class | Do not |
|---|---|
| the thing has state, and the state changes | it is a one-off data transformation |
| a group of operations belongs to that data | only one function ever touches it |
| there will be many instances of the same kind | there is exactly one, globally |
| the data’s validity needs protecting | the data is read-only anyway |
Summary
- A class is a template; an object is an instance built from it.
type(x)returns x’s class __init__is called automatically at creation and initializes the attributesselfis the object the method was called on.p.show()isPoint.show(p)- Forgetting
self→TypeError: ... takes 0 positional arguments but 1 was given - Encapsulation: data and its behavior packaged together, with methods enforcing the rules
- Instance variables are per-object; class variables are shared
- Never make a class variable mutable — the same trap as a mutable default argument
- A leading underscore
_xmarks internals — a convention, not enforcement - A one-method class should be a function; a data-only class should be a dictionary
Exercises
- Write a
Rectangleclass withwidthandheight, offeringarea(),perimeter()andis_square(). - Add a
scale(factor)method toRectanglethat multiplies both dimensions. Should it modify the object or return a new one? Justify your choice. - Write a
Counterclass supportingadd(item),get(item)andmost_common(). Store the data in an instance variable. - What is wrong with this class? Fix it:
class Playlist: songs = [] def add(self, song): self.songs.append(song) - Write a
Stackclass (stacks came up in L7 when discussing the call stack) offeringpush,pop,peekandis_empty. What shouldpopdo on an empty stack? Explain your design. - Rewrite the results-file parsing from the L6 exercises as a
Gradebookclass offeringadd(name, subject, score),average(name)andtop_student().
The lab problems for this lecture are in Lab 9.