In this post I describe the “ternary operator” and argue that although people refer to a certain Python structure by this name, in reality Python doesn’t have a ternary operator at all.
What does it mean to be “ternary”?
“The ternary operator” usually means a specialized if...else...
construction, first introduced in C:
condition ? if_true : if_false
(Kernighan and Ritchie, The C Programming Language, second edition, secs. 2.11, A7.16.)
The word “ternary” itself is well known — maybe best known in the tech community — in this usage. It isn’t limited to computer science — it just means “three-part” or “of order 3”, and it corresponds to unary for “one-”, binary for “two-”, and quaternary for “four-” in the same senses. An important usage of ternary other than in programming is in music: ternary measure: ‘triple time’ (a time signature of 3/4, 3/8, etc.).
In programming we are really talking about what we should call a “two-way decision expression”. In C this expression is “an operator” because it is a single entity, even though it has two parts that always appear separated by one of the arguments. Granted that the format is anomalous, many languages have adopted it: JavaScript, Java, Ruby, Bash, PHP. Here is Bash, for instance:
$(( condition ? if_true : if_false ))
This is the only C operator that requires three arguments — we don’t bother using ternary to describe functions that take three arguments, or series of keywords acting on three expressions.
Does Python have something comparable?
Arguments against a Python ternary operator
Below I make three arguments against the idea that Python has a comparable structure:
-
argument by form: Python syntax
-
argument by effect: Python bytecode
-
argument by design: Python expression grammar
Argument by form: Python syntax
In C, significantly, the element ?
isn’t otherwise used as an operator. In particular, it doesn’t otherwise have the meaning if
. Operator :
appears only in an unrelated use (declaring the bits assigned to a field in a struct). Together,
? :
in C constitutes a single operator that cannot reasonably be confused with anything else.
Contrast the situation in Python. The Python structure that many people refer to as a ternary operator is not an operator at all — it is an expression, and in terms of its superficial form it is just a variety of if
-statement syntax, differing in the placement and order of its keywords:
x = True if random.randint(0, 1) else False
That is an inline equivalent to this ordinary if...else...
statement:
if random.randint(0, 1):
x = True
else:
x = False
Such a structure does not deserve the name “ternary” because it is not a single operator — it is a series of keywords.
Question: Does the rearranged syntax mean that, internally, Python handles it differently from a standard if...else...
statement?
Argument by effect: Bytecode instructions in CPython
To answer that, we can use the dis
module to examine empirically the bytecode instructions into which a two-way decision expression is parsed, and compare it with traditional if...else...
.
Here is a simple example:
>>> import random
>>> def if_else():
... return True if random.randint(0, 1) else False
...
>>> dis.dis(if_else)
2 0 LOAD_GLOBAL 0 (random)
3 LOAD_ATTR 1 (randint)
6 LOAD_CONST 1 (0)
9 LOAD_CONST 2 (1)
12 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
15 POP_JUMP_IF_FALSE 22
18 LOAD_CONST 3 (True)
21 RETURN_VALUE
>> 22 LOAD_CONST 4 (False)
25 RETURN_VALUE
We can see that the function loads randint
, chooses 0 or 1 at random, and then based on the result either jumps to address 22 (return False
) or continues to address 18 (return True
).
And here is an equivalent function using a standard if...else...
block:
>>> def if_else_standard():
... if random.randint(0, 1):
... return True
... else:
... pass
... return False
...
>>> dis.dis(if_else_standard)
2 0 LOAD_GLOBAL 0 (random)
3 LOAD_ATTR 1 (randint)
6 LOAD_CONST 1 (0)
9 LOAD_CONST 2 (1)
12 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
15 POP_JUMP_IF_FALSE 22
3 18 LOAD_CONST 3 (True)
21 RETURN_VALUE
4 >> 22 LOAD_CONST 4 (False)
25 RETURN_VALUE
The sequence of instructions — opcodes, instruction-addresses, and arguments — is identical. These are the same function, apart from the trivial disparity of how many lines the original code is spread over.
Question: Granted that the if...else...
statemnt and the two-way decision expression are fundamentally the same in syntax and underlying bytecode instructions, does the parser travel from syntax to bytecode by the same path in both cases?
Argument by design: Python grammar
To answer that, we can look at the Python expression grammar — the roadmap describing how Python is parsed. An expression grammar is a series of lines — on the left part of the line is a single symbol, followed by a colon; to the right of the colon is some expression or series of expressions that can be decomposed into keywords (in quotes), symbols, and a few basic operators:
(...)*
(parentheses and asterisk) for zero or more occurrences of what is contained in the parentheses;|
(pipe) for logical disjunction;[...]
(square brackets) for optional content that occurs at most once.
Python today uses a symbol test
for basic conditional evaluation, beginning in v. 2.6. Here is a pertinent snippet of the expression grammar defining if
and the test
symbol:
if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]
...
test: or_test ['if' or_test 'else' test] | lambdef
or_test: and_test ('or' and_test)*
and_test: not_test ('and' not_test)*
not_test: 'not' not_test | comparison
comparison: expr (comp_op expr)*
comp_op: '<'|'>'|'=='|'>='|'<='|'<>'|'!='|'in'|'not' 'in'|'is'|'is' 'not'
expr: xor_expr ('|' xor_expr)*
xor_expr: and_expr ('^' and_expr)*
and_expr: shift_expr ('&' shift_expr)*
...
(Symbols suite
, lambdef
, shift_expr
, and subsequent replacement expressions are omitted for simplification.)
Notice is that the if...[elif...]else...
statement is now a kind of wrapper around conditional evaluation with test
:
if_stmt: 'if' test ':' suite ('elif' test ':' suite)* ['else' ':' suite]
Interestingly, test
is more rigid:
test: or_test ['if' or_test 'else' test] | lambdef
It requires else
, which is optional in an if
statement. Symbol test
itself resolves ultimately to a comparison
expression
comparison: expr (comp_op expr)*
In other words, in Python a two-way decision expression is not a distinct structure from an if
statement at all.
So there is simply no “ternary operator” in Python.
Addendum: How the two-way decision statement got into Python
Symbol test
had initially been introduced along with testlist
in v. 2.3 for support of filtering (without an else
option) in list comprehensions, already in existence since v. 2.0.
Actual two-way decision expressions entered Python in the rule for the expression
symbol, between versions 2.4:
expression ::= or_test | lambda_form
and 2.5:
expression ::= or_test [if or_test else test] | lambda_form
In versions 2.5.1 through 2.5.4 this rule was amplified to:
expression ::= conditional_expression | lambda_form
...
conditional_expression ::= or_test ["if" or_test "else" expression]
But in v. 2.6 the symbol test
was restored, to replace conditional_expression
, and that is how things remain in v. 3.5.
In v. 2.6, symbol comparison
now pointed to expr
, rather than or_expr
, as it had from v. 1.4 onward; expr
now pointed to xor_expr
. What that means is that the test
symbol that ultimately supported if
statements until v. 2.6 was founded on logical OR
.
Addendum on quaternary
Note that it has only two instances of letter r — quaternary, not *quaRternary.
Before you ask: quinary: ‘five-part’ or ‘of order 5’. Sexenary, septenary, octonary, nonary/novenary, and decenary are also attested. Vicenary/vigenary/vigesimal for twenty, triacontad for thirty, centenary for one hundred, millenary for one thousand. Stop me, someone.
[end]