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:
- If
__bool__is defined on the object, it is called; the value it returns is the result itself. - If not defined,
__len__is called; if the result is greater than zero, the object is counted true. - 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.
Trackerdefines only__len__,NonEmptyadds__bool__to it,Silentdefines 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. - CF6 —
Trackerdefines 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
ifis 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<cand 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 aTypeError. andandordo 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.