Skip to content
academia.sh

Lesson 11 / 16

Built-in Functions

Built-ins are not functions doing the work themselves, they are thin shells calling a protocol: eight built-ins produce twenty-one calls, every collector builds the same five-call sequence, and on an object defining no protocol, three return a value while five throw TypeError.

Contents

The previous lesson measured the four levels of name lookup and showed the names at the last rung only as something to be shadowed. Yet the names sitting at that level are the language’s most heavily used, and they connect directly to every protocol measured so far.

This lesson’s question is not what those names do — it is whether they do any work themselves. Does len compute the length itself, or does it ask the object? Does sorted know how to sort itself, or does it leave comparison to the object? The course’s measurement axis suggests an answer: if syntax is a shorthand, built-ins can be a shorthand too. The measurement tests this.

A Built-in Is a Shell

The four lessons up to here measured syntax forms: if, for, while, call notation. Built-ins are not syntax; they are ordinary functions called with a bare name. But the measurement axis is the same for both — a built-in’s number is also which special method it calls, and how many times.

The contract is this: a built-in does not do the work itself. It looks for a special method of a given name on the object, calls it if found, and gives back what it returns. len does not count the length, it asks __len__; str does not build the text, it asks __str__; iter does not produce an iterator, it requests one from __iter__. A built-in’s contribution is not the work, it is a single call name: the same name is used no matter what the object’s type is.

The consequence is that built-ins operate not by type but by protocol. What decides whether an object can be measured with len is not its class’s name, it is whether it defines __len__.

The practical payoff: a new class needs nothing to inherit or register anywhere to work with built-ins. It only defines a method carrying the expected name; the built-in finds it. The shared reference’s fourth reading gets paid a second time here — what an object can do is decided not by its type’s name, but by the special methods it defines. All a built-in knows about an object is whether the method it is looking for is there.

Collecting Built-ins

A second cluster is not satisfied with calling a single method. list, sum, and sorted want all of the object’s items; to do this they run the iteration protocol start to finish. The expected sequence has to be the same one the second lesson measured: one __iter__, followed by one more __next__ than the item count.

One consequence: collecting items is always work proportional to item count. The built-in cannot know how many items there are in advance, because the iteration protocol announces no count. Collecting ends only when StopIteration arrives — it continues until items run out.

sorted stands apart within this cluster, because after collecting it also has to order the items. Collecting is one protocol, ordering another; the second does comparisons between items. This is where the assumption that a built-in maps to a single special method gets tested.

When There Is No Protocol

The third measure is the case where the method cannot be found. The shared reference’s fourth reading already said this: no protocol, no syntax either. Does the same hold for built-ins?

It is clear from the start that the answer is not uniform, because there is a case already measured in the first lesson: the truthiness trial did not error when it found no method at all, it counted the object true. So at least one built-in has a default behavior. How many do, how many do not — the measurement separates this.

The measurement’s assumptions:

  • CF31 — The oracle is the rig itself: it logs the name every time a special method is called, and which method a built-in calls is read from this log.
  • CF32 — Two classes are used. Flow defines only the iteration and comparison methods; Tracker adds length and text methods to it. Collecting built-ins are measured on Flow — so the measured sequence comes only from the iteration protocol.
  • CF33Silent defines no special method at all and is the measure of the “no protocol” state.
  • CF34 — Results are not printed as values, only “produced a value” or the exception’s class is written. The text form of an object defining no method carries environment-dependent information; it does not enter the measurement.
  • CF35Tracker.__repr__‘s body calls __str__; this is the shared reference’s behavior and is the reason for the two calls.
  • CF36 — In the sorting measurement, not the call count but the set of protocols called is written. Comparison count is a detail of the sort order and is not part of the measure.
  • CF37 — The total call count is specific to these eight built-ins and a three-item object.
  • CF38 — In the key-function measurement, the len built-in is given as key, so every place the key is called is logged and how many times per item can be counted.

Measurement

"""The built-ins' contract: which special method does each one call."""

LOG = []


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


class Flow:
    """Only iteration and ordering protocol; does not define __len__."""

    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 __lt__(self, other):
        record("__lt__")
        return len(self.items) < len(other.items)


class Tracker(Flow):
    """Also defines the length and text protocols."""

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

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

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


class NonEmpty(Tracker):
    """__bool__ defined: truthiness does not fall to __len__."""

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


class Silent:
    """Defines no special method at all."""


def measure(action):
    LOG.clear()
    try:
        action()
        status = "produced value"
    except Exception as e:
        status = f"!{type(e).__name__}"
    return list(LOG), status


SINGLE = (("len(n)", len, Tracker), ("str(n)", str, Tracker), ("repr(n)", repr, Tracker),
         ("bool(n)", bool, Tracker), ("iter(n)", iter, Tracker))
