Skip to content
academia.sh

Lesson 07 / 11

Abstract Base Classes and Protocols

1 of three candidates passes the ancestry test, 3 pass the method test, and 3 answer the call; in the two tests' blind spots, 3 of three candidates are accepted but only 1 answers the call.

Contents

The previous lesson counted what a class writes by looking at its dict; the ones before it searched for the answer in the class tree. Both methods rest on the same assumption: knowing what an object can do requires knowing which class it comes from. But the fourth lesson showed the opposite — joining came not from the type’s name, it came from the methods written. The second lesson’s Wrapper class is the concrete form of this duality: it derives from nothing, and answers all four of the four capabilities.

This lesson turns the duality into a test. Whether a contract is satisfied can be asked in two separate ways. A nominal test searches for ancestry: “did this object derive from that contract?” A structural test searches for methods: “does this object carry these names?” Which of the two does the same object pass, and does passing the test say it will actually answer the call? For the question to be measurable, three separate things are asked of every candidate: the two tests’ results, and the call itself. Where the three do not agree is this lesson’s finding.

Two Tests, One Object

Two contracts are set up, and both ask for the same two capabilities: source and format. The first is an abstract base class; a class satisfying it must derive from it. The second is a protocol; the test only looks at whether the names exist.

Three candidates are tested. Document comes from an inheritance tree and inherits both capabilities from three separate classes. Wrapper derives from nothing and writes both capabilities in its own body. Declarer derives from the abstract base and writes both bodies too.

  • CD72 — The rig’s four-class tree and Wrapper are the shared definition’s form; the contracts are set up separately and the tree is untouched.
  • CD73 — Both contracts ask for the same two names; the only difference between them is what the test looks at.
  • CD74 — Three things are measured separately for every candidate: whether the names are actually found, the two tests’ results, and the result the call gives.
  • CD75 — The last column is the string format() returns; it is the same result the second lesson measured, printed again here.
"""Nominal and structural contract: which test does the same object pass."""

from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable


class Record:
    def source(self):
        return "Record"

    def format(self):
        return f"<{self.source()}>"


class Timed(Record):
    def stamp(self):
        return "time"


class Signed(Record):
    def source(self):
        return "Signed"

    def signature(self):
        return "signature"


class Document(Timed, Signed):
    pass


class Wrapper:
    def __init__(self):
        self.timed = Timed()
        self.signed = Signed()

    def source(self):
        return self.signed.source()

    def stamp(self):
        return self.timed.stamp()

    def signature(self):
        return self.signed.signature()

    def format(self):
        return f"<{self.source()}>"


class Formattable(ABC):
    """Nominal contract: demands ancestry."""

    @abstractmethod
    def source(self):
        ...

    @abstractmethod
    def format(self):
        ...


@runtime_checkable
class FormattableProtocol(Protocol):
    """Structural contract: looks for method names."""

    def source(self): ...

    def format(self): ...


class Declarer(Formattable):
    """Declares the contract by ancestry and writes both bodies."""

    def source(self):
        return "Declarer"

    def format(self):
        return f"<{self.source()}>"


CANDIDATES = (("Document", Document()), ("Wrapper", Wrapper()), ("Declarer", Declarer()))

print(f"{'candidate':<10s} {'has both methods':>17s} {'ancestry test':>15s} "
      f"{'method test':>13s} {'format() result':>16s}")
for name, n in CANDIDATES:
    print(f"{name:<10s} "
          f"{str(all(hasattr(n, y) for y in ('source', 'format'))):>17s} "
          f"{str(isinstance(n, Formattable)):>15s} "
          f"{str(isinstance(n, FormattableProtocol)):>13s} {n.format():>16s}")

nominal = sum(1 for _, n in CANDIDATES if isinstance(n, Formattable))
structural = sum(1 for _, n in CANDIDATES if isinstance(n, FormattableProtocol))
print(f"\ncandidates {len(CANDIDATES)}, passing ancestry test {nominal}, "
      f"passing method test {structural}, answering the call {len(CANDIDATES)}")
candidate   has both methods   ancestry test   method test  format() result
Document                True           False          True         <Signed>
Wrapper                 True           False          True         <Signed>
Declarer                True            True          True       <Declarer>

candidates 3, passing ancestry test 1, passing method test 3, answering the call 3

All three of the three candidates answer the call. Passing the ancestry test is 1, passing the method test is 3. The two objects in between — Document and Wrapper — do everything the contract asks for and still fail the nominal test, because that test looks not at the work done, but at the declared ancestry.

The two candidates fail for separate reasons, and this shows how narrow the test is. Wrapper does not even know the contract exists. Document carries both bodies the contract asks for through inheritance — as the second lesson measured, format’s body is in Record, source’s in Signed. Both sit exactly where they should, both work, and the test still gives False.

