Skip to content
academia.sh

Lesson 07 / 16

Conditionals

A condition does not expect a logical value, it puts the object through the truthiness rule: eight protocol calls are measured across twelve trials, four return true without calling any method, and a comparison chain produces its middle term once.

Contents

The previous lesson measured type conversion and showed that where conversion fails, an exception is born: when an object cannot answer for the type demanded of it, flow is interrupted. Conversion asks the object “what do you correspond to as an integer?” and that question has the right to go unanswered.

A condition also asks the object a question, but a different one: “are you counted as true?” Unlike conversion’s, the answer here is almost never an exception — if the object does not give the answer itself, the language produces one in its place. So who gives that answer? Does if look inside the object and count its items, check its type, or ask the object? This is what this lesson measures.

A Condition Does Not Expect a Logical Value

The Programming Fundamentals course’s conditional-branching lesson defined a condition as an expression producing a logical value; that definition was language-independent and built branching as a control structure. In Python the definition is broader: any object can be written after the if keyword, and the language reduces it to a truth value itself. What is measured here is not branching itself, but which special method that reduction calls.

The reduction rule is called truthiness and has three rungs:

  1. If __bool__ is defined on the object, it is called; the value it returns is the result itself.
  2. If not defined, __len__ is called; if the result is greater than zero, the object is counted true.
  3. If neither is defined, no method is called at all and the object is always true.

The third rung is the most overlooked part of the rule. An empty object being counted false is not a language rule, it is the object’s own declaration: if there is no method declaring it, there is no concept of emptiness either. An ordinary object, whatever it contains, is true.

A Chain Is a Shorthand

Comparison operators are chainable in Python: a < b < c is a valid expression. This notation does not mean a left-to-right pairwise comparison — if it did, a < b would produce a truth value, that value would be compared with c, and the result would come out meaningless.

The chain means the expression a < b and b < c, with two differences. First, the middle term b is evaluated once. In the expanded notation b is written twice, and if it is a function call, it runs twice. Second, like and, a chain also short-circuits: if the first comparison comes out false, the second is never even built, and the right-hand term is not even produced.

The chaining rule applies not only to < and > but to every comparison operator; membership and identity operators are in the same class. This is why a is b is c is as valid a chain as a < b <= c and carries the same two guarantees. A chain has no length limit: each additional term builds its own comparison with the one before it, and never comes up unless everything to its left has come out true.

Neither of these is visible to the eye. The only way to see how many times terms are produced is to build a rig that counts production.

What Short-Circuiting Returns

The and and or operators do not return a logical value; they return one of the operands. The expression x or y gives x if x is true, y otherwise. What is returned is the object itself, not its truth value.

The protocol the two call is separate too: and and or do not call their own special method on the operands the way an arithmetic operator does. All they do is put the left operand through the truthiness rule and decide, based on the result, which operand to return. The right operand is returned if needed — it is not tested.

The measurement’s assumptions:

  • CF1 — The oracle is the rig itself: which special method gets called is known because the object records it itself; the measurement is not an outside observation, it is the object’s own declaration.
  • CF2 — Three classes are used. Tracker defines only __len__, NonEmpty adds __bool__ to it, Silent defines neither. The three classes correspond exactly to the three rungs of the truthiness rule.
  • CF3 — Each class is tested in an empty and a three-item state; the item count changes not to change the result, but to distinguish who determines the result.
  • CF4 — The chain’s middle term is produced by a function call, and every production is logged; this log is the measure for the question “how many times was it evaluated.”
  • CF5__lt__ compares by length. What the comparison is based on does not enter the measurement; what is measured is how many times it is called.
  • CF6Tracker defines only __lt__, its counterpart __gt__ is deliberately left undefined, so that reflection can be measured.
  • CF7 — The calls counted come only from the forms this lesson builds; other forms in the shared reference are not part of this measurement.

Measurement

"""Truthiness: which special method does `if n` call, in what order."""

LOG = []


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


class Tracker:
    """__len__ defined, __bool__ undefined."""

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

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

    def __lt__(self, other):
        record("__lt__")
        return len(self.items) < len(other.items)


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

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


class Silent:
    """Neither __bool__ nor __len__ defined."""

    def __init__(self, items=()):
        self.items = list(items)


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


OBJECTS = (
    ("Tracker(3 items)", lambda: Tracker((1, 2, 3))),
    ("Tracker(0 items)", lambda: Tracker(())),
    ("NonEmpty(3 items)", lambda: NonEmpty((1, 2, 3))),
    ("NonEmpty(0 items)", lambda: NonEmpty(())),
    ("Silent(3 items)", lambda: Silent((1, 2, 3))),
    ("Silent(0 items)", lambda: Silent(())),
)