COLLECTING = (("list(n)", list, Flow), ("sum(n)", sum, Flow),
            ("sorted(n)", sorted, Flow))

print(f"{'builtin':<12s} {'calls':>6s}  protocol sequence called")
total = 0
for name, f, cls in SINGLE + COLLECTING:
    c, _ = measure(lambda f=f, s=cls: f(s()))
    total += len(c)
    print(f"  {name:<10s} {len(c):6d}  {' '.join(c)}")
print(f"\neight built-ins, total {total} calls")

print()
print(f"{'builtin':<12s} {'object defining protocol':<27s} object not defining it")
for name, f, cls in SINGLE + COLLECTING:
    _, d1 = measure(lambda f=f, s=cls: f(s()))
    _, d2 = measure(lambda f=f: f(Silent()))
    print(f"  {name:<10s} {d1:<27s} {d2}")

print()
c, _ = measure(lambda: bool(Tracker()))
print(f"bool(__bool__ undefined) -> {' '.join(c)}")
c, _ = measure(lambda: bool(NonEmpty()))
print(f"bool(__bool__ defined)   -> {' '.join(c)}")

print()
c, _ = measure(lambda: sorted(Flow()))
print(f"sorted(single object, items are numbers) -> {' '.join(sorted(set(c)))}")
TRIO = [Flow((1, 2, 3)), Flow((1,)), Flow((1, 2))]
c, _ = measure(lambda: sorted(TRIO))
print(f"sorted(three-object list)                -> {' '.join(sorted(set(c)))}")

print()
TRIO_TRACKER = [Tracker((1, 2, 3)), Tracker((1,)), Tracker((1, 2))]
print(f"{'three-object list':<28s} {'protocol called':<20s} is comparison asked of object")
for name, f in (("sorted(list)", lambda: sorted(TRIO_TRACKER)),
              ("sorted(list, key=len)", lambda: sorted(TRIO_TRACKER, key=len))):
    c, _ = measure(f)
    print(f"  {name:<26s} {' '.join(sorted(set(c))):<20s}"
          f" {'yes' if '__lt__' in c else 'no'}")
c, _ = measure(lambda: sorted(TRIO_TRACKER, key=len))
print(f"  key=len calls per item: {c.count('__len__')} / {len(TRIO_TRACKER)} items")
builtin       calls  protocol sequence called
  len(n)          1  __len__
  str(n)          1  __str__
  repr(n)         2  __repr__ __str__
  bool(n)         1  __len__
  iter(n)         1  __iter__
  list(n)         5  __iter__ __next__ __next__ __next__ __next__
  sum(n)          5  __iter__ __next__ __next__ __next__ __next__
  sorted(n)       5  __iter__ __next__ __next__ __next__ __next__

eight built-ins, total 21 calls

builtin      object defining protocol    object not defining it
  len(n)     produced value              !TypeError
  str(n)     produced value              produced value
  repr(n)    produced value              produced value
  bool(n)    produced value              produced value
  iter(n)    produced value              !TypeError
  list(n)    produced value              !TypeError
  sum(n)     produced value              !TypeError
  sorted(n)  produced value              !TypeError

bool(__bool__ undefined) -> __len__
bool(__bool__ defined)   -> __bool__

sorted(single object, items are numbers) -> __iter__ __next__
sorted(three-object list)                -> __lt__

three-object list            protocol called      is comparison asked of object
  sorted(list)               __lt__               yes
  sorted(list, key=len)      __len__              no
  key=len calls per item: 3 / 3 items

A One-to-One Match

The top table’s first five rows confirm the contract. len makes one call and what it calls is __len__; str one call, __str__; iter one call, __iter__. There is no code in the built-in’s body that counts a length, builds text, or produces an iterator — if there were, the call count would be zero, because there would be no need to ask the object at all.

The 2 in the repr row looks like an exception, but is not. repr still calls a single method: __repr__. The second call does not come from the built-in — the body of __repr__ itself calls __str__. The built-in’s contract is not broken; the measured sequence also shows the work the called method does itself.

The bool row reproduces the first lesson’s result: because __bool__ is not defined on the object, the trial falls to __len__. The two lines further down put the pair side by side — while __bool__ is defined, only it is called, __len__ never enters the trial. This is the one real exception to the rule that a built-in maps to a single method: bool looks for not one but two, in order.

The Collectors’ Shared Sequence

The last three rows give the same sequence: one __iter__, four __next__, total 5. Three separate built-ins — one builds a list, one takes a sum, one sorts — and all three collect items in exactly the same way.

The sequence is character-for-character the one the second lesson measured, and for the same reason it is one more: four __next__ for three items, the last one announcing collection is done by throwing StopIteration. for, list, sum, and sorted — all four call the same protocol the same number of times. The difference between them is what they do with the items collected, not how they collect them.

