Skip to content
academia.sh

Lesson 01 / 16

Python's Execution Model

The source is compiled as a whole before it is executed; syntax is a shorthand — eleven forms call 12 distinct special methods 18 times in total, and a loop over a three-item object calls `__next__` one more time than the item count, 4 times.

Contents

The Software Development Practice curriculum built a team discipline across five courses and, at its close, said that everything it measured was language-independent: what was counted was never the code itself, but the work around it. This curriculum turns from the surroundings back to the center. It’s the language’s own turn.

The Programming Fundamentals course wrote its examples in Python; assignment as a binding, the is/== distinction, basic data types, operators, and type conversion were all built there. But using a language as an example and learning the language itself are different things: there, Python was a vehicle for explanation; here, it is the object being measured. This course looks beneath the syntax and carries a single question across sixteen lessons — which machinery does a written form invoke while it runs, and how many times?

Two Phases: Compiling and Executing

Python runs with an interpreter, but it does not read the source line by line and run it immediately. When a file is run, the text is first split into tokens, then turned into an abstract syntax tree, then translated into bytecode. What the interpreter runs is the bytecode, not the source text.

The boundary between the two phases is observable. Compilation is done over the whole file: a syntax defect on the source’s last line also blocks the execution of the statement on the first line. An error born during execution, by contrast, only surfaces once its own turn comes, and everything before it has already run. The difference can be measured with two built-in functions: compile compiles the source and returns a code object, exec runs that object.

"""Two phases: the source is first compiled as a whole, then executed."""

SOURCE = {
    "missing parenthesis": 'write = print\nwrite("one")\nwrite(\n',
    "undefined name": 'write = print\nwrite("one")\nwritee("two")\n',
}

for name, source in SOURCE.items():
    print(f"--- {name}")
    try:
        code_obj = compile(source, "<lesson>", "exec")
    except SyntaxError as e:
        print(f"compile: {type(e).__name__} — no statement executed")
        continue
    print(f"compile: clean, obtained a {type(code_obj).__name__} object")
    try:
        exec(code_obj, {})
    except Exception as e:
        print(f"execution: {type(e).__name__} — statements before it were executed")
--- missing parenthesis
compile: SyntaxError — no statement executed
--- undefined name
compile: clean, obtained a code object
one
execution: NameError — statements before it were executed

In the second source, the line one was printed, and the call after it fell with a NameError. In the first source, nothing was printed at all — even though its first two lines are flawless. The distinction is this: a syntax defect is found at compile time, a name defect at execution time. Whether a name exists is not known at compile time, because a name is only bound to an object during execution.

The same two steps also work in the interactive shell; there, the unit compiled is not the whole file but the block entered. If the block’s syntax is broken, the block never runs at all.

The layout of the bytecode that compilation produces is not part of the language’s definition; it is the interpreter’s internal business, and reading it is not this course’s subject. What is worth reading is which methods the bytecode calls.

Syntax Is a Shorthand

The course’s measurement axis comes from a single observation: most forms of Python’s syntax are a shorthand. When n + m is written, what runs is not an addition but a call — the n object’s __add__ method is called, and the returned value is the expression’s value. for x in n is not a looping keyword but two methods called in sequence: first __iter__, then __next__ until it runs out.

The names of these methods are written into the language’s definition; each one is called a special method, and the set of methods a form requires is called a protocol. Their names are written between two underscores, and this is meant to set them apart from ordinary methods: special methods do not exist to be called directly, they exist to be called by syntax.

Not every syntax form is a shorthand, and this distinction has to be set up from the start. Binding a value to a name, defining a function, or returning a value asks the object no question at all; these forms are concerned with names and flow, not with what is inside the object. The ones that are a shorthand are forms that consume an object: they sum it, walk through it, test it in a condition, slice it, turn it into text. All eleven of the forms measured here belong to this second set.

This has a direct consequence: syntax does not look at the type’s name, it looks at whether the method exists. That the + operator adds numbers and concatenates strings is not a special privilege but the fact that two separate types define a method with the same name in different ways. The Programming Fundamentals course built this observation as a concept under the heading operator overloading; what is measured here is not the concept but the name that carries it out — which form calls which method, and how many times.

The Shared Setup: an Object That Logs Its Participation

A single class is enough for the measurement. Tracker defines a large number of special methods, and every time a method is called, it writes its name to a shared list. Ordinary syntax is run on it — for, in, if, +, +=, slicing, str(), with, == — and which method each form calls, how many times, is read from the list.

A second class, NonEmpty, derives from Tracker and adds only one thing: __bool__. The same if expression will now call a different method on this object. The oracle is the setup itself: because the object records which method got called, we know it directly — the count is a log, not an inference.

"""Protocols called by syntax: every special method logs itself when called."""

LOG = []


def record(name):
    LOG.append(name)


