Operators - Arithmetic, Comparison, and Logical at Engineering Depth
Reading time: ~25 minutes | Level: Foundation → Engineering
Consider this snippet. Before reading further, predict every output:
print(2 ** 3 ** 2) # Is this 64 or 512?
print(-7 // 2) # Is this -3 or -4?
print(-7 % 2) # Is this -1 or 1?
print(0 or [] or "hi") # Bool? String? List?
print(5 and 0 and 1/0) # Does this crash?
x = [1, 2, 3]
y = x
y += [4]
print(x) # [1, 2, 3] or [1, 2, 3, 4]?
If any of those surprised you, this lesson closes the gap between writing Python and understanding Python. Operators are not just punctuation. Each one maps to a protocol of method calls, evaluation rules, and semantic contracts that every serious Python engineer must own completely.
What You Will Learn
- How every arithmetic operator delegates to a specific dunder (double-underscore) method on the object
- Why Python 3 changed
/to always return a float, and the mathematical reason//floors toward negative infinity rather than truncating toward zero - How chained comparisons work mechanically, and why
1 < x < 10is not the same as twoand-joined comparisons in most languages - The precise short-circuit semantics of
and/or, and why they return values, not booleans - How augmented assignment (
+=,*=) behaves differently for mutable and immutable types - and whyy += [4]mutates the original list - The walrus operator (
:=) and when it eliminates accidental double evaluation - Operator precedence pitfalls, including the right-associativity of
** - How to implement operator overloading in custom classes
Prerequisites
- Variables, types, and basic expressions (Lesson 03)
- Functions enough to read small examples (Lesson 06 optional)
- A mental model that Python objects carry methods - not just data
Part 1 - Arithmetic Operators and the Dunder Protocol
Operators Are Method Calls
When you write a + b, Python does not execute a built-in addition instruction directly. It calls a.__add__(b). If a does not implement __add__, Python tries b.__radd__(a) (the reflected version). Only if both return NotImplemented does Python raise TypeError.
x = 10
y = 3
print(x + y) # x.__add__(y) → 13
print(x - y) # x.__sub__(y) → 7
print(x * y) # x.__mul__(y) → 30
print(x / y) # x.__truediv__(y) → 3.3333...
print(x // y) # x.__floordiv__(y) → 3
print(x % y) # x.__mod__(y) → 1
print(x ** y) # x.__pow__(y) → 1000
Every one of those symbols is syntactic sugar over an object method. This is why operators work on strings, lists, custom classes - anything that defines the right dunder.
# String + delegates to str.__add__
print("hello" + " world") # "hello world"
# List * delegates to list.__mul__
print([0] * 4) # [0, 0, 0, 0]
# These are not "special" - they are the same protocol
The Full Arithmetic Dunder Map
| Operator | Dunder method | Reflected dunder |
|---|---|---|
+ | __add__ | __radd__ |
- | __sub__ | __rsub__ |
* | __mul__ | __rmul__ |
/ | __truediv__ | __rtruediv__ |
// | __floordiv__ | __rfloordiv__ |
% | __mod__ | __rmod__ |
** | __pow__ | __rpow__ |
-x | __neg__ | (no reflected) |
+x | __pos__ | (no reflected) |
abs(x) | __abs__ | (no reflected) |
True Division (/) - Why Python 3 Changed the Rules
In Python 2, 5 / 2 returned 2 (integer division) when both operands were integers. This caused endless bugs in scientific code. Python 3 made / always invoke __truediv__, which always returns a float:
print(5 / 2) # 2.5
print(4 / 2) # 2.0 ← still float, even when evenly divisible
print(-7 / 2) # -3.5
If you explicitly want integer division, use //. If you are writing a library that must run under both Python versions, use from __future__ import division at the top (Python 2 only - irrelevant today but good to know when reading legacy code).
Floor Division (//) - Floor, Not Truncation
This is one of the most commonly misunderstood points in Python. // performs floor division - it rounds toward negative infinity, not toward zero.
print( 7 // 2) # 3 (floor of 3.5 → 3)
print(-7 // 2) # -4 (floor of -3.5 → -4, not -3)
print( 7 // -2) # -4 (floor of -3.5 → -4)
print(-7 // -2) # 3 (floor of 3.5 → 3)
Visualizing on the number line:
-4 -3.5 -3 ... 3 3.5 4
|----------*---------| |--------*----------|
^ ^
floor(-3.5) = -4 floor(3.5) = 3
Truncation (what C uses) would give -3 for -7 / 2. Python's choice is mathematically consistent with modulo: it guarantees the invariant a == (a // b) * b + (a % b) holds for all integers, including negatives.
Modulo (%) - The Consequence of Flooring
Because // floors, % in Python always has the same sign as the divisor (the right operand), not the dividend:
print( 7 % 2) # 1 (positive divisor → positive result)
print(-7 % 2) # 1 (positive divisor → positive result)
print( 7 % -2) # -1 (negative divisor → negative result)
print(-7 % -2) # -1 (negative divisor → negative result)
Verification of the invariant for -7 % 2:
-7 // 2 == -4-4 * 2 == -8-7 - (-8) == 1✓
This differs from C/Java where % takes the sign of the dividend. If you need C-style remainder, use math.fmod(a, b).
Practical use of modulo: Wrap-around indexing in circular buffers (index % buffer_size), checking divisibility (n % k == 0), extracting digits (n % 10 gives the units digit), and clock arithmetic all rely on modulo. Python's sign-follows-divisor rule is ideal for these because it never produces unexpected negative indices.
Exponentiation (**) - Right-Associative
** is right-associative. This surprises many engineers who assume all binary operators are left-associative.
print(2 ** 3 ** 2) # 512, not 64
# Parsed as: 2 ** (3 ** 2) = 2 ** 9 = 512
# NOT as: (2 ** 3) ** 2 = 8 ** 2 = 64
Right-associativity matches mathematical notation: a^(b^c) means a^(b^c) in standard math. Python honors this convention.
Right-associativity of ** is a real interview trap. Always parenthesize when chaining exponentiation in code to communicate intent, even when the result would be the same.
Python Integers Have No Overflow
In C, int is 32 bits - overflow wraps around or causes undefined behavior. In Python, integers are arbitrary precision objects. They grow to whatever size is needed:
print(2 ** 100)
# 1267650600228229401496703205376 - no overflow, no exception
factorial = 1
for i in range(1, 101):
factorial *= i
print(factorial) # 158-digit number, no problem
This is both a feature (correctness) and a cost (large ints allocate heap memory). In performance-critical code, use numpy with fixed-width dtypes when you need predictable memory layout.
Part 2 - Comparison Operators
The Six Comparisons and Their Dunders
| Operator | Dunder | Meaning |
|---|---|---|
== | __eq__ | Equal in value |
!= | __ne__ | Not equal in value |
< | __lt__ | Strictly less than |
> | __gt__ | Strictly greater than |
<= | __le__ | Less than or equal |
>= | __ge__ | Greater than or equal |
When a < b is evaluated, Python calls a.__lt__(b). If that returns NotImplemented, Python tries b.__gt__(a) as the reflected operation.
print(5 > 3) # True - int.__gt__
print(5 == 5.0) # True - int.__eq__ handles cross-type numeric comparison
print(5 == "5") # False - int.__eq__ returns NotImplemented, str.__eq__ returns NotImplemented, falls back to identity → False
Chained Comparisons - How They Actually Work
Python's chained comparisons are a language feature, not just syntactic sugar for and. The expression 1 < x < 10 is equivalent to (1 < x) and (x < 10) but with the crucial property that x is evaluated exactly once. This matters when x has side effects.
x = 5
print(1 < x < 10) # True
print(1 < x < 4) # False
print(1 < x > 2) # True - works, but avoid for clarity
print(1 == 1 == 1) # True - chains work with ==
print(1 < 2 > 1) # True - legal, but confusing
# The single-evaluation guarantee:
import random
def side_effect():
result = random.randint(1, 10)
print(f"evaluated: {result}")
return result
# x evaluated once in the chained form:
val = 1 < side_effect() < 10 # prints "evaluated: N" exactly once
You can chain as many comparisons as you like. The result is True only if every consecutive pair satisfies its comparison.
Comparing Different Types
Python 3 raises TypeError when you try to order-compare fundamentally incompatible types:
print(5 == 5.0) # True - equality works across numeric types
print(5 == "5") # False - no TypeError, just not equal
# But ordering fails:
try:
print(5 < "5")
except TypeError as e:
print(e) # '<' not supported between instances of 'int' and 'str'
Use == freely across types to check equality. Python's == will return False rather than raise if types are incompatible. But never use <, >, <=, >= across incompatible types - sort key functions exist for mixed-type ordering.
is vs == - Identity vs Equality
== calls __eq__ and compares values. is compares object identity (memory address, via id()). They are fundamentally different:
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True - same value
print(a is b) # False - different objects in memory
c = a
print(a is c) # True - same object
The only correct use of is with literal values is None, True, and False - these are singletons:
# Correct
if result is None:
...
if flag is True: # actually, just `if flag:` is more Pythonic
...
# Wrong - CPython caches small integers (-5 to 256) so this might
# "work" but is undefined behavior and will fail for larger numbers
x = 1000
y = 1000
print(x is y) # False in most contexts - never rely on this
Part 3 - Logical Operators: Short-Circuit and Value Return
The Critical Insight: and and or Return Values, Not Booleans
This is the single most important and most misunderstood aspect of Python's logical operators. and and or do not return True or False. They return one of their operands.
# and: returns the first falsy value, or the last value if all are truthy
print(0 and 5) # 0 ← first falsy
print(5 and 10) # 10 ← all truthy, returns last
print(5 and 0 and 3) # 0 ← first falsy
print([] and "hi") # [] ← first falsy
# or: returns the first truthy value, or the last value if all are falsy
print(0 or 5) # 5 ← first truthy
print(5 or 10) # 5 ← first truthy (short-circuits, 10 never evaluated)
print(0 or [] or "hi") # "hi" ← first truthy
print(0 or [] or {}) # {} ← all falsy, returns last
The formal rule:
a and b:
if bool(a) is False → return a (short-circuit)
else → return b
a or b:
if bool(a) is True → return a (short-circuit)
else → return b
Short-Circuit Evaluation - The Execution Guarantee
"Short-circuit" means Python stops evaluating as soon as the result is determined. The remaining expressions are never executed:
def expensive():
print(" expensive() called")
return True
# and short-circuits on first falsy:
print(False and expensive()) # "expensive() called" is NEVER printed
print(True and expensive()) # "expensive() called" IS printed → True
# or short-circuits on first truthy:
print(True or expensive()) # "expensive() called" is NEVER printed
print(False or expensive()) # "expensive() called" IS printed → True
This is not just an optimization - it is a semantic guarantee you can design around:
# Safe attribute access - second condition never runs if user is None
user = None
if user and user.is_active:
print("Active user")
# Guard against division by zero
denominator = 0
if denominator != 0 and 100 / denominator > 0.5:
print("Significant")
# Chain of validations - stops at first failure
data = {"id": 42, "name": "Alice"}
if data and isinstance(data, dict) and "id" in data:
print(data["id"]) # 42
Short-circuit means side effects in the right operand may never run. Never put required side effects (logging, state mutation, I/O) in the right operand of and/or and expect them to always execute. This is a common source of subtle bugs.
The or-for-Default Pattern
Because or returns the first truthy value, it is used idiomatically to provide defaults:
name = user_input or "Guest"
config = provided_config or default_config
timeout = kwargs.get("timeout") or 30
The or default pattern fails silently when 0, "", [], or False are valid values. If user_input = 0 is meaningful, user_input or "Guest" will incorrectly use "Guest". Use if user_input is None explicitly, or Python 3's walrus operator with a conditional expression:
# Correct default that handles 0, "", False as valid:
name = "Guest" if user_input is None else user_input
The and-for-Guard Pattern
and returns the right operand when the left is truthy. This enables conditional return chains:
# Return the value only if the condition holds, else return falsy
result = is_valid and compute_result()
# Equivalent to:
result = compute_result() if is_valid else None # ← clearer, prefer this
The and guard pattern is less readable than a conditional expression for most humans. Prefer explicit if or ternary except in very simple cases.
not - Always Returns a Boolean
Unlike and and or, not always returns True or False:
print(not 0) # True
print(not 5) # False
print(not []) # True
print(not [1,2]) # False
print(not "") # True
print(not "hi") # False
Precedence note: not has lower precedence than comparisons:
print(not 5 > 3) # not (5 > 3) = not True = False
print(not 5 > 3 == True) # not ((5 > 3) == True) = not True = False
Always parenthesize not expressions when combining with comparisons to make intent explicit.
Part 4 - Augmented Assignment and Mutability
What += Actually Does
x += y is not simply x = x + y. It first tries x.__iadd__(y) (in-place add). If that is not defined, it falls back to x = x.__add__(y). The difference is significant for mutable objects:
# Immutable: int has no __iadd__, so += creates a new object
x = 5
old_id = id(x)
x += 3
print(x) # 8
print(id(x) == old_id) # False - new object was created
# Mutable: list has __iadd__ (calls extend), mutates IN PLACE
a = [1, 2, 3]
b = a # b and a point to the same list
old_id = id(a)
a += [4] # calls a.__iadd__([4]) → mutates the existing list
print(a) # [1, 2, 3, 4]
print(b) # [1, 2, 3, 4] ← b sees the change!
print(id(a) == old_id) # True - same object
This explains the opening puzzle. y += [4] mutated the original list because list.__iadd__ modifies in-place and returns self.
The full augmented assignment dunder map:
| Operator | In-place dunder | Fallback dunder |
|---|---|---|
+= | __iadd__ | __add__ |
-= | __isub__ | __sub__ |
*= | __imul__ | __mul__ |
/= | __itruediv__ | __truediv__ |
//= | __ifloordiv__ | __floordiv__ |
%= | __imod__ | __mod__ |
**= | __ipow__ | __pow__ |
Shared mutable references + augmented assignment = hidden state mutation. When multiple variables point to the same mutable object, += on one will silently change what all others see. This is a leading cause of bugs in Python - always be aware of whether you hold a reference or a copy.
Part 5 - The Walrus Operator (:=, Python 3.8+)
Assignment Expressions
The walrus operator := assigns a value to a variable as part of an expression. It does not replace =. It is for cases where you need to both capture and test a value in one step.
Before walrus - double evaluation:
# Reading from a network stream - processes chunk twice:
data = file.read(1024)
while data:
process(data)
data = file.read(1024)
With walrus - single evaluation:
while chunk := file.read(1024):
process(chunk)
In list comprehensions - avoiding repeated calls:
# Without walrus - expensive() called twice for each element that passes:
results = [expensive(x) for x in data if expensive(x) > 0]
# With walrus - expensive() called once per element:
results = [y for x in data if (y := expensive(x)) > 0]
Regex match capture:
import re
if m := re.match(r"(\d+)-(\w+)", line):
number, word = m.group(1), m.group(2)
print(f"Found: {number}, {word}")
The walrus operator has lower precedence than almost everything else. Always parenthesize it: (n := len(data)) rather than naked n := len(data) in complex expressions. In while and if conditions, the parentheses are often required by the parser.
Part 6 - Operator Precedence
The Full Precedence Table (Highest to Lowest)
| Priority | Operator(s) | Notes |
|---|---|---|
| 1 | () | Parentheses - always highest |
| 2 | ** | Exponentiation - RIGHT associative |
| 3 | +x, -x, ~x | Unary positive, negative, bitwise NOT |
| 4 | *, /, //, % | Multiplication, division, modulo |
| 5 | +, - | Addition, subtraction |
| 6 | <<, >> | Bitwise shifts |
| 7 | & | Bitwise AND |
| 8 | ^ | Bitwise XOR |
| 9 | | | Bitwise OR |
| 10 | ==, !=, <, >, <=, >=, is, is not, in, not in | Comparisons, membership, identity |
| 11 | not | Logical NOT |
| 12 | and | Logical AND |
| 13 | or | Logical OR |
| 14 | := | Walrus (lowest except lambda) |
| 15 | lambda | Lambda expression |
Surprising Cases
# ** before unary minus: -2**2 is -(2**2) = -4, not (-2)**2 = 4
print(-2 ** 2) # -4 ← surprises many engineers
print((-2) ** 2) # 4
# * before +: standard PEMDAS
print(2 + 3 * 4) # 14
# Bitwise & has LOWER precedence than ==:
print(4 & 5 == 4) # True? No - parsed as 4 & (5 == 4) = 4 & False = 0 → 0 → False!
print((4 & 5) == 4) # True - with explicit parens
# not before and before or:
print(True or False and False) # True - and binds tighter
print((True or False) and False) # False - with parens
Bitwise operators (&, |, ^) have lower precedence than comparisons. This surprises engineers from C backgrounds, where bitwise operators have different relative precedence to comparisons. When mixing bitwise and comparison operators in Python, always use explicit parentheses.
Part 7 - Operator Overloading in Custom Classes
Custom classes can implement the full operator protocol by defining dunder methods. This is not magic - it is the same protocol built-in types use.
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector2D({self.x}, {self.y})"
def __add__(self, other):
if isinstance(other, Vector2D):
return Vector2D(self.x + other.x, self.y + other.y)
return NotImplemented # Let Python try other.__radd__
def __mul__(self, scalar):
return Vector2D(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
# Handles: 3 * v (scalar on left)
return self.__mul__(scalar)
def __eq__(self, other):
if isinstance(other, Vector2D):
return self.x == other.x and self.y == other.y
return NotImplemented
def __lt__(self, other):
# Define ordering by magnitude squared
return (self.x**2 + self.y**2) < (other.x**2 + other.y**2)
v1 = Vector2D(1, 2)
v2 = Vector2D(3, 4)
print(v1 + v2) # Vector2D(4, 6)
print(v1 * 3) # Vector2D(3, 6)
print(3 * v1) # Vector2D(3, 6) ← uses __rmul__
print(v1 == v2) # False
print(v1 < v2) # True
Always return NotImplemented (not raise TypeError) from dunder methods when the operand type is not supported. Returning NotImplemented signals Python to try the reflected operation. Raising TypeError immediately prevents Python from attempting the fallback, which breaks composition with other types.
Interview Questions
Q1: Why does -7 // 2 equal -4 in Python instead of -3?
Python's // operator is defined as floor division - it rounds toward negative infinity, not toward zero. The exact result of -7 / 2 is -3.5. The floor of -3.5 is -4 (the largest integer not greater than -3.5). This is intentional: it ensures the mathematical invariant a == (a // b) * b + (a % b) holds for all integer combinations including negatives. C and Java truncate toward zero, giving -3, but this breaks the modulo invariant for negatives.
Q2: What does x = [] or [1, 2, 3] evaluate to, and when would this pattern fail?
It evaluates to [1, 2, 3] because [] is falsy, so or returns its right operand. This pattern fails when an empty list [] is a valid, meaningful value that should not trigger the default. For example, if a function legitimately returns [] to mean "no results found" and the caller writes data = fetch() or default_data, it will incorrectly use default_data when the function returned an intentional empty list. The correct pattern is data = fetch(); result = data if data is not None else default_data.
Q3: What is the difference between a += [1] and a = a + [1] when another variable references the same list?
a += [1] calls list.__iadd__([1]), which calls a.extend([1]) in place and returns self. The original list object is mutated. Any other variable holding a reference to the same list will see the change. a = a + [1] calls list.__add__([1]), which creates a new list, and then rebinds the name a to the new object. Other variables still point to the original, unchanged list. The difference is in-place mutation versus new object creation.
Q4: Explain short-circuit evaluation and give a production use case where it prevents a runtime error.
Short-circuit evaluation means Python stops evaluating and/or expressions as soon as the result is determined. For and, if the left operand is falsy, the right is never evaluated. For or, if the left is truthy, the right is never evaluated. Production example: if user and user.account and user.account.balance > 0. If user is None, the subsequent attribute accesses would raise AttributeError, but short-circuit evaluation stops at user because None is falsy, preventing the crash. This is a standard guard pattern in Python web frameworks and API handlers.
Q5: What does 2 ** 3 ** 2 evaluate to, and why?
It evaluates to 512. The ** operator is right-associative in Python, so it is parsed as 2 ** (3 ** 2). First, 3 ** 2 = 9, then 2 ** 9 = 512. If it were left-associative like most operators, it would be (2 ** 3) ** 2 = 8 ** 2 = 64. This matches mathematical convention where a^(b^c) means a^(b^c).
Q6: When should you use the walrus operator versus a regular assignment?
Use the walrus operator when you need to assign a value and test it in the same expression, especially to avoid computing the same value twice. The clearest use cases are: while loops over streams (while chunk := f.read(4096)), comprehensions that filter and transform with the same expensive call ([y for x in items if (y := process(x)) is not None]), and regex match capture inside if statements. Avoid the walrus operator in plain assignment contexts (use =) or when the expression becomes hard to read. The walrus operator does not replace = - it complements it for expression contexts.
Graded Practice Challenges
Level 1 - Predict the Output
# Predict each output before running:
print(10 % -3)
print(-10 % 3)
print(2 ** 3 ** 1)
print(not 0 or not "" and not [])
print(0 or False or None or "" or "found")
print(5 and "yes" and 0 and "no")
Show Answer
10 % -3 = -2 (sign follows divisor: -3; 10 = (-4)*(-3) + (-2))
-10 % 3 = 2 (sign follows divisor: 3; -10 = (-4)*3 + 2)
2**3**1 = 8 (right-assoc: 2**(3**1) = 2**3 = 8)
not 0 or not "" and not []
→ True or (True and True) [and binds tighter than or]
→ True or True
→ True
0 or False or None or "" or "found" → "found" (first truthy)
5 and "yes" and 0 and "no" → 0 (first falsy)
Key concepts: modulo sign rule, ** right-associativity, and precedence over or, or/and return values.
Level 2 - Debug the Bug
A team member wrote this utility function. It has two operator-related bugs. Find and fix both:
def safe_divide(a, b, default=0):
# Bug 1: intended to return default if b is 0 or if result is tiny
result = b != 0 and a / b or default
# Bug 2: intended to check that result is in range (0, 100) exclusive
if 0 < result < 100 == True:
return result
return default
Show Answer
Bug 1: The or default pattern fails when a / b evaluates to 0.0 (a valid, meaningful result). Because 0.0 is falsy, 0.0 or default returns default instead. Fix:
result = (a / b) if b != 0 else default
Bug 2: The chain 0 < result < 100 == True does not mean what is intended. Python parses this as (0 < result) and (result < 100) and (100 == True). Since 100 == True is False (True equals 1, not 100), this condition is always False. Fix:
if 0 < result < 100:
return result
The corrected function:
def safe_divide(a, b, default=0):
result = (a / b) if b != 0 else default
if 0 < result < 100:
return result
return default
Level 3 - Design
Design a Python class Measurement that represents a physical measurement with a value and a unit (e.g., Measurement(5.0, "kg")). Implement operator overloading so that:
- Two
Measurementobjects with the same unit can be added and subtracted. - A
Measurementcan be multiplied by a scalar (int or float) on either side. - Two
Measurementobjects can be compared for equality and ordering (same unit required). - Adding measurements with different units raises a
ValueErrorwith a descriptive message. - Demonstrate the walrus operator somewhere in a function that processes a list of measurements.
Show Answer
class Measurement:
def __init__(self, value: float, unit: str):
self.value = value
self.unit = unit
def __repr__(self):
return f"Measurement({self.value}, '{self.unit}')"
def _check_unit(self, other):
if not isinstance(other, Measurement):
return NotImplemented
if self.unit != other.unit:
raise ValueError(
f"Cannot combine measurements: '{self.unit}' and '{other.unit}'"
)
def __add__(self, other):
if (check := self._check_unit(other)) is NotImplemented:
return NotImplemented
return Measurement(self.value + other.value, self.unit)
def __sub__(self, other):
if (check := self._check_unit(other)) is NotImplemented:
return NotImplemented
return Measurement(self.value - other.value, self.unit)
def __mul__(self, scalar):
if not isinstance(scalar, (int, float)):
return NotImplemented
return Measurement(self.value * scalar, self.unit)
def __rmul__(self, scalar):
return self.__mul__(scalar)
def __eq__(self, other):
if (check := self._check_unit(other)) is NotImplemented:
return NotImplemented
return self.value == other.value
def __lt__(self, other):
if (check := self._check_unit(other)) is NotImplemented:
return NotImplemented
return self.value < other.value
def find_heavy(measurements: list, threshold: float):
"""Return first measurement exceeding threshold, using walrus operator."""
return next(
(m for m in measurements if (total := m.value) > threshold),
None
)
# Demo
m1 = Measurement(5.0, "kg")
m2 = Measurement(3.0, "kg")
m3 = Measurement(2.0, "m")
print(m1 + m2) # Measurement(8.0, 'kg')
print(m1 - m2) # Measurement(2.0, 'kg')
print(m1 * 2) # Measurement(10.0, 'kg')
print(3.5 * m2) # Measurement(10.5, 'kg')
print(m1 > m2) # True
print(m1 == m1) # True
try:
print(m1 + m3)
except ValueError as e:
print(e) # Cannot combine measurements: 'kg' and 'm'
items = [Measurement(1.0, "kg"), Measurement(6.0, "kg"), Measurement(4.0, "kg")]
print(find_heavy(items, 5.0)) # Measurement(6.0, 'kg')
Quick Reference Cheatsheet
| Operator | Dunder | Notes |
|---|---|---|
a + b | __add__ / __radd__ | Strings: concatenate; Lists: join new list |
a - b | __sub__ | Numeric only by default |
a * b | __mul__ / __rmul__ | Str/list * int = repetition |
a / b | __truediv__ | Always returns float in Python 3 |
a // b | __floordiv__ | Floors toward −∞, not toward 0 |
a % b | __mod__ | Sign follows divisor (right operand) |
a ** b | __pow__ | Right-associative: 2**3**2 = 512 |
a == b | __eq__ | Value equality; safe across types |
a is b | (identity) | Only for None, True, False |
1 < x < 10 | chains | x evaluated once; all pairs checked |
a and b | (short-circuit) | Returns first falsy or last value |
a or b | (short-circuit) | Returns first truthy or last value |
not a | __bool__ | Always returns True or False |
a += b | __iadd__ | Mutates if mutable; rebinds if immutable |
x := expr | (walrus) | Assign + test in one expression |
-2 ** 2 | (precedence) | -(2**2) = -4, not (-2)**2 = 4 |
Key Takeaways
- Every arithmetic operator is a dunder method call (
+→__add__). Custom classes can implement any operator by defining the corresponding dunder. /always returns float in Python 3.//floors toward negative infinity --7 // 2 == -4, not-3.%(modulo) in Python always has the sign of the divisor, ensuringa == (a//b)*b + (a%b)holds for all integers.**is right-associative:2**3**2 == 512, not64.andandorreturn one of their operands, not a boolean. Short-circuit evaluation is a design tool, not just an optimization.- Augmented assignment (
+=) calls__iadd__for mutable types, mutating in place. This means all references to the same object see the change. - The walrus operator (
:=) allows assignment inside expressions, eliminating double evaluation in loops and comprehensions. - Bitwise operators have lower precedence than comparisons in Python - always parenthesize when mixing them.
- Return
NotImplemented(not raise) from dunder methods when the type is not supported, to allow Python to try the reflected operation.
