Classes II and Modules
Two halves. The first is inheritance and polymorphism — giving classes relationships to each other. The second is modules: packing classes and functions into files, and a look at how Python actually runs your code.
By the end of this lecture you should be able to
- Reuse code through inheritance and change a subclass's behavior by overriding
- Make your own classes work with +, [], print() and other built-in syntax
- State the difference between == and is
- Explain what import does and where Python looks for modules
- Say what __name__ == "__main__" achieves
Contents
First half: inheritance and polymorphism
Classes can relate to each other
Every class in L9 stood alone. Real concepts, though, come in hierarchies: a dog is an animal, so is a cat; a square is a rectangle; an administrator is a user.
Inheritance is the mechanism for expressing that “is a kind of” relationship.
class Animal:
def __init__(self, name: str) -> None:
self.name = name
def speak(self) -> str:
return "..."
def introduce(self) -> str:
return f"I am {self.name} and I say: {self.speak()}"
class Dog(Animal): # Dog inherits from Animal
def speak(self) -> str:
return "woof"
class Cat(Animal):
def speak(self) -> str:
return "meow"
for a in [Animal("Creature"), Dog("Rex"), Cat("Biscuit")]:
print(a.introduce())
Look carefully at what happened:
Dogdefines neither__init__norintroduce, yet has both — inherited fromAnimalDogdefines its ownspeak, which replaces the parent’s versionintroduceis written once, but theself.speak()inside it takes a different branch depending on the actual object
That last point is the crucial one, and we return to it shortly.
Terminology: Animal is the parent or base class, Dog the child or derived class.
Overriding and super()
A subclass redefining a method it inherited is overriding.
Overriding __init__ usually means “do what the parent does, then add mine”:
class Person:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def describe(self) -> str:
return f"{self.name}, {self.age}"
class Student(Person):
def __init__(self, name: str, age: int, school: str) -> None:
super().__init__(name, age) # let the parent set name and age
self.school = school # then add our own
def describe(self) -> str:
return super().describe() + f", studying at {self.school}"
s = Student("Alex", 18, "SJTU")
print(s.describe())
print(s.name, s.age, s.school)
super() means “the parent’s version of this”. It buys two things:
- you do not repeat the parent’s initialization
- when the parent changes, the child follows automatically
Polymorphism: one call, different behavior
Back to that crucial point:
class Shape:
def area(self) -> float:
raise NotImplementedError("subclasses must implement area")
class Circle(Shape):
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return 3.14159 * self.r ** 2
class Square(Shape):
def __init__(self, side: float) -> None:
self.side = side
def area(self) -> float:
return self.side ** 2
class Rectangle(Shape):
def __init__(self, w: float, h: float) -> None:
self.w, self.h = w, h
def area(self) -> float:
return self.w * self.h
shapes: list[Shape] = [Circle(1), Square(2), Rectangle(2, 3)]
total = 0.0
for s in shapes:
print(f"{type(s).__name__:10} area {s.area():.4f}")
total += s.area()
print(f"total {total:.4f}")
That loop has no idea whether it is handling a circle or a square. All it knows is that each thing has an area(). This is polymorphism: one call, behaving differently according to the type it lands on.
Its value is extension: add a triangle later and the loop above does not change by a single character.
Duck typing: Python does not check ancestry
The Shape base class above is in fact optional. Python cares whether the object has the method, not what it inherits from:
class Circle: # inherits from nothing
def area(self) -> float:
return 3.14159
class Square: # likewise
def area(self) -> float:
return 4.0
for s in [Circle(), Square()]: # works regardless
print(s.area())
This is duck typing, from the saying:
If it walks like a duck and quacks like a duck, it is a duck.
Python decides whether something can be used a certain way by whether it has the method, not by what type it is.
It is also why len() works on strings, lists, dictionaries and sets — they share no common parent, they merely all implement __len__.
Making your class work with built-in syntax
Python has a family of special methods (double underscores on both sides, colloquially “dunders”). Implement them and your objects work with the ordinary syntax.
__str__ and __repr__
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
p = Point(3, 4)
print(p) # the default, which tells you nothing useful
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def __str__(self) -> str:
"""for humans"""
return f"({self.x}, {self.y})"
def __repr__(self) -> str:
"""for developers — ideally something you could paste back in"""
return f"Point({self.x}, {self.y})"
p = Point(3, 4)
print(p) # uses __str__
print(str(p)) # uses __str__
print(repr(p)) # uses __repr__
print([p, p]) # elements inside a container use __repr__
Operator overloading
class Vector:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Vector") -> "Vector":
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, k: float) -> "Vector":
return Vector(self.x * k, self.y * k)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __abs__(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
a = Vector(1, 2)
b = Vector(3, 4)
print(a + b)
print(b - a)
print(a * 3)
print(abs(b))
print(a == Vector(1, 2))
print(a == b)
The common ones:
| What you write | What it calls |
|---|---|
a + b | __add__ |
a - b | __sub__ |
a * b | __mul__ |
a == b | __eq__ |
a < b | __lt__ |
len(a) | __len__ |
a[i] | __getitem__ |
a[i] = v | __setitem__ |
x in a | __contains__ |
for x in a | __iter__ (see L13) |
print(a) | __str__ |
abs(a) | __abs__ |
Supporting indexing
class Playlist:
def __init__(self) -> None:
self._songs: list[str] = []
def add(self, song: str) -> None:
self._songs.append(song)
def __len__(self) -> int:
return len(self._songs)
def __getitem__(self, i: int) -> str:
return self._songs[i]
def __contains__(self, song: str) -> bool:
return song in self._songs
pl = Playlist()
pl.add("first"); pl.add("second"); pl.add("third")
print(len(pl))
print(pl[0], pl[-1])
print("second" in pl)
for song in pl: # __getitem__ makes for work too
print(" -", song)
== versus is
These get confused constantly, and L5’s reference model explains them exactly.
==compares values — do these two have the same contents (definable via__eq__)iscompares identity — do these two names point at the same object (equivalent toid(a) == id(b))
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print("a == b:", a == b) # same contents
print("a is b:", a is b) # but two different lists
print("a is c:", a is c) # c and a are the same object
print(id(a), id(b), id(c))
Introspection: asking an object what it is
class Animal: pass
class Dog(Animal): pass
d = Dog()
print(type(d)) # what type is it
print(isinstance(d, Dog)) # is it a Dog
print(isinstance(d, Animal)) # also an Animal, through inheritance
print(issubclass(Dog, Animal)) # is Dog a subclass of Animal
print(Dog.__mro__) # the method resolution order
isinstance accounts for inheritance; type() == does not:
class Animal: pass
class Dog(Animal): pass
d = Dog()
print(isinstance(d, Animal)) # True
print(type(d) == Animal) # False
Prefer isinstance for type checks. It holds for subclasses too, which is usually what you meant.
class Point:
def __init__(self, x: float, y: float) -> None:
self.x, self.y = x, y
def show(self) -> None: pass
p = Point(1, 2)
print(p.__dict__) # this object's instance variables
print([n for n in dir(p) if not n.startswith("_")]) # its public members
dir() and __dict__ are genuinely useful while debugging — when you are unsure what an object has, ask it.
Everything is an object
Including functions and classes themselves:
def greet(name: str) -> str:
return f"hello, {name}"
print(type(greet))
print(greet.__name__)
f = greet # assign a function to a variable
print(f("Alex"))
def apply(func, value): # pass a function as an argument
return func(value)
print(apply(greet, "Blake"))
print(apply(str.upper, "abc"))
L14 develops this into “functions are first-class citizens”, the foundation of functional programming.
Second half: modules and the interpreter
One file is not enough
As a program grows, piling every class and function into one file stops working. A module is a .py file holding a set of related definitions.
You have been using them already:
import math
print(math.sqrt(16), math.pi)
math is a module.
The forms of import
# 1. import the module and use module.member
import math
print(math.sqrt(16))
# 2. with an alias
import math as m
print(m.sqrt(16))
# 3. import only the names you need
from math import sqrt, pi
print(sqrt(16), pi)
# 4. and alias those
from math import sqrt as square_root
print(square_root(16))
# 5. import everything — do not do this
from math import *
print(sqrt(16))
What import actually does
import mymodule does three things:
- finds the file for that module
- executes all the code in it, top to bottom, once
- binds the result to a name
Step 2 matters — import really does run the file. A bare print("loaded") in a module prints when it is imported.
And it runs only once:
import math
import math # the second import does not re-execute
import math
print("How many times was math executed? Only the first import did anything.")
print(math.pi)
Python records loaded modules in sys.modules and serves later imports from that cache.
import sys
print("modules currently loaded:", len(sys.modules))
print("is math among them:", "math" in sys.modules)
Where Python looks
import sys
for p in sys.path[:5]:
print(repr(p))
sys.path is a list of directories searched in order, roughly:
- the directory of the running script
- directories named in the
PYTHONPATHenvironment variable - the standard library
- installed third-party packages (
site-packages)
__name__ and __main__
Every module has a __name__:
- run the file directly and
__name__is"__main__" - import it and
__name__is the module’s name
print("__name__ here is:", __name__)
Which produces an extremely common idiom:
# mymath.py
def add(a: int, b: int) -> int:
return a + b
def main() -> None:
print(add(2, 3))
print(add(10, 20))
if __name__ == "__main__":
main()
It means: “only run main() when this file is executed directly.”
Why does that matter? Because someone importing mymath wants the add function, not your test code running as a side effect.
Packages: directories of modules
Several modules in a directory form a package:
mypackage/
├── __init__.py # marks it as a package (optional in modern Python)
├── geometry.py
└── stats.py
from mypackage import geometry
from mypackage.stats import mean
pip: installing other people’s packages
pip install numpy # install
pip install numpy==1.26.0 # a specific version
pip list # what is installed
pip uninstall numpy # remove
python3 -m pip install numpy # the recommended form
Libraries you will meet:
| Library | For |
|---|---|
| NumPy | numerical computing, n-dimensional arrays. The team project builds a simplified version |
| Matplotlib | plotting |
| SciPy | scientific computing: optimization, integration, statistics |
| pandas | tabular data |
| pytest | testing (used in L11) |
How your code actually runs
L1 said Python is interpreted. Here is the finer picture.
Two steps:
- compile to bytecode — an intermediate form, closer to the machine than source but still not machine code
- a virtual machine executes the bytecode
import dis
def add(a, b):
return a + b
dis.dis(add)
That is add’s bytecode. Read it roughly as: fetch the two parameters onto a stack, perform one binary operation (BINARY_OP), then return the top of the stack (RETURN_VALUE).
The exact instruction names change between Python versions — you do not need to memorize them. Seeing the “fetch, operate, return” skeleton is enough.
It also explains the __pycache__ directory you have seen: the .pyc files inside are cached bytecode, so importing the same module again skips recompilation.
Summary
Inheritance and polymorphism
- Inheritance expresses “is a kind of”; a subclass gets the parent’s attributes and methods
- Overriding changes a subclass’s behavior;
super()reaches the parent’s version - A subclass
__init__almost always starts withsuper().__init__(...) - Polymorphism: one call behaving differently per type, which is what makes code extensible
- Duck typing: Python checks for the method, not the type
- Special methods make your class work with
+,[],len(),print()and the rest - If you write only one, write
__repr__— it is what shows inside containers ==compares value,iscompares identity. Useisonly againstNone- Prefer
isinstancefor type checks; it accounts for subclasses
Modules and the interpreter
- A module is a
.pyfile;importexecutes it, exactly once - Never
from module import * - Python searches
sys.path, current directory first — do not name files after standard library modules if __name__ == "__main__":lets a file both run directly and import cleanly- Use
python3 -m pip installto hit the right interpreter - Python compiles to bytecode and a virtual machine runs it;
__pycache__holds the cache
Exercises
- Write a
Shapebase class withCircle,RectangleandTrianglesubclasses, all implementingarea()andperimeter(). Write a function taking a list ofShapeand returning the total area. - Add a
SavingsAccountsubclass to L9’sBankAccount, with aninterest_rateattribute and anadd_interest()method. Usesuper(). - Give a
Moneyclass__add__,__sub__,__eq__,__lt__and__repr__, so thatMoney(10) + Money(5)works andsorted([...])orders them. - Explain why these two print differently:
(Hint: Python caches small integers and short strings. Which is exactly why you should not usea = [1, 2]; b = [1, 2] print(a == b, a is b) x = "hi"; y = "hi" print(x == y, x is y)isto compare values.) - Write a module
stats.pywithmean,medianandmode, plus self-test code underif __name__ == "__main__":. Import it from another file and confirm the self-test does not run. - Create a
random.pyin your directory containingprint("this is mine"), then from another file in the same directory runimport random; random.randint(1, 10). Record what happens and explain why.
The lab problems are in Lab 10, and solo project B opens this week.