The structural test accepts both objects, because it asks a different question: are the names the contract asks for present on this object? If they are, where they come from does not matter — its own body, inheritance, or composition. The contract here is defined as a surface, not an ancestry.

The two tests’ cost is not the same either. The nominal test looks at a single ancestry relationship; its result is known the moment the class is built and is not recomputed on every test. The structural test has to search the object for every name the contract asks for, and as the contract grows, the number of names searched grows with it. The contract in this measurement asks for two names; a contract asking for ten names makes the same test do ten searches. The split does not change direction here either: the nominal test is cheap and narrow, the structural test is expensive and broad.

Choosing between an interface and an abstract class, narrowing interfaces to the client, and preserving a contract across subtypes were established in the Software Design and Architecture Principles curriculum; those discussions are not repeated here. The only thing measured here is which test the same object passes.

The measurement does not force a choice either; it says under which condition each test is right. If we write both sides of the contract ourselves, a nominal declaration pays off: the declaring class states clearly what it must provide, and is stopped if it falls short. If others will write the objects satisfying the contract, a nominal declaration becomes a burden — demanding that every acceptable object derive from our definition leaves out objects like Document and Wrapper that do the job completely. This is why most of the standard library’s container contracts carry a hook that searches for bodies: when a class writes the required methods, it counts as a provider of the contract without setting up any ancestry at all.

When the Contract Is Enforced

The nominal contract also has an enforcement side. A class deriving from an abstract base has to write all the declared bodies. But at which point is this requirement checked — when the class is defined, or when an instance is built?

  • CD76 — Both classes derive from the same abstract base; one writes both bodies, the other only one.
  • CD77 — The “at definition time” column is read from the block running: since both classes can be defined, its value is constant. The “building an instance” column is the call’s result, and a falling call records the exception’s name with an exclamation prefix.
class Incomplete(Formattable):
    """Declares the contract but writes only one of the two bodies."""

    def source(self):
        return "Incomplete"


def attempt(action):
    try:
        action()
    except Exception as e:
        return f"!{type(e).__name__}"
    return "passed"


print(f"{'class':<10s} {'at definition time':>19s} {'building an instance':>21s} "
      f"{'unwritten method':>17s}")
for cls in (Declarer, Incomplete):
    print(f"{cls.__name__:<10s} {'built':>19s} {attempt(cls):>21s} "
          f"{str(sorted(cls.__abstractmethods__)):>17s}")
class       at definition time  building an instance  unwritten method
Declarer                 built                passed                []
Incomplete               built            !TypeError        ['format']

The incomplete class can be defined. The error is not raised at definition time, it is raised when an attempt is made to build the first instance, and the last column gives the missing name. The distinction matters in practice: the incomplete class can sit in a file, be imported, and serve as a base for other classes. Nothing happens as long as it is never instantiated itself — this is exactly why the abstract base itself cannot be instantiated either. Placing the check at this point is the right choice: an incomplete class can still be a useful definition, but an incomplete object cannot answer the call while claiming to carry the contract. What is stopped is not the definition, it is the claim.

This is notable as the one contract that is actually enforced. The third lesson measured that naming blocks 0 access; here, the declared contract is genuinely enforced, and an incomplete implementation is stopped at runtime. But the enforcement point is narrow: only the presence of the body is tested. Whether the written body does the right thing, or returns the right value, is not part of this test at all.

Tying the check to instance construction also grants a convenience. The set of unwritten methods is recomputed for every subclass; a class can stay as an intermediate layer and deliberately leave some bodies unwritten, and a class at the end of the chain completes the gaps. As long as the intermediate class is never instantiated, it meets no obstacle. The contract can thus be split across more than one class — what is checked is not a single class’s own body, it is the entirety of the bodies gathered along the resolution order.

The Two Tests’ Blind Spots

Objects can be built that both tests accept but that do not answer the call. The two blind spots mirror each other: the nominal test reads ancestry and does not look at the body; the structural test reads the name and does not look at the signature.

  • CD78 — The first candidate writes no body at all and is registered by hand with the contract; the registration changes only the ancestry information.
  • CD79 — The second candidate carries both names, but both methods demand one extra argument; the names are correct, the calling form is wrong.
  • CD80 — The third candidate is the previous measurement’s correct class, added as a comparison baseline. The call column is made with no arguments on every candidate.
class Empty:
    """Writes no body at all; is registered with the contract by hand."""


Formattable.register(Empty)


class WrongSignature:
    """Carries both names, but both demand one extra argument."""

    def source(self, extra):
        return f"WrongSignature-{extra}"

    def format(self, extra):
        return f"<{self.source(extra)}>"