print(f"{'object':<19s} {'if n':>6s} {'not n':>6s}  protocol called")
total = trials = silent = 0
for name, build in OBJECTS:
    n = build()
    c1, d1 = measure(lambda: bool(n))
    c2, d2 = measure(lambda: not n)
    total += len(c1) + len(c2)
    trials += 2
    silent += (len(c1) == 0) + (len(c2) == 0)
    print(f"  {name:<17s} {str(d1):>6s} {str(d2):>6s}  {' '.join(c1) or '(no method)'}")
print(f"\ntrials {trials}, calls {total}, calling no method {silent}")

PRODUCED = []


def produce(name, n):
    PRODUCED.append(name)
    return Tracker(range(n))


def chained():
    return produce("a", 1) < produce("b", 2) < produce("c", 3)


def expanded():
    return (produce("a", 1) < produce("b", 2)) and (produce("b", 2) < produce("c", 3))


def short_circuit():
    return produce("a", 3) < produce("b", 2) < produce("c", 9)


print()
print(f"{'form':<17s} {'result':>6s} {'__lt__':>7s} {'produced':>9s}  order")
for name, f in (("a<b<c", chained), ("a<b and b<c", expanded),
                ("a<b<c  (false)", short_circuit)):
    PRODUCED.clear()
    c, d = measure(f)
    print(f"  {name:<15s} {str(d):>6s} {c.count('__lt__'):7d}"
          f" {len(PRODUCED):9d}  {' '.join(PRODUCED)}")

print()
print(f"{'comparison':<25s} {'result':>6s}  protocol called")
for name, f in (("Tracker(1) < Tracker(2)", lambda: Tracker((1,)) < Tracker((1, 2))),
              ("Tracker(2) > Tracker(1)", lambda: Tracker((1, 2)) > Tracker((1,))),
              ("Silent(1) < Silent(2)", lambda: Silent((1,)) < Silent((1, 2)))):
    c, d = measure(f)
    print(f"  {name:<23s} {str(d):>6s}  {' '.join(c)}")

print()
print(f"{'expression':<32s} {'calls':>6s}  {'returned type':<16s} protocol")
EMPTY, FULL = Tracker(()), NonEmpty((1, 2))
for name, f in (("Tracker(0) or NonEmpty(2)", lambda: EMPTY or FULL),
              ("NonEmpty(2) or Tracker(0)", lambda: FULL or EMPTY),
              ("Tracker(0) and NonEmpty(2)", lambda: EMPTY and FULL),
              ("bool(Tracker(0) or Tracker(0))", lambda: bool(Tracker(()) or Tracker(())))):
    c, d = measure(f)
    print(f"  {name:<30s} {len(c):6d}  {type(d).__name__:<16s} {' '.join(c)}")
object                if n  not n  protocol called
  Tracker(3 items)    True  False  __len__
  Tracker(0 items)   False   True  __len__
  NonEmpty(3 items)   True  False  __bool__
  NonEmpty(0 items)  False   True  __bool__
  Silent(3 items)     True  False  (no method)
  Silent(0 items)     True  False  (no method)

trials 12, calls 8, calling no method 4

form              result  __lt__  produced  order
  a<b<c             True       2         3  a b c
  a<b and b<c       True       2         4  a b b c
  a<b<c  (false)   False       1         2  a b

comparison                result  protocol called
  Tracker(1) < Tracker(2)   True  __lt__
  Tracker(2) > Tracker(1)   True  __lt__
  Silent(1) < Silent(2)     None  !TypeError

expression                        calls  returned type    protocol
  Tracker(0) or NonEmpty(2)           1  NonEmpty         __len__
  NonEmpty(2) or Tracker(0)           1  NonEmpty         __bool__
  Tracker(0) and NonEmpty(2)          1  Tracker          __len__
  bool(Tracker(0) or Tracker(0))      2  bool             __len__ __len__

The Cost of the Three Rungs

The top table lays the truthiness rule’s three rungs side by side. There are eight calls across twelve trials; the remaining four trials produce a result without calling any method.

The first two rows are the second rung: because Tracker does not define __bool__, the trial falls to __len__, and the item count determines the result. The third and fourth rows are the first rung: the moment __bool__ is added to the same class, __len__ is never called at all. When both are defined, there is no question of which method gets called — if __bool__ exists, __len__ takes no part in the trial.

The last two rows are the third rung, and this is where the table’s real result shows. Silent(0 items) is empty, but the trial gives true — and it does this not by calling a method, but by calling none at all. Whether the object has three items or zero does not change the result, because there is no method to produce the result. The practical consequence: a program testing an empty instance of its own class with if, if it forgot to write __len__ or __bool__, always takes the true branch, and nothing signals that this happened.

The not n column shows the same chain running once more: negation is not a separate protocol, it is the reverse of the same trial’s result. The only difference between the columns shows up in the Silent rows — not returns not an object, but a real logical value.

