Classes I: Objects and Encapsulation
Organize related state and operations with classes. Follow initialization and method binding, check valid states, and distinguish instance attributes, class attributes, shared references, and class methods. Encapsulation provides an interface, not automatic access control.
By the end of this lecture you should be able to
- 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
- Validate constructor and update inputs and explain the limits of underscore conventions
- Distinguish instance attributes, class attributes, shared collections, and class methods
- Choose classes, functions, or records from their state and interface requirements
Contents
I. Objects and initialization
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 state rules
An interface is not access control
Encapsulation groups related data and operations behind a supported interface. That interface describes accepted inputs, state effects, results, and failure behavior.
An invariant is a condition supported operations must preserve, such as a nonnegative account balance. Establish it during initialization, then examine every public update. A guard in one setter cannot validate every possible entry point.
Ordinary Python attributes remain assignable. Code can write account.balance = -10 or alter a public history list. The teaching guarantee applies to using the supported operations correctly; a class is not a security sandbox.
Review the withdrawal candidate
This is intentionally faulty code. Predict its result before treating it as an acceptable implementation:
class CandidateAccount:
def __init__(self, balance: int) -> None:
self.balance = balance
def withdraw(self, amount: int) -> bool:
if amount > self.balance:
return False
self.balance -= amount
return True
a = CandidateAccount(100)
print(a.withdraw(-20), a.balance)
The actual line is True 120. Checking insufficient funds did not reject a nonpositive amount. Neither the class nor the annotation supplied the missing validation.
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.
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.
Rebinding an attribute differs from mutating a list
This class attribute references a single list:
class Basket:
items: list[str] = []
def add(self, item: str) -> None:
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(b.items)
print(a.items is b.items)
The results are ['apple'] and True. Appending to the reached list did not establish a fresh instance list. The implementation violates an independent-baskets contract, but intentionally shared registries can legitimately use class attributes.
Create a list on every initialization when the contract requires independent collections:
class Basket:
def __init__(self) -> None:
self.items: list[str] = []
def add(self, item: str) -> None:
self.items.append(item)
a = Basket()
b = Basket()
a.add("apple")
print(a.items, b.items)
print(a.items is b.items)
This gives ['apple'] [] and False. The independence comes from the separate list constructions, not from the parameter spelling self.
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.
Reference: complete examples and checks
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 translation operation returning a new Point. Contrast it with the in-place move and verify input preservation.
- Use two Triangle instances to check accepted and rejected updates, including preservation of all former edges.
- Inspect HappyNumber before and after a query and record an ordered trajectory rather than a set’s display order.
- Exercise Vector with a zero vector, mismatched dimensions, a caller-owned list, and scaling. State the radians convention.
- Save a bound method, alter its instance, then call the saved method. Explain receiver identity and observed values.
- Compare a conversion, a deck of cards, a palindrome predicate, and a program retaining conversation history. No external model call is needed.
Lab 9 retains Rectangle, Stack, WordCounter, Temperature, Gradebook, and design decisions. Use the lab’s method names and failure contracts.