class Tracker:
    """An object that takes part in many protocols; records each participation."""

    def __init__(self, items=(1, 2, 3)):
        self.items = list(items)

    def __iter__(self):
        record("__iter__")
        self._i = 0
        return self

    def __next__(self):
        record("__next__")
        if self._i >= len(self.items):
            raise StopIteration
        value = self.items[self._i]
        self._i += 1
        return value

    def __len__(self):
        record("__len__")
        return len(self.items)

    def __contains__(self, x):
        record("__contains__")
        return x in self.items

    def __add__(self, o):
        record("__add__")
        return Tracker(self.items + list(o.items))

    def __iadd__(self, o):
        record("__iadd__")
        self.items.extend(o.items)
        return self

    def __getitem__(self, k):
        record("__getitem__")
        return self.items[k]

    def __eq__(self, o):
        record("__eq__")
        return isinstance(o, Tracker) and self.items == o.items

    def __str__(self):
        record("__str__")
        return f"Tracker{tuple(self.items)}"

    def __repr__(self):
        record("__repr__")
        return self.__str__()

    def __enter__(self):
        record("__enter__")
        return self

    def __exit__(self, kind, value, tb):
        record("__exit__")
        return False          # does not swallow the exception


class NonEmpty(Tracker):
    """__bool__ defined: the truthiness test no longer falls back to __len__."""

    def __bool__(self):
        record("__bool__")
        return len(self.items) > 0


def measure(function):
    """Runs a syntax form and returns the protocols it called."""
    LOG.clear()
    try:
        function()
    except Exception as e:
        LOG.append(f"!{type(e).__name__}")
    return list(LOG)


def loop():
    for _ in Tracker():
        pass


def membership():
    9 in Tracker()


def truthiness_len():
    if Tracker():
        pass


def truthiness_bool():
    if NonEmpty():
        pass


def addition():
    Tracker() + Tracker()


def in_place_addition():
    a = Tracker()
    a += Tracker()


def slicing():
    Tracker()[0:2]


def to_string():
    str(Tracker())


def context():
    with Tracker():
        pass


def context_with_exception():
    with Tracker():
        raise ValueError("example")


def equality():
    Tracker() == Tracker()


FORMS = (
    ("for x in n", loop),
    ("x in n", membership),
    ("if n  (__len__)", truthiness_len),
    ("if n  (__bool__)", truthiness_bool),
    ("n + m", addition),
    ("n += m", in_place_addition),
    ("n[0:2]", slicing),
    ("str(n)", to_string),
    ("with n", context),
    ("with n + exception", context_with_exception),
    ("n == m", equality),
)

The measurement’s assumptions:

  • LF1 — The forms measured are eleven; not the language’s entire syntax, but the core this course pays off lesson by lesson. The remaining forms are measured in their own lessons.
  • LF2 — The oracle is the object’s own log: it writes its name to the list when a method is called, so the measurement is a count, not an inference.
  • LF3Tracker is built with three items; the call count read in the loop measurement depends on this three.
  • LF4__exit__ returns False on every run; an exception born inside the context block is not swallowed, and it enters the measurement as an exception name with an exclamation mark in front of it.
  • LF5 — The measurement reads no environment data: memory address, object identity count, and duration are not used; the only thing counted is the names of the methods called.
  • LF6 — Every form is measured twice — once for the table, once for the paired comparison. Because Tracker is rebuilt on every measurement, the two runs give the same sequence.

Measurement

print(f"{'syntax':<20s} {'calls':>6s}  protocol sequence")
for name, function in FORMS:
    c = measure(function)
    print(f"{name:<20s} {len(c):6d}  {' '.join(c)}")

print()
by_form = {name: measure(f) for name, f in FORMS}
unique = sorted({p for c in by_form.values() for p in c if not p.startswith("!")})
print(f"distinct protocols {len(unique)}: {', '.join(unique)}")
print(f"total calls {sum(len(c) for c in by_form.values())}, "
      f"forms ending in an exception "
      f"{sum(1 for c in by_form.values() if any(p.startswith('!') for p in c))}")

print()
print("pairs that look the same but call different protocols:")
for a, b in (("n + m", "n += m"),
             ("if n  (__len__)", "if n  (__bool__)")):
    print(f"  {a:<18s} -> {' '.join(by_form[a])}")
    print(f"  {b:<18s} -> {' '.join(by_form[b])}")

print()
loop_log = by_form["for x in n"]
print(f"loop over a three-item object: __next__ called {loop_log.count('__next__')} times "
      f"— one more than the item count; the last one ended with StopIteration")
syntax                calls  protocol sequence
for x in n                5  __iter__ __next__ __next__ __next__ __next__
x in n                    1  __contains__
if n  (__len__)           1  __len__
if n  (__bool__)          1  __bool__
n + m                     1  __add__
n += m                    1  __iadd__
n[0:2]                    1  __getitem__
str(n)                    1  __str__
with n                    2  __enter__ __exit__
with n + exception        3  __enter__ __exit__ !ValueError
n == m                    1  __eq__

distinct protocols 12: __add__, __bool__, __contains__, __enter__, __eq__, __exit__, __getitem__, __iadd__, __iter__, __len__, __next__, __str__
total calls 18, forms ending in an exception 1

pairs that look the same but call different protocols:
  n + m              -> __add__
  n += m             -> __iadd__
  if n  (__len__)    -> __len__
  if n  (__bool__)   -> __bool__

