Classes I: Objects and Encapsulation
Organize related state and operations with classes. Follow initialization, method binding and attribute lookup. Distinguish instance attributes, class attributes, shared references, and class methods. Encapsulation separates public methods from internal storage.
By the end of this lecture you should be able to
- Distinguish procedural and object-oriented organization using the same task
- Distinguish a class from an instance and store per-instance state in attributes
- Explain initialization, default arguments, and the receiver of an instance method
- Check return values, output, and state changes separately
- Explain public methods and internal attributes, and compare instance, class and static methods
- Distinguish instance attributes, class attributes, shared collections, and class methods
- Choose classes, functions, or records from their state and interface requirements
Contents
Project: a two-rover expedition simulator
Build two characters, a and b, each with a position, coins, and a backpack. The Python versions implement the core object logic. A separate graphical demonstration shows the same state transitions on a map. Initially a is at (0, 0), b is at (3, 4), and both have 100 coins and empty bags. Move a by (1, 1), pay 30 coins, and pick an apple. The final states must be a at (1, 1) with 70 coins and ['apple'], and b unchanged with 100 coins and [].
Open the staged graphical demo. Preview the complete mission, then select position, coins, or backpack. Observe added attributes, methods, and shared references. The rover.html file in the download package also works offline; the Python files contain the corresponding core logic.
Why introduce a class if we already know dictionaries and functions?
The dictionary version already solves the task correctly. Repeated handwritten construction sites can drift when fields are added; a make_rover function can centralize construction. A class additionally groups initialization, retained state, and the operations using that state behind one Rover interface. a.move(...) expresses the receiver of a command.
This is an organization choice. Classes do not automatically validate inputs, isolate mutable references, or make code faster. We implement and verify those promises explicitly.
Procedural and object-oriented programming
Procedural programming organizes a program around task steps and functions that receive and process data. Dictionaries can hold the rover records, while separate functions implement movement, payments, and picking.
Object-oriented programming organizes a program around objects with state and behavior, using their interfaces for operations and collaboration. Here a class defines Rover initialization and operations. Each instance retains its own state; ordinary methods access it through the receiver. The class shares method definitions across instances. We have already used object interfaces such as items.append(...); this lecture defines our own.
The following complete programs use the same starting positions and movement algorithm. Procedural version:
def move(r, dx, dy):
r["x"] += dx
r["y"] += dy
a = {"x": 0, "y": 0}
b = {"x": 3, "y": 4}
move(a, 1, 1)
print("a:", a["x"], a["y"])
print("b:", b["x"], b["y"])
Object-oriented version:
class Rover:
def __init__(self, x, y):
self.x, self.y = x, y
def move(self, dx, dy):
self.x += dx
self.y += dy
a, b = Rover(0, 0), Rover(3, 4)
a.move(1, 1)
print("a:", a.x, a.y)
print("b:", b.x, b.y)
Both print a: 1 1 and b: 3 4. Locate where the data are established, where operations are defined, and how each call selects a rover. Revisit these questions after completing initialization, movement, and distance queries.
C and Pascal are common procedural examples; Java and Smalltalk are common object-oriented examples. Python and C++ support multiple paradigms. A Python program can combine functions and objects according to its state, operations, and maintenance needs. See the Python documentation on programming styles.
Functions are a direct starting point for a fixed calculation or data-processing pipeline. Objects can provide clear interfaces for multiple entities with persistent state and related operations. Rover groups creation, coordinates, payment rules, and backpack operations; validation and independence still depend on the implementation. This lecture covers classes, objects, and encapsulation; inheritance and polymorphism follow in L10.
Version 1: create, move, and measure
Download the position version. Initialization establishes coordinates. Ordinary method binding supplies the receiver as self; move mutates its position, whereas distance_to reads two rovers without changing either. The execution frames use smaller tests of this same interface.
Version 2: instance state and methods
Download the coin version. Extend the same Rover with _coins, established during initialization. spend updates the receiver’s attribute; coins_left reads it. self selects the instance, while amount is local to the call. The instance retains the updated attribute after the call returns.
This complete minimal program isolates the coin-related members. The full project also retains coordinates and backpack members.
class Rover:
def __init__(self, coins=100):
self._coins = coins
def spend(self, amount):
self._coins -= amount
return True
def coins_left(self):
return self._coins
a, b = Rover(), Rover()
print(a.spend(30))
print(a.coins_left(), b.coins_left())
The output is True, then 70 100. Initialization establishes a separate _coins attribute on each instance. Calling a’s method leaves b at 100. True is the call’s result; 70 is stored on a. This normal payment illustrates attributes and methods; input handling remains available in the full download.
Version 3: independent backpacks
Run the shared-backpack example and its repair to inspect both bags after only a picks an apple. A class-body items = [] lets both instances find one list in this ordinary-field example. append mutates that list; assigning a.items = [] creates an instance binding. The final version creates a list during each initialization and copies an optional starter list. Items are strings; copying the outer list is not recursive copying of nested mutable objects.
What belongs to the character type?
The shared created counter records successful constructions, not currently live instances. The class method from_pair accepts map coordinates and reuses normal initialization. valid_coins depends only on its argument and can be static; an external ordinary helper function is also valid.
Complete project and mission result
This standalone version includes all project members. Locate initialization, instance attributes, and methods first; inspect input handling as needed.
Expand the complete Rover program and output
Download the complete expedition or all numbered versions. This program runs independently without graphics dependencies:
class Rover:
created = 0
def __init__(self, x=0, y=0, coins=100, items=None):
if not Rover.valid_coins(coins):
raise ValueError("invalid coins")
self.x = x
self.y = y
self._coins = coins
if items is None:
self.items = []
else:
self.items = list(items)
Rover.created += 1
def move(self, dx, dy):
self.x += dx
self.y += dy
def distance_to(self, other):
dx = self.x - other.x
dy = self.y - other.y
return (dx * dx + dy * dy) ** 0.5
def spend(self, amount):
if (type(amount) is not int
or amount <= 0
or amount > self._coins):
return False
self._coins -= amount
return True
def coins_left(self):
return self._coins
def pick(self, item):
self.items.append(item)
@classmethod
def from_pair(cls, pair):
return cls(pair[0], pair[1])
@staticmethod
def valid_coins(value):
return type(value) is int and value >= 0
a = Rover(0, 0)
b = Rover.from_pair((3, 4))
print("start distance:", a.distance_to(b))
a.move(1, 1)
print("pay:", a.spend(30))
a.pick("apple")
print("a:", a.x, a.y, a.coins_left(), a.items)
print("b:", b.x, b.y, b.coins_left(), b.items)
Actual output:
start distance: 5.0
pay: True
a: 1 1 70 ['apple']
b: 3 4 100 []
The Point, account, temperature, and other examples below are independent extensions to consult after the rover project.
Rover: definition and execution mechanisms
Use the same rover to examine definitions, namespaces, and calls. The code package includes executable mechanism-*.py examples. Three deliberately reproduce exceptions.
Definition and execution
The indented suite after class Rover: is the class body. Assigning unit = "grid" there creates a class attribute; def move(self, dx, dy): defines a method. Executing self.x = x in a method stores an instance attribute. The class statement executes its body; def creates a function; a call executes the function body. Rover refers to the class object, while a = Rover() binds a to a new instance.
__init__ receives an already created instance, initializes it, and returns None. The class call produces the instance. Defaults, positional arguments, and keyword arguments follow ordinary function rules: compare Rover(3) and Rover(y=4). Run mechanism-init_return.py to see the TypeError caused by an invalid initialization return.
Attribute storage
For these ordinary instances, vars(a) shows the instance’s own attributes, such as {'x': 3}. Rover.__dict__ exposes the class namespace, including class attributes and method functions. Reading an ordinary data attribute checks the instance, then the class. a.unit = "pixel" creates an instance binding; deleting it reveals the class value again. L10 extends this account to inheritance and descriptors.
Method binding
a.move(1, 1) supplies a, 1, 1 to the underlying function. A bound ordinary method combines a receiver and a function: a.move.__self__ is a, and a.move.__func__ is Rover.move. Saving action = a.move retains that receiver for a later call. Run mechanism-bad_receiver.py to observe the argument mismatch when the receiver parameter is omitted.
Inside a method, call another instance method through self.coins_left(). A bare coins_left() performs ordinary name lookup; the class namespace is not an enclosing function scope. mechanism-unqualified_method.py reproduces the resulting NameError in a complete program.
A saved method and a rebound name
A bound method retains its function and receiver. Save a.move, then bind the name a to b:
class Rover:
def __init__(self, x):
self.x = x
def move(self, dx):
self.x += dx
a, b = Rover(0), Rover(10)
step = a.move
a = b
step(2)
print(step.__self__.x, a.x)
The actual output is 2 10. step still refers to the first receiver. a = b only rebinds the name. Calling step changes the first instance’s x to 2; a now refers to the second instance, whose x remains 10. step.__self__ exposes the retained receiver.
Shared data
A class-body list, two stored references to the same input list, and a mutable default such as items=[] can each cause sharing. Defaults are evaluated when the function is defined. Use None, then create a list during each call when omitted. list(items) copies the outer list; nested mutable items retain their identities. Choose copying depth according to ownership requirements.
For an integer counter, self.created += 1 can read the class value and then write a new instance attribute. Update Rover.created explicitly for the shared counter. Inside the class, __coins is rewritten to _Rover__coins; external code can still access that name.
Three method categories
An instance method receives an instance. A class method receives the class through both class and instance access. A factory’s cls(...) constructs a new instance through normal initialization. A static method receives only explicit arguments through either access form. Choose the interface according to the state the operation needs.
I. Objects and initialization
Two rover characters provide a running example: moving one must leave the other at its own position. If an apple added to one backpack appears in the other, we must trace a shared reference. Our route is create state → choose a receiver → maintain valid state → inspect sharing. The previous lecture used AI-assisted review and a debugger to inspect actual execution; we now apply that approach to objects.
Begin with a working record implementation
Last lecture established contracts and checked behavior. This lecture asks how to organize the data and operations those contracts describe.
A point needs coordinates. Related operations might display it, move it, or measure a distance. A dictionary and ordinary functions already work:
def make_point(x: float, y: float) -> dict[str, float]:
return {"x": x, "y": y}
def distance(p: dict[str, float], q: dict[str, float]) -> float:
dx = p["x"] - q["x"]
dy = p["y"] - q["y"]
return (dx * dx + dy * dy) ** 0.5
a = make_point(0, 0)
b = make_point(3, 4)
print(distance(a, b))
The result is 5.0. There is nothing inherently wrong with this organization. Functions already isolate local arguments, and modules can group code. A class offers an interface for a kind of instance and its related state and operations.
Classes can help when several records persist across calls or when their operations must follow shared rules. Merely using a class does not validate a formula, reject an invalid assignment, or establish better performance.
Existing objects, new classes
A class defines a kind of object. One object of that class is an instance. Integers, strings, lists, and dictionaries already have runtime types; "abc".upper() accesses a method through a string object.
print(type(42), type(3.5), type("abc"), type([1, 2]))
text = "hello world"
print(text.count("o"))
items = [3, 1, 2]
print(items.reverse())
print(items)
reverse() returns None while changing the list. Observing the return value does not replace checking the original state.
Define a minimal class with pass, the no-operation statement used when a suite is required:
class Point:
pass
p = Point()
print(type(p))
print(type(p) is Point)
Executing the class statement establishes Point; calling it creates an instance. The final comparison is True. A default representation may contain a changing address or an execution-dependent module name. Neither is a fixed expected answer.
The class itself is also an object referenced by the name Point. The reference section shows more built-in constructors.
Attributes are different bindings
The ordinary classes used here allow attributes to be added by assignment. An attribute belongs to the selected object, not to the external variable with the same spelling:
class Point:
pass
x = 100
p = Point()
p.x = 3
p.y = 4
print(p.x, p.y, x)
This prints 3 4 100. Updating p.x did not rebind the separate name x.
Another instance does not acquire those assignments automatically. This next program deliberately raises AttributeError:
class Point:
pass
p = Point()
p.x = 3
q = Point()
print(p.x)
print(q.x)
It prints 3 first. Being an instance of the same class does not show that the program has established every required attribute.
Initialize a new instance
In ordinary construction, __init__ initializes the new instance. The instance is passed as the first parameter, conventionally named self:
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
for p in [Point(), Point(3), Point(3, 4)]:
print(p.x, p.y)
The three lines are 0 0, 3 0, and 3 4. The argument x is local to that call; self.x = x establishes the instance attribute. Default arguments follow the rules already used for functions.
Zero coordinates are valid defaults here. That says nothing about an unrelated class: three zero edge lengths do not define a valid triangle.
Class execution is not method execution
A class statement executes its body. A def in that body establishes a function but does not execute the function body. Predict the order:
class Trace:
print("class body")
def __init__(self) -> None:
print("init")
a = Trace()
b = Trace()
The output is class body, then init twice. This class definition is reached once, followed by two ordinary constructions. Reaching a class statement again would execute it again and establish a new class.
__init__ must return None; it usually has no explicit return statement. The result of calling Point(...) is the instance, not the result returned by __init__. Creation hooks and an invalid return example are discussed later.
An alias is not a second instance
Assignments still bind references:
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
p = Point(3, 4)
alias = p
other = Point(3, 4)
alias.x = 8
print(p.x, other.x)
print(alias is p, other is p)
The lines are 8 3 and True False. alias and p select one object. other selects a separate instance with initially equal coordinates. is checks identity, not geometric equality; defining value equality is part of L10’s protocols.
An initialization contract should state the required attributes, accepted values, defaults, and failure behavior. Attribute presence and value validity are separate checks. An annotation establishes neither by itself.
II. Instance methods and receivers
The instance is supplied by binding
A regular instance method receives the selected instance first:
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(self.x, self.y)
p = Point(3, 4)
p.show()
Point.show(p)
Both calls print 3 4. For this ordinary Python-defined method, p.show() binds p, while Point.show(p) passes that receiver explicitly. self is not a keyword, but using a different name needlessly departs from the normal convention.
Do not extend this rule to every kind of method. A class method receives a class; a static method receives no implicit receiver.
The following deliberately omits a receiver parameter and raises TypeError:
class Point:
def show():
print("hi")
p = Point()
p.show()
The bound call supplies an instance to a definition that accepts no arguments. The failure concerns the parameter count, not the spelling self.
Local arguments and attributes
The same spelling can identify different bindings:
class Point:
def __init__(self, x: int) -> None:
self.x = x
def shift(self, x: int) -> None:
self.x += x
x = 100
p = Point(3)
p.shift(2)
print(p.x, x)
The result is 5 100. In shift, local x is 2; self.x initially holds 3. The external name remains bound to 100. A class does not implicitly qualify every bare name in a method with self.
Shared implementation, different calls
Instances can use the same method function with different receivers. Each call still has its own local environment:
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(self.x, self.y)
a = Point(0, 0)
b = Point(3, 4)
a.show()
b.show()
The output is 0 0, then 3 4. The differing self values select different state. This does not promise that every object reachable from those instances is unshared.
Observe mutation and results separately
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
def move(self, dx: float, dy: float) -> None:
self.x += dx
self.y += dy
p = Point(3, 4)
result = p.move(1, 0)
print(p.x, p.y, result)
This prints 4 4 None. A None result does not imply no mutation. Conversely, a numerical return annotation does not establish that a method is read-only.
Calling another method inside a method still needs the intended access path:
def message() -> str:
return "global function"
class Record:
def __init__(self, text: str) -> None:
self.text = text
def message(self) -> str:
return self.text
def report(self) -> str:
return self.message()
r = Record("instance method")
print(message())
print(r.report())
Here message() selects the external function, while self.message() selects the instance method. L14 develops the full scope rules; the immediate requirement is to distinguish a name lookup from an attribute lookup.
A complete Point interface
distance_to accepts another point. While the class body is executing in Python 3.13, the name Point has not yet been bound to the finished class. The string annotation "Point" is a forward reference. An optional future feature is covered in the reference section.
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(self.x, self.y)
def move(self, dx: float, dy: float) -> None:
self.x += dx
self.y += dy
def distance_to(self, other: "Point") -> float:
dx = self.x - other.x
dy = self.y - other.y
return (dx * dx + dy * dy) ** 0.5
a = Point(0, 0)
b = Point(3, 4)
print(a.distance_to(b))
a.move(1, 1)
a.show()
print(a.distance_to(b))
The observations are 5.0, 1 1, and approximately 3.605551275463989. These small finite values illustrate the geometry and interface, not a robust numerical library for arbitrary scales.
| Operation | State effects | Result or output |
|---|---|---|
Point(x, y) | Initializes a new instance | The class call returns it; init returns None |
show() | Does not change coordinates | Prints coordinates; returns None |
move(dx, dy) | Changes the receiver | Returns None |
distance_to(other) | Does not change either point in this implementation | Returns the distance |
These are contractual behaviors supported by the code and tests. Names and annotations alone are not evidence.
III. Encapsulation and object interfaces
Public methods and internal storage
Encapsulation organizes related data and operations behind an interface. Callers use public methods, which access attributes retained by the receiver through self.
Rover’s _coins stores coins; spend updates it; coins_left reads and returns it. Callers can use those operations without depending on the storage details. A leading underscore conventionally marks internal implementation. Ordinary Python objects still permit direct attribute access and assignment.
Underscores and name mangling
_name marks a nonpublic interface by convention. Clients should avoid relying on its representation or bypassing supported updates. The language does not enforce that request.
A double leading underscore such as __hidden triggers name mangling inside a class body. It does not make the value inaccessible:
class Secret:
def __init__(self) -> None:
self.__hidden = 42
s = Secret()
print(s._Secret__hidden)
This prints 42. This separate program deliberately accesses the unmangled spelling and raises AttributeError:
class Secret:
def __init__(self) -> None:
self.__hidden = 42
s = Secret()
print(s.__hidden)
The mechanism chiefly avoids accidental naming collisions involving subclasses. L10 returns to that use with inheritance. It is not an access-control boundary.
| Form | Meaning here |
|---|---|
_x | Nonpublic naming convention |
__x | Name mangling in a class body |
__init__ | A documented special method, not mangled by the __x rule |
Inventing a double-ended name does not automatically give it interpreter behavior.
IV. Shared data and method categories
Class attributes and instance attributes
Instance attributes commonly hold per-instance data. Class attributes are established by the class body or assignments to the class. In these ordinary examples, with no descriptors or inheritance involved, an instance without its own binding can reach a class attribute:
class Point:
dimensions = 2
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
a = Point(0, 0)
b = Point(3, 4)
print(a.x, b.x)
print(a.dimensions, b.dimensions, Point.dimensions)
Coordinates differ; the dimension queries all produce 2. A same-named ordinary instance assignment can shadow the class binding:
class Record:
label = "class"
a = Record()
b = Record()
a.label = "only A"
print(a.label, b.label, Record.label)
The line is only A class class. This does not describe the complete lookup algorithm for properties, data descriptors, or inheritance. Those require L10’s additional mechanisms.
Shared backpacks: where does the attribute live?
Continue with the classroom Rover. This minimal program retains only the backpack and the picking method so it can run on its own. Create a and b, let only a pick an apple, and inspect both bags:
class Rover:
items = []
def pick(self, item):
self.items.append(item)
a = Rover()
b = Rover()
a.pick("apple")
print("a:", a.items)
print("b:", b.items)
print(a.items is b.items)
a: ['apple']
b: ['apple']
True
a.items is b.items is True: both accesses reach the same list object. Executing the class body creates one list for the class attribute Rover.items. Neither instance has its own items binding in this example, so ordinary attribute lookup reaches the class binding. self.items.append(...) mutates that shared list, and b sees the apple too.
Each rover needs an independent backpack. Move list construction into initialization and keep pick unchanged:
class Rover:
def __init__(self):
self.items = []
def pick(self, item):
self.items.append(item)
a = Rover()
b = Rover()
a.pick("apple")
print("a:", a.items)
print("b:", b.items)
print(a.items is b.items)
a: ['apple']
b: []
False
Each Rover() call initializes its current self, evaluates [] again, and stores the new list as an instance attribute through self.items = .... The two instances receive separate lists. Picking on a leaves b empty, and the identity comparison is False.
The key distinctions are where the attribute is bound and when the list is created. The class-body assignment creates one list during this execution of the class body. The initialization assignment creates a fresh list on each initialization and binds it to that instance. Other methods can also establish instance attributes; initialization gives each new rover a complete starting state.
Also distinguish mutation from rebinding: a.items.append("apple") mutates an existing list; a.items = [] creates or updates a’s own binding and leaves b’s lookup result unchanged. Class attributes can intentionally hold shared information, such as a registry for all rovers. These backpacks require separate per-rover storage.
Shared inputs and mutable defaults
Writing an assignment inside init does not imply a copy:
class Bucket:
def __init__(self, items: list[str]) -> None:
self.items = items
shared = []
a = Bucket(shared)
b = Bucket(shared)
a.items.append("apple")
print(b.items)
print(a.items is b.items)
Again, the results are ['apple'] and True. A contract might choose items.copy() to isolate the outer list; nested mutable elements need their own analysis.
The mutable-default rule from L04 is a third source of sharing:
class Bucket:
def __init__(self, items: list[str] = []) -> None:
self.items = items
a = Bucket()
b = Bucket()
a.items.append("apple")
print(b.items)
print(a.items is b.items)
The default expression is evaluated when the function is defined. Calls omitting that argument reach one default list. Class attributes, default arguments, and repeated input references are distinct mechanisms.
Count at the intended level
A shared counter requires updating the class binding. This candidate instead creates a same-named instance attribute:
class Counter:
total = 0
def add(self) -> None:
self.total += 1
a = Counter()
b = Counter()
a.add()
print(a.total, b.total, Counter.total)
It prints 1 0 0. The read reaches class-level zero; the assignment binds the incremented value on a.
A class method is marked with the built-in @classmethod. It receives the class as its first argument, conventionally named cls. A call through the class does not require an instance. Learn this concrete marker here; L14 later explains general decorators.
class Student:
count = 0
def __init__(self, name: str) -> None:
self.name = name
Student.count += 1
@classmethod
def created_count(cls) -> int:
return cls.count
print(Student.created_count())
a = Student("Alex")
b = Student("Blake")
print(Student.created_count(), a.created_count())
The results are 0 and 2 2. The instance access also supplies the class, not a. This counter records initialization events, not currently live objects. Deleting references does not decrement an ordinary numeric attribute. If initialization can fail, define when a creation counts and increment only after successful checks.
| Category | Implicit argument | Typical use |
|---|---|---|
| Regular instance method | Instance, conventionally self | Operate on that instance |
@classmethod | Class, conventionally cls | Class-level operations |
@staticmethod | None | A related operation without receiver state |
The reference section introduces the static marker before its complete example. A function’s position in a class does not mean it must need instance state.
Create a Point from a coordinate pair
A class method can provide a named creation interface. cls receives the class; cls(...) then creates and initializes an instance. This interface assumes a pair of two coordinates.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def from_pair(cls, pair):
return cls(pair[0], pair[1])
p = Point.from_pair((3, 4))
print(type(p) is Point, p.x, p.y)
The output is True 3 4. from_pair returns the instance; __init__ returns None.
Reference: complete examples and checks
Further reading: complete account and temperature implementations
These independent examples retain their complete interfaces and input handling for optional practice. The main lecture focuses on initialization, instance attributes, method receivers, and sharing.
Define the supported account operations
Use integer cents. The opening balance must be a nonnegative integer; deposits and withdrawals take positive integers. Deposit returns None. Withdrawal reports success with a Boolean. Rejected calls leave balance and history unchanged; statement produces a string.
class BankAccount:
"""A teaching account; amounts are integer cents."""
def __init__(self, owner: str, balance: int = 0) -> None:
if type(balance) is not int or balance < 0:
raise ValueError("invalid opening balance")
self.owner = owner
self.balance = balance
self.history: list[tuple[str, int]] = []
def deposit(self, amount: int) -> None:
if type(amount) is not int or amount <= 0:
return
self.balance += amount
self.history.append(("deposit", amount))
def withdraw(self, amount: int) -> bool:
if type(amount) is not int or amount <= 0 or amount > self.balance:
return False
self.balance -= amount
self.history.append(("withdraw", amount))
return True
def statement(self) -> str:
lines = [f"{self.owner}: {self.balance} cents"]
for action, amount in self.history:
lines.append(f" {action}: {amount} cents")
return "\n".join(lines)
a = BankAccount("Alex", 100)
b = BankAccount("Blake")
a.deposit(50)
print(a.withdraw(30), a.balance)
print(a.withdraw(1000), a.balance)
print(a.withdraw(-20), a.balance)
print(b.balance, b.history)
print(a.statement())
The withdrawal observations are True 120, False 120, and False 120. Blake’s separate account remains at zero with an empty history. Initialization creates a new history list on each call.
Validation here is explicit code, including rejecting Boolean values as money. A real financial system also needs persistence, auditing, currency rules, and concurrency control; this classroom example does not implement them.
Validate starting values as well as updates
math.isfinite(value) reports whether a numerical value is finite. The special string accepted by float("inf") constructs positive infinity for this test:
import math
print(math.isfinite(25.0))
print(math.isfinite(float("inf")))
The results are True and False. The next example expects real numerical inputs whose values are finite and at least −273.15°C. Construction rejects an invalid starting value with ValueError; a rejected update returns False without replacing the old value.
The leading underscore in _celsius identifies nonpublic implementation detail. It does not prevent assignment.
import math
class Temperature:
"""Minimal lecture interface, not the full lab interface."""
def __init__(self, celsius: float) -> None:
if not math.isfinite(celsius) or celsius < -273.15:
raise ValueError("invalid starting temperature")
self._celsius = celsius
def get_celsius(self) -> float:
return self._celsius
def to_fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
def set_celsius(self, value: float) -> bool:
if not math.isfinite(value) or value < -273.15:
return False
self._celsius = value
return True
t = Temperature(25)
print(t.to_fahrenheit())
print(t.set_celsius(-300), t.get_celsius())
print(t.set_celsius(100), t.to_fahrenheit())
The lines are 77.0, False 25, and True 212.0. Checking only the setter would leave an invalid constructor unprotected. The lab extends this minimal example with Celsius, Fahrenheit, and Kelvin queries; follow its specified method names.
Built-in construction
__name__ gives the class name here. repr() makes empty strings and containers visible in the output:
classes = [int, float, complex, str,
list, tuple, dict, set]
for cls in classes:
value = cls()
print(cls.__name__, repr(value))
These create zero values and empty containers of the corresponding kinds. Rebinding a name to a different type does not alter the former object’s type.
Triangle: validation and member calls
This version defaults to edges 1, 1, 1. Initial edges must be finite, positive, and satisfy the strict triangle inequalities. A rejected update returns False without changing old edges. Area checks current values because direct writes to public fields can bypass the setter. The examples use moderate finite magnitudes, not a general numerical-geometry guarantee.
import math
class Triangle:
def __init__(self, a: float = 1, b: float = 1, c: float = 1) -> None:
self.a, self.b, self.c = a, b, c
if not self.is_valid():
raise ValueError("invalid triangle")
def is_valid(self) -> bool:
for value in [self.a, self.b, self.c]:
if not math.isfinite(value) or value <= 0:
return False
return (self.a + self.b > self.c and self.b + self.c > self.a
and self.c + self.a > self.b)
def set_edges(self, a: float, b: float, c: float) -> bool:
for value in [a, b, c]:
if not math.isfinite(value) or value <= 0:
return False
if not (a + b > c and b + c > a and c + a > b):
return False
self.a, self.b, self.c = a, b, c
return True
def area(self) -> float:
if not self.is_valid():
raise ValueError("invalid current edges")
s = (self.a + self.b + self.c) / 2
product = s * (s - self.a)
product *= s - self.b
product *= s - self.c
return product ** 0.5
def print(self) -> None:
print(f"Triangle: {self.a}, {self.b}, {self.c}")
def print_area(self) -> None:
print(f"Area: {self.area():.3f}")
a = Triangle(3, 4, 5)
b = Triangle(3, 3, 3)
a.print()
a.print_area()
print(b.area())
print(a.set_edges(1, 1, 3), a.area())
A 3–4–5 triangle has area 6. An equilateral triangle with side 3 has area approximately 3.897114317029974. The invalid update is rejected and the former area remains 6. The legacy zero-edge defaults established attributes, not a valid triangle.
print_area selects self.area(). An external function named area can coexist; explicit names and access paths identify which operation is called.
Dog: a retained record example
This legacy example compares direct field updates, an external function, and methods. It does not depend on a picture or an animal analogy.
class Dog:
def __init__(self, name: str, color: str, age: int, weight: float) -> None:
self.name = name
self.color = color
self.age = age
self.weight = weight
def set_name(self, name: str) -> None:
self.name = name
def set_color(self, color: str) -> None:
self.color = color
def set_age(self, age: int) -> None:
self.age = age
def set_weight(self, weight: float) -> None:
self.weight = weight
def print_dog(self) -> None:
print(
self.name, self.color,
self.age, self.weight,
)
def train_dog(dog: Dog) -> None:
dog.name += " 1st"
dog = Dog("Fox", "Red", 3, 4)
dog.print_dog()
dog.age += 1
dog.weight /= 2
train_dog(dog)
dog.print_dog()
dog.set_name("Tiger")
dog.print_dog()
An ordinary function can accept an object. Methods provide another organization for related operations. These setters merely store data; their names do not establish business validation of ages or weights.
HappyNumber: a recurrence and a cycle
Repeatedly replace a positive integer by the sum of its squared decimal digits. Reaching 1 makes it happy. Revisiting a value before reaching 1 means the deterministic process will repeat. A set records seen values; a local variable records the current query state.
class HappyNumber:
def __init__(self, number: int) -> None:
if number <= 0:
raise ValueError("positive integer required")
self.number = number
def sum_of_digit_squares(self) -> int:
return self._digit_square_sum(self.number)
def _digit_square_sum(self, value: int) -> int:
total = 0
while value > 0:
digit = value % 10
total += digit * digit
value //= 10
return total
def is_happy(self) -> bool:
current = self.number
seen = set()
while current not in seen:
if current == 1:
return True
seen.add(current)
current = self._digit_square_sum(current)
return False
def print_HN(self) -> None:
print(self.number)
for number in [19, 2, 1234567890987654321]:
h = HappyNumber(number)
print(h.number, h.sum_of_digit_squares(), h.is_happy())
print(h.number == number)
The first replacements are 82, 4, and 570; the decisions are True, False, False. Original fields remain unchanged. The first trajectory is 19→82→68→100→1. For 2, the trajectory 2→4→16→37→58→89→145→42→20→4 repeats.
The old candidate updated self.number: after returning True for 19, the field was 100. A query-style name does not prevent this side effect. Comparing versions requires checking the retained field as well as the Boolean result.
This algorithm can also be a function. A class is useful only if retaining the original number and related operations fits the required interface.
Vector: norms, products, scaling, and directions
Store a list of n scalar components, copying its outer list at construction. length computes the norm; dot requires equal dimensions; scale mutates the receiver. angle() returns angles to the coordinate axes in radians. A zero vector has no defined direction and raises ValueError.
math.acos(x) is the principal inverse cosine: inputs from −1 to 1 produce results from 0 to π. An axis direction cosine is that component divided by the norm. Clamp small round-off excursions to the function domain. These moderate finite examples do not claim robustness at arbitrary numerical scales.
import math
class Vector:
def __init__(self, values: list[float]) -> None:
self.values = values.copy()
def length(self) -> float:
total = 0.0
for value in self.values:
total += value * value
return math.sqrt(total)
def print_length(self) -> None:
print(f"Length: {self.length():.3f}")
def dot(self, other: "Vector") -> float:
if len(self.values) != len(other.values):
raise ValueError("different dimensions")
total = 0.0
for i in range(len(self.values)):
total += self.values[i] * other.values[i]
return total
def scale(self, ratio: float) -> None:
for i in range(len(self.values)):
self.values[i] *= ratio
def angle(self) -> list[float]:
length = self.length()
if length == 0:
raise ValueError("zero vector")
angles = []
for value in self.values:
direction = value / length
cosine = max(-1.0, min(1.0, direction))
angles.append(math.acos(cosine))
return angles
def print_angle(self) -> None:
for angle in self.angle():
print(f"Angle: {angle:.4f} rad")
values = [3.0, 4.0]
v = Vector(values)
u = Vector([1.0, 2.0])
v.print_length()
print(v.dot(u))
v.print_angle()
print(v.scale(2))
print(v.values, values, v.length())
The original length is 5.0 and the dot product is 11.0. The angles are about 0.9273 and 0.6435 radians. Scaling returns None, produces [6.0,8.0], and gives length 10.0; the caller’s list still holds [3.0,4.0].
An outer copy suffices for these scalar elements, not arbitrary nested mutable data. The empty vector has norm zero; mismatched products and undefined zero-vector angles need separate checks.
Same-named operations and function objects
These area names have explicit access paths. The external function accepts edges; the instance method obtains them from its fields and calls that function. The bare name area is not automatically rewritten as self.area.
def area(a: float, b: float, c: float) -> float:
s = (a + b + c) / 2
return (s * (s - a) * (s - b) * (s - c)) ** 0.5
class Shape:
def __init__(self, a: float, b: float, c: float) -> None:
self.a, self.b, self.c = a, b, c
def area(self) -> float:
return area(self.a, self.b, self.c)
x = Shape(3, 4, 5)
print(area(3, 4, 5), x.area())
f = print
f("function object")
Both areas are 6.0. The name f refers to the print function rather than to its return value, and the final call prints the string. Functions already isolate their local arguments; that is not an exclusive benefit of classes.
Inspect a bound method
For the ordinary Python-defined methods here, __self__ identifies the bound receiver and __func__ identifies the underlying function. With those meanings established, inspect them:
class Point:
def __init__(self, x: float = 0, y: float = 0) -> None:
self.x = x
self.y = y
def show(self) -> None:
print(self.x, self.y)
a = Point(0, 0)
b = Point(3, 4)
print(a.show.__self__ is a)
print(a.show.__func__ is b.show.__func__)
f = a.show
a.x = 8
f()
Both comparisons are True, and the final line is 8 0. The saved bound method still selects the original object and observes its later state. Do not assume these exact attributes exist on every built-in method.
Functions are objects too. f = print binds another name to that function. It is not a call to print and does not save print’s return value.
A related operation without a receiver
@staticmethod prevents an implicit receiver from being supplied through either class or instance access:
class Test:
@staticmethod
def add(u: int, v: int) -> int:
return u + v
print(Test.add(1, 1))
print(Test().add(3, 4))
The output is 2 and 7. Without the marker, the same add(u,v) can be called explicitly as Test.add(1,1), but an instance call supplies an additional receiver. That is why “a function without self cannot be used at all” is incorrect.
An operation needing neither instance nor class state can also be an ordinary external function. Method categories express an interface decision, not an obligation to put every helper into a class.
Forward annotations and failed initialization
String forward references are the mainline approach. from __future__ import annotations has been optional since Python 3.7. Python 3.13 still requires the explicit directive; delaying annotation evaluation does not validate runtime arguments.
Ordinary construction generally creates an instance using __new__, then initializes it using __init__. We do not implement creation hooks here. Some sources loosely call init the constructor; distinguish these stages when reading them.
This deliberately invalid initialization raises TypeError:
class Wrong:
def __init__(self):
return 1
Wrong()
Other common failures are misspelling __init__ as an ordinary name or omitting the receiver parameter. The former does not run that method automatically; the latter mismatches the supplied arguments. Omitting a custom init can still permit default construction, but does not establish your intended fields.
Organization and alternatives
Important object-oriented mechanisms developed in Simula during the 1960s, and Smalltalk advanced them in the 1970s. This does not show that OO replaced every other style. Python supports object-oriented and procedural organization; C++ and Java are other familiar related languages.
Choose from the lifetime of the state, cohesion of the operations, and usefulness of a stable interface and invariants. A function, dictionary, tuple, or structured record may be simpler. A single instance, a single method, or read-only data is not a mechanical reason to reject a class.
NamedTuple is one structured-record option; no new API syntax is required at this point.
Summary and practice
- Locals, instance attributes, and class attributes have different bindings.
- Instance methods bind an instance, class methods bind a class, and static methods bind neither automatically.
- Check initialization, updates, return values, printed output, and retained state independently.
- Class-level lists, mutable defaults, and repeated input references are three sources of sharing.
- Underscores and name mangling are not security permissions.
- Classes, records, and functions are choices justified by interface and state requirements.
Practice:
- Add a Point translation returning a new point. Explain its field effects and result compared with in-place move.
- Save
a.move, executea = b, then call the saved method. Explain the receiver and the current name a. - Shadow a class attribute on an instance, then delete that binding. Explain each lookup result.
- Compare a class-level list, a mutable default, and repeated references to one input list. Locate the source of sharing.
- Access classmethod and staticmethod through a class and an instance. State which arguments are supplied automatically.
- Compare data conversions, a deck of cards, and retained conversation history. Identify state that must persist across calls.
Lab 9 retains Rectangle, Stack, WordCounter, Temperature, Gradebook, and design decisions. Use the lab’s method names and failure contracts.