The practical consequence: an object defining the iteration protocol works with all of these built-ins automatically. list support, sum support, sorted support are not added one by one — the moment __iter__ and __next__ are written, all three arrive.

Sorting’s Two Protocols

The bottom two lines set sorted apart and show it does not map to a single method.

In the first line, the sorted object’s items are numbers. The protocols called are __iter__ and __next__ — collecting only. Comparison happens too, of course, but between numbers; the rig’s object never enters that comparison and nothing lands in the log.

In the second line, what is sorted is a list of three objects. This time the log holds only __lt__. Because the list is already a list, the rig’s protocol is not called during collection; during sorting, every comparison is asked of the object.

Read together, the two lines show sorted calls two separate protocols: the iteration protocol to collect items, the comparison protocol to order them. Which one lands in the log depends on which role the rig’s object is playing — the outer container, or an item inside.

The reading that follows: sorted does not know how to sort, it asks the object to compare. What decides the sort order is not the built-in’s body, it is the items’ __lt__ body. The built-in only asks which item comes before which and follows the answer it gets. The same list is arranged in a different order just by changing the items’ __lt__ definition, without a single character changing in the sorted call.

A Key Changes the Protocol

The last table measures how the same call switches to a different protocol with a keyword argument. The two rows sort the same list, the same objects; the only thing that changes is whether the key argument is given.

In the call with no key, the protocol landing in the log is __lt__ — comparison is asked of the objects. In the call with a key, the protocol in the log is __len__ and __lt__ is never called at all. The right-hand column says this directly: comparison is no longer asked of the object.

What happens: given a key, sorted first applies the key function to every item, then does the ordering on the returned key values. The objects are never compared with each other; what gets compared is their keys. This is why even objects with no __lt__ can be sorted with a suitable key — sortability becomes a property of the key, not the object.

The last line gives how many times the key is called: 3 calls for three items, exactly once per item. The key is not recomputed on every comparison; it is computed once and stored. The result is that an expensive key function is paid for once over the whole sort — no matter the comparison count.

Having a Default and Not

The middle table measures the case where the protocol cannot be found, and the eight built-ins split in two.

Five define no default at all: len, iter, list, sum, and sorted produce not a value but TypeError when the method cannot be found. For these built-ins the shared reference’s fourth reading holds literally — no protocol, no built-in either.

Three produce a value. str and repr give a text form even for an object with no method defined; the language defines a default text representation for every object. bool applies the first lesson’s third rung and counts the object true.

The reason for the split is whether the question has an answerable default. “What is this object’s length?” has no reasonable answer if the object does not say — any number made up would be wrong. “What is this object’s text form?” does have an answer producible for every object. Whether a built-in has a default depends on whether the question has an answer independent of the object.

This split has a cost that does not show in the table: built-ins with a default run silently. A class that forgot to write __len__ shows it immediately on a len call; a class that forgot __bool__ returns true from every if trial with no signal at all. The error surfacing early is a guarantee the TypeError-producing built-ins provide.

Summary

  • Built-ins are not functions doing the work themselves, they are thin shells calling a special method on the object; len does not count the length, str does not build the text, iter does not produce an iterator — all three ask.
  • Eight built-ins produce a total of 21 calls on a three-item object; the first five map to a single method with one call each, while bool looks for two methods in order and falls to __len__ if __bool__ is absent.
  • list, sum, and sorted build the same five-call sequence — one __iter__, four __next__ — character-for-character the same as the for loop’s sequence; a class writing the iteration protocol gains all three at once.
  • sorted calls not one but two protocols: iteration to collect, comparison to order. The sort order is decided not by the built-in’s body but by the items’ __lt__ body.
  • With a keyword argument given, sorted never asks the object for comparison at all: the key function is called instead of __lt__ — exactly once per item — and sortability becomes a property of the key, not the object.
  • On an object defining no protocol, five of eight built-ins give TypeError, three produce a value; the ones with a default are the ones whose question has an answer independent of the object, and they pay the cost of passing over an omission silently.

Next Step

Every protocol measured up to here followed the expected path. Syntax looked for a method, found it, and called it; when not found it fell to a second method; when that too was absent it either applied a default or threw a well-formed TypeError. Even the StopIteration ending a loop was not an accident, it was the protocol’s planned last step.

So what if a protocol fails? The method called is found, it runs, but cannot finish its job — a file is missing, a number cannot convert, an index falls outside the bound. In this lesson TypeError showed up once; in earlier lessons NameError, UnboundLocalError, IndexError, and StopIteration showed up. All five were classes, and all can be caught with the same notation. The next lesson builds how these classes relate to each other: which one catching also catches which others, and what a broad catch swallows.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close