BLIND_SPOTS = (("Empty", Empty()), ("WrongSignature", WrongSignature()),
               ("Declarer", Declarer()))

print(f"{'candidate':<14s} {'ancestry test':>15s} {'method test':>13s} "
      f"{'format() call':>15s}")
for name, n in BLIND_SPOTS:
    print(f"{name:<14s} {str(isinstance(n, Formattable)):>15s} "
          f"{str(isinstance(n, FormattableProtocol)):>13s} "
          f"{attempt(n.format) if hasattr(n, 'format') else '!AttributeError':>15s}")

accepted = sum(1 for _, n in BLIND_SPOTS
               if isinstance(n, Formattable) or isinstance(n, FormattableProtocol))
answering = sum(1 for _, n in BLIND_SPOTS
                if hasattr(n, "format") and attempt(n.format) == "passed")
print(f"\ncandidates {len(BLIND_SPOTS)}, passing at least one test {accepted}, "
      f"answering the call {answering}")
candidate        ancestry test   method test   format() call
Empty                     True         False !AttributeError
WrongSignature           False          True      !TypeError
Declarer                  True          True          passed

candidates 3, passing at least one test 3, answering the call 1

3 of three candidates pass at least one test; answering the call is 1. The first row is the nominal test’s blindness: Empty writes no body at all, is registered with the contract by hand, and the test gives True. Registration changes only ancestry information; it never asks about the bodies written. The call falls with AttributeError — an object that passed the test does not even carry the name being searched for.

The second row is the structural test’s blindness. WrongSignature carries both names, and the test gives True; but both methods demand one extra argument, and a no-argument call falls with TypeError. The structural test looks at whether the name exists; it does not look at its parameters, its return value, or what its body does. The surface’s name matches, its shape does not.

The two rows together give this lesson’s finding. The nominal test verifies a declared intent; it does not verify that the intent was fulfilled. The structural test verifies existing names; it does not verify that those names are callable. Neither says “this object answers the call,” and an object accepted by both can still be wrong.

This finding’s measured counterpart is completed when set next to the first table. In the first measurement, 3 of three candidates answered the call, and the nominal test accepted only 1 of them — the test was too narrow. In the third measurement, both tests each accepted one object that did not answer the call — the tests were too wide. The same tool under-filters in one direction and over-filters in the other. The right contract is not in the test itself, it is in choosing the test knowing what it measures.

Where the blind spots close is also known, and it differs for each. The nominal test’s blind spot is specific to hand-written registration; when ancestry is set up through inheritance, the second measurement’s check kicks in, and an instance of an incompletely implemented class can never even be built. Hand registration is a declaration that bypasses this check and leaves the responsibility with whoever registers it. The structural test’s blind spot does not close: a name search done at runtime can never see the signature under any condition. The side that could see the signature is a side that reads the code without running it.

This split turns into a writing rule in practice. A test passing is not a guarantee the call will be answered; the test is only a pre-filter. Testing an incoming object is not a substitute for calling it — and since an object that has passed the test can still fall on the call, who closes the gap between the test and the call has to be decided separately.

Summary

  • The nominal test searches for ancestry, the structural test searches for method names; of the same three candidates, 1 passes the first, 3 pass the second, and 3 answer the call.
  • Document and Wrapper both carry the two bodies the contract asks for — one through inheritance, the other through composition — and both fail the nominal test; the test looks not at the work done, but at the declared ancestry.
  • The contract declared by an abstract base is enforced not at definition time, but when the first instance is built: an incomplete class can be defined, its instantiation falls with TypeError, and the unwritten body’s name is reported.
  • The nominal test’s blind spot is the body: a class writing no body at all passes the test once registered by hand with the contract, and the call falls with AttributeError.
  • The structural test’s blind spot is the signature: a class carrying both names but demanding an extra argument passes the test, and the call falls with TypeError.
  • 3 of three blind-spot candidates pass at least one test, and 1 answers the call: passing the test does not say the call will be answered.

Next Step

Throughout this topic the contract was always declared in code. The abstract base demanded bodies be written and did not let an instance be built when they were not; the protocol demanded names exist and gave the test’s result at runtime. Both stayed incomplete, but both were working checks: what was declared was compared against what existed, and the result came back as a value.

The last measurement’s gap calls for a different kind of declaration. WrongSignature passed the test, because all the test saw was names; how many arguments the method takes, what the arguments look like, and what it returns entered no test at all. This information can be written into code — Python gives a spelling for it, and the previous lesson’s short definition already used that same spelling as its field declaration. The next topic starts exactly here: once a contract is put down in writing, who tests it, what does runtime do with that writing, and where does the error surface when what is written and what is done come apart?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close