How Many Terms a Chain Produces

The middle table shows in numbers that a chain is a shorthand. a<b<c and the expanded notation call __lt__ the same number of times: both 2. There is no gain in comparison count.

The difference is in the produced column. The chain produces three terms, the expanded notation four: b is produced twice. This is a difference not of readability but of meaning. If the middle term is a function call, that function runs twice; if it has a side effect, the side effect happens twice; if it returns a different value on each call, the two comparisons test two separate values unaware of each other. The expanded notation does not give the guarantee the chain gives.

The third row measures short-circuiting. Because the first comparison comes out false, __lt__ is called 1 time and production stops at 2: c is never built at all. The term at the chain’s right end is something that will not be produced without looking to its left.

The Protocol Found in the Reverse Direction

The third table shows comparison’s own fallback rule, and the middle row is the lesson’s sharpest result. The Tracker class defines __lt__, not __gt__. Yet Tracker(2) > Tracker(1) runs — and the method called is __lt__.

The rule is this: if the left operand’s method cannot be found in a comparison, the language tries the right operand’s counterpart method. For a > b, __gt__ is first sought on a; if absent, __lt__ is called on b and the operands swap places. The result comes out correct, because “b is less than a” and “a is greater than b” are the same claim.

The direct consequence of this is the second claim itself: the operator written does not say the name of the method called. Two rows give True, both call the same method, but one writes < and the other >. It is impossible to guess which method ran by looking at the syntax; only measurement tells you.

The third row shows where the rule ends. Silent defines neither __lt__ nor __gt__; no method is found on either the left or the right operand, and the comparison produces not a result but a TypeError. While the third rung of the truthiness trial silently returns true, comparison’s last rung ends in an exception: one syntax has a default behavior, the other does not. An object with no protocol does not carry that syntax at all.

The Condition Statement and the Conditional Expression

The truthiness rule does not run only in the if statement. The same reduction runs in a while condition, in the and and or operators, in the not operator, and in the conditional expression too.

The conditional expression uses the statement-versus-expression distinction from the previous topic: if is a statement, it produces no value and cannot be written on the right side of an assignment. Its expression counterpart is the a if k else b notation, and it produces a value — so it can be placed on the right side of an assignment, in place of an argument, or inside a list.

The difference between the two is only in notation; the truthiness rule runs the same three rungs in both, and the object written in place of k goes through the same measurement. Short-circuiting is preserved too: in a conditional expression, only one of the a and b branches is evaluated. This is what separates a conditional expression from and/or chains — there, the returned value was one of the operands; here it is the chosen branch itself, and the branch not chosen is never built at all.

The Type of the Returned Object

The bottom table shows what and and or return, and in all three of its rows, what comes back is not a logical value.

First row: the left operand Tracker(0) is empty, the trial comes out false via __len__, and or returns the right operand — the returned type is NonEmpty. Second row: this time the left operand is true, tested via __bool__, and or returns the left operand. Third row: and does the reverse: because the left operand is false, the result is directly it, type Tracker.

All three rows’ call count is 1. The right operand is never tested; it is only returned. The consequence of this is exactly what the default-value pattern written with or does in Python: if the left operand comes out false by the truthiness rule, the right one is given — not because it is empty, but because it is counted false.

The last row separates the two. When the bool() built-in steps in, the returned type really becomes bool and the call count rises to 2: once or tests the left operand, once bool() tests the result. The cost of turning the same expression into a logical value is one extra protocol call.

Summary

  • In Python a condition does not expect a logical value; every object after if is put through the truthiness rule, and what is measured is which special method this reduction calls.
  • The rule has three rungs: if __bool__ exists it is called, otherwise it falls to __len__, and if that is also absent the object is counted true without calling any method — four of the twelve trials fall on this rung, and even an empty object comes back true.
  • While __bool__ is defined, __len__ takes no part in the trial at all; the two methods do not compete, the first disables the second.
  • A comparison chain a<b<c and the expanded notation call __lt__ the same number of times (2), but the chain produces the middle term once, the expanded notation twice; when the chain comes out false, the right term is never produced.
  • Comparison has its own fallback rule: if the left operand has no method, the right operand’s counterpart is called — an expression written with > can run __lt__; if neither exists, the result is not a value but a TypeError.
  • and and or do not return a logical value, they return one of the operands; they put only the left operand through the truthiness rule and never test the right one.

Next Step

The truthiness rule asks one question once and ends with one branch. A loop, on the other hand, asks not the same question but another question over and over: is there an item next in line? In the Programming Fundamentals course a loop was a control structure; how many turns it took depended on a condition or a collection’s length. In Python a loop is a protocol, and what decides the turns are ending is not a condition. The next lesson counts how many times a loop calls __next__ on a three-item object — and why that count is one more than the item count.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close