Python Part I · Module 16 · Object-Oriented Programming
Instance Methods
So far the objects in this module have been containers: they hold four fields and do nothing. Adding behaviour is syntactically trivial — a function written inside the class body — and the interesting question is not how but why, because Chapter 16.3 already demonstrated that a method call is literally a function call with the object passed in. If the two are the same operation, choosing between them cannot be a technical decision. It is a decision about where a piece of knowledge belongs, and that turns out to matter a great deal more than the syntax does.
Intermediate15 min readPythonPart IFundamentalsOOPMethods
Part I · Module 16 · Chapter 16.5 · Instance Methods
1. A Function Inside the Class Body
class TestRun:
def __init__(self, name, seed):
self.name = name
self.seed = seed
def label(self):
return f"{self.name}/{self.seed}"
run = TestRun("smoke", 1234)
print(run.label())smoke/1234label is defined exactly like any function, with self as its first parameter
(Chapter 16.3). It reads the object's own attributes through self and returns
a value.
A method may take further parameters after self:
class TestRun:
def __init__(self, name, seed):
self.name = name
self.seed = seed
def path(self, root, extension=".log"):
return f"{root}/{self.name}_{self.seed}{extension}"
run = TestRun("smoke", 1234)
print(run.path("runs"))
print(run.path("scratch", extension=".wlf"))runs/smoke_1234.log
scratch/smoke_1234.wlfDefaults and keyword arguments work as Module 11 described. self is simply the
first in the list.
2. Why Not Just Store the Value?
The obvious alternative is to compute the label once in __init__ and keep it
as a field. The two versions behave identically until something changes:
class Stored:
def __init__(self, name, seed):
self.name = name
self.seed = seed
self.label = f"{name}/{seed}"
class Computed:
def __init__(self, name, seed):
self.name = name
self.seed = seed
def label(self):
return f"{self.name}/{self.seed}"
a = Stored("smoke", 1234)
b = Computed("smoke", 1234)
print("initially :", a.label, "|", b.label())
a.seed = 9999
b.seed = 9999
print("after change:", a.label, "|", b.label())
print("Stored reports seed", a.seed, "with label", a.label)
print("Computed reports seed", b.seed, "with label", b.label())initially : smoke/1234 | smoke/1234
after change: smoke/1234 | smoke/9999
Stored reports seed 9999 with label smoke/1234
Computed reports seed 9999 with label smoke/9999Stored is now internally inconsistent. It reports seed 9999 and a label
built from 1234, because the label was computed once at construction and the
later assignment could not reach it. Nothing raised; any report printing both
fields simply shows two different seeds for one run.
Computed cannot drift, because there is no stored copy to drift from — the
label is derived at the moment it is asked for.
A derived value stored as a field can go stale. A method computing it on demand cannot.
That is the first real argument for behaviour living on the object. The cost is recomputation on every call, which for a formatted string is nothing; when it is genuinely expensive, caching is a deliberate decision rather than the default.
3. The Function Version Is Mechanically the Same
Now the canonical question. Here is label as a plain function:
class TestRun:
def __init__(self, name, seed):
self.name = name
self.seed = seed
def label(self):
return f"{self.name}/{self.seed}"
def label_of(run):
return f"{run.name}/{run.seed}"
run = TestRun("smoke", 1234)
print("method :", run.label())
print("function:", label_of(run))
print("long form:", TestRun.label(run))
print("identical:", run.label() == label_of(run) == TestRun.label(run))method : smoke/1234
function: smoke/1234
long form: smoke/1234
identical: TrueThree spellings, one result. Chapter 16.3 Section 3 already established why:
run.label() is TestRun.label(run). There is no performance difference and
no access difference — label_of reads run.name just as freely as the method
reads self.name. Python has no private data (Chapter 16.7).
So the choice is not technical.
4. What Actually Differs
Three things, none of them about mechanism:
Discoverability. run.<tab> in any editor lists label. label_of is
findable only if you know it exists and where it lives. For a reader meeting
your code, a method is attached to the thing it concerns.
Coupling. label_of depends on run having .name and .seed — the same
dependency the method has, but written in a file that may be far away. When the
field is renamed, the method is adjacent to the change and the function is not.
Polymorphism. This is the one that matters most and cannot be replicated:
class Job:
def __init__(self, name):
self.name = name
def describe(self):
return f"job {self.name}"
class SimulationJob(Job):
def describe(self):
return f"simulation {self.name}"
def describe_of(job):
return f"job {job.name}"
for j in (Job("alu"), SimulationJob("smoke")):
print(f"{type(j).__name__:<14} method={j.describe():<22} function={describe_of(j)}")Job method=job alu function=job alu
SimulationJob method=simulation smoke function=job smokeThe method gave the right answer for both; the function gave the Job answer
for both. A method is chosen by the object's class at call time, so a more
specific class can supply a better version. A function cannot — it would need an
explicit check of what it was handed.
That is Chapters 16.11 and 16.12's subject, previewed here because it is the one genuine, unreplicable difference.
5. When a Function Is the Right Answer
Methods are not always right. The test:
Does this operation belong to one object, using its internals? Or does it span several objects, or belong to the caller's concerns?
A summary over a list of results belongs to nobody:
class TestResult:
def __init__(self, name, passed, runtime_s):
self.name = name
self.passed = passed
self.runtime_s = runtime_s
def label(self):
return f"{self.name} {'PASS' if self.passed else 'FAIL'}"
def summarise(results):
"""Spans the whole collection -- not any one result's business."""
total = 0.0
failures = []
for r in results:
total += r.runtime_s
if not r.passed:
failures.append(r.name)
return total, failures
RESULTS = [
TestResult("smoke", True, 182.4),
TestResult("cdc", False, 204.1),
TestResult("burst", True, 199.8),
]
for r in RESULTS:
print(" ", r.label())
total, failures = summarise(RESULTS)
print(f"total {total:.1f}s | failures: {failures}") smoke PASS
cdc FAIL
burst PASS
total 586.3s | failures: ['cdc']label() is a method because it is about one result. summarise() is a
function because it is about the set, and making it TestResult.summarise
would mean asking one result to describe its peers.
A rough rule: if the body would mostly say self, write a method. If it would
mostly loop, write a function.
6. Methods Can Call Other Methods
Through self, like anything else:
class TestRun:
def __init__(self, name, seed, runtime_s):
self.name = name
self.seed = seed
self.runtime_s = runtime_s
def label(self):
return f"{self.name}/{self.seed}"
def is_slow(self):
return self.runtime_s > 200
def report(self):
flag = " SLOW" if self.is_slow() else ""
return f"{self.label()}{flag}"
for r in (TestRun("smoke", 1234, 182.4), TestRun("cdc", 5678, 204.1)):
print(r.report())smoke/1234
cdc/5678 SLOWreport calls self.label() and self.is_slow(). The lookup happens at call
time, on whatever object self is — which is the mechanism that makes
Chapter 16.11's overriding work, because a subclass replacing is_slow changes
what report does without report being touched.
7. Predict, Run, Observe
class Counter:
def __init__(self):
self.value = 0
def bump(self):
self.value += 1
def bumped_twice(self):
self.bump()
self.bump()
return self.value
c = Counter()
print("A:", c.bump())
print("B:", c.value)
print("C:", c.bumped_twice())
print("D:", c.value)
print("E:", Counter().bumped_twice())
print("F:", c.value)A: None
B: 1
C: 3
D: 3
E: 2
F: 3A is None — bump has no return, so it returns None (Chapter 11.8).
A method that mutates and returns nothing is a normal and deliberate shape, and
Chapter 16.6 is about that choice.
C — bumped_twice called self.bump() twice on an object already at 1,
giving 3.
E is 2, not 5. Counter() built a fresh object starting at 0. Each
instance has its own value.
F confirms the fresh object did not disturb c.
8. Exercises
Exercise 1 — a first method. Add a method to a class that returns a string built from two of its attributes. Call it on two instances.
Exercise 2 — extra parameters. Write a method taking two arguments after
self, one with a default. Call it three ways.
Exercise 3 — the stale field. Reproduce Section 2's Stored versus
Computed. Change an attribute and show the two disagree.
Exercise 4 — three spellings. Call the same behaviour as a method, as a
plain function, and in the Class.method(obj) long form. Confirm all three
agree.
Exercise 5 — the one real difference. Reproduce Section 4's polymorphism example. Explain in two sentences why the function could not be fixed without an explicit type check.
Exercise 6 — method or function. For each, choose and justify: (a) a test's log path (b) the pass rate of a suite (c) whether one result is slow (d) the slowest result in a list (e) a result's human-readable label.
Exercise 7 — composition through self. Write a method that calls two other methods on the same object. Change one of the two and confirm the caller follows.
Exercise 8 — returning None. Write one method that mutates and returns
nothing and one that returns a new value without mutating. Say which you would
prefer for a rename operation and why.
9. Summary
- A method is a function in the class body whose first parameter is
self. - Methods take further parameters normally, including defaults and keyword arguments.
- A derived value stored as a field goes stale when its inputs change; a method computing it cannot.
- A method and a function taking the object are mechanically identical —
obj.m()isClass.m(obj). - What differs is discoverability, coupling and polymorphism, not mechanism.
- Polymorphism is the unreplicable one: a method is selected by the object's class at call time, so a subclass can supply a better version.
- An operation spanning several objects belongs in a function, not on any one of them.
- If the body would mostly say
self, write a method; if it would mostly loop, write a function. - Methods call each other through
self, resolved at call time. - A method with no
returnreturnsNone, which is a normal shape for one that mutates.
The next chapter is about methods that change the object, and the decision of whether they should.
Where this fits
Part of the Python curriculum.