loop over a three-item object: __next__ called 4 times — one more than the item count; the last one ended with StopIteration

Reading the Numbers

Eleven forms produce 12 distinct protocols and 18 calls in total. This is the measure the course will carry forward: a lesson’s number is which protocol the syntax it covers calls, and how many times. Every row of the table is a syntax form, and the sequence next to it shows the calls that really ran underneath that form. None of it is a guess; all of it was read from the object’s own log.

Two expressions that look alike call different protocols. n + m and n += m sit side by side as if one were shorthand for the other, but one calls __add__, the other __iadd__. The difference is not just a name: __add__ produces and returns a new object, __iadd__ mutates the existing object and returns itself. In the same way, if n calls __len__ on Tracker, __bool__ on NonEmpty — a single syntax, two separate methods. What creates the difference is which method the object defines. The look of the syntax does not tell you which protocol gets called. The first half of this claim will be paid off in this topic’s operators lesson, the second half in the flow topic’s conditionals lesson.

An exception is not an error, it is part of the protocol. The loop over the three-item object called __next__ 4 times — one more than the item count. The fourth call does not return a value, it raises StopIteration; this is exactly what ends the loop. The word for does not know the items ran out by counting, it knows by catching an exception. The line with n + exception is of the same family: even though an exception is born inside the block, __exit__ is still called, and its name stands in the log as the third entry. Because __exit__ returns False, the exception made it out — had it returned True, it would not have.

No Protocol, No Syntax

The table above is full because Tracker has these methods. What happens if the same forms are tried on an object that defines no special method at all? The measurement’s assumption:

  • LF7 — The object being compared defines no special method; the only thing it carries is what comes from every object’s base. Forms that fail are written with the exception type, forms that do not fail are written with “ran”; values are not printed, because the text the base definition produces carries an environment-dependent identity.
class Bare:
    """Defines no special methods; carries only what comes from the object's base."""


def bare_loop():
    for _ in Bare():
        pass


def bare_context():
    with Bare():
        pass


BARE_FORMS = (
    ("for x in n", bare_loop),
    ("x in n", lambda: 9 in Bare()),
    ("n + m", lambda: Bare() + Bare()),
    ("n[0:2]", lambda: Bare()[0:2]),
    ("len(n)", lambda: len(Bare())),
    ("with n", bare_context),
    ("str(n)", lambda: str(Bare())),
    ("n == m", lambda: Bare() == Bare()),
)

print("the same forms on an object with no special methods:")
failing = 0
for name, function in BARE_FORMS:
    try:
        function()
    except Exception as e:
        failing += 1
        print(f"  {name:12s} {type(e).__name__}")
    else:
        print(f"  {name:12s} ran — used the definition inherited from the base")
print(f"failing forms {failing} / {len(BARE_FORMS)}")
the same forms on an object with no special methods:
  for x in n   TypeError
  x in n       TypeError
  n + m        TypeError
  n[0:2]       TypeError
  len(n)       TypeError
  with n       TypeError
  str(n)       ran — used the definition inherited from the base
  n == m       ran — used the definition inherited from the base
failing forms 6 / 8

6 of eight forms fail, and all with the same exception: TypeError. The name fits — the defect is not in the value used, it is that the object carries no method that answers that syntax. The source text is flawless and passes the compile phase without issue; when it comes to execution, the method to call cannot be found.

The 2 forms that do not fail are the other side of the same rule. str(n) and n == m work because every object inherits these two methods from its base: the first produces a text that identifies the object, the second answers equality by identity. So the protocol is still there; only its definition comes not from the class itself but from its base. When a class writes __str__, it does not add a capability from scratch, it takes the place of the inherited definition.

The result is the course’s fourth reading: in Python, what an object “can do” is determined not by its type’s name but by the special methods it can reach. If __iter__ can be reached, a loop can be built over it; if __len__ can be reached, its length can be asked — there is no requirement linking the two. The language does not look at the type’s name before running a form — it looks for the method, and raises TypeError if it cannot find it.

Summary

  • The source text is first turned into tokens and an abstract syntax tree, then into bytecode; what the interpreter runs is the bytecode.
  • Compilation happens over the whole file: a syntax defect runs no statement at all, while a name defect only surfaces once its own turn comes, after everything before it has run.
  • Syntax is a shorthand; every form calls a specific special method, and the set of methods is called a protocol.
  • Eleven forms use 12 distinct protocols with 18 calls in total; for x in n alone produces 5 calls.
  • __next__ is called 4 times on a three-item object, and the last one raises StopIteration; here the exception is not a defect but the mechanism that ends the loop.
  • On an object defining no special method, 6 of the same eight forms fail with TypeError; the remaining 2 work with definitions inherited from the base object.

Next Step

This lesson counted the calls underneath syntax, but never questioned the syntax itself: what decides where the body under a for line starts and ends? Python does this not with braces but with whitespace, and whitespace here is not a style preference but part of the grammar. The next lesson measures this: how the same sequence of tokens turns into a different tree and a different result just by changing its indentation — and exactly where the boundary between a statement and an expression lies.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close