Conditions, Loops and Functions
So far your programs run straight down the page and stop. This lecture adds the two things they have been missing — branching and repetition. With those, Python can express any computable process at all.
By the end of this lecture you should be able to
- Express multi-way branching with if / elif / else, and avoid the indentation traps
- Decide between for and while, and use break and continue correctly
- Say exactly when the else on a loop runs
- Write flexible functions using default, keyword and *args / **kwargs parameters
- Annotate functions with types, and say why it is worth the keystrokes
Contents
From a straight line to a fork
Everything you have written so far runs in a straight line: first statement, second, third, done. Such a program cannot do much — it ignores its input and behaves identically every time.
Two things change that:
- Branching — take a different path depending on the situation
- Repetition — do something many times
That sounds modest. But sequence, branching and repetition together are enough to express any computable process. That is not a figure of speech; it is a theorem (the structured program theorem). As of this lecture you have every control structure a Turing-complete language needs.
Conditions
Boolean expressions
As covered in L2, a comparison yields True or False:
print(3 > 2)
print(3 == 2)
print("abc" != "abd")
print(3 >= 3)
Combine them with and, or and not. Watch the order: not binds tightest, then and, then or.
print(True or False and False) # and first → True or False → True
print((True or False) and False) # parentheses change it → False
Chained comparisons
Python lets you chain comparisons the way mathematics does:
x = 5
print(1 < x < 10) # means 1 < x and x < 10
print(0 <= x <= 100)
This is a genuine advantage over C and Java. There, 1 < x < 10 evaluates 1 < x to True, then compares True against 10 — meaningless, and silently accepted.
if / elif / else
score = 85
if score >= 90:
print("excellent")
elif score >= 80:
print("good")
elif score >= 60:
print("pass")
else:
print("fail")
Three things to notice:
- the colon is not optional
- the body must be indented (four spaces)
- the first true branch runs, and every other branch is skipped
That last point matters. The elif score >= 80 above does not need to say elif 80 <= score < 90 — if execution reaches that line at all, score >= 90 was already false.
A classic: leap years
The rule: divisible by 4 is a leap year, except that divisible by 100 is not, unless it is also divisible by 400.
def is_leap(year):
if year % 400 == 0:
return True
if year % 100 == 0:
return False
return year % 4 == 0
for y in [1900, 2000, 2024, 2025, 2026]:
print(y, is_leap(y))
Notice the shape: start from the most specific condition and return straight out. Compare it against the nested version:
# same logic, nested — it works, but it is tiring to read
def is_leap_nested(year):
if year % 4 == 0:
if year % 100 == 0:
if year % 400 == 0:
return True
else:
return False
else:
return True
else:
return False
print(is_leap_nested(1900), is_leap_nested(2000), is_leap_nested(2024))
It also fits on one line, at some cost to clarity:
def is_leap_oneline(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
print(is_leap_oneline(1900), is_leap_oneline(2000), is_leap_oneline(2024))
The conditional expression
When there are exactly two cases and both are short, one line will do:
score = 75
result = "pass" if score >= 60 else "fail"
print(result)
# equivalent to
if score >= 60:
result = "pass"
else:
result = "fail"
print(result)
The shape is value1 if condition else value2. Do not nest them — by the time you have a if p else (b if q else c), it wanted to be an if-elif-else.
match-case: structural pattern matching
Introduced in Python 3.10, as an alternative to long chains of elif:
def http_message(code):
match code:
case 200:
return "OK"
case 301 | 302: # | means "or"
return "redirect"
case 404:
return "not found"
case 500:
return "server error"
case _: # _ is the wildcard, like else
return "unknown status"
for c in [200, 302, 404, 500, 418]:
print(c, http_message(c))
What it does that if-elif cannot is match on structure, not merely compare values:
def describe(point):
match point:
case (0, 0):
return "the origin"
case (0, y):
return f"on the y axis, y={y}"
case (x, 0):
return f"on the x axis, x={x}"
case (x, y):
return f"an ordinary point ({x}, {y})"
case _:
return "not a 2D point at all"
for p in [(0, 0), (0, 5), (3, 0), (2, 7), "hello"]:
print(p, "→", describe(p))
Note that case (0, y) does two jobs at once: it checks that the first element is 0, and it binds the second element to y. That combination is what separates it from if.
Loops
while: keep going while the condition holds
n = 1
while n <= 5:
print(n, end=" ")
n += 1
print()
A while loop has three moving parts, and dropping any of them breaks it:
- initialize —
n = 1 - the condition —
n <= 5 - update —
n += 1
for: walk through a collection
for ch in "Python":
print(ch, end=" ")
print()
for x in [10, 20, 30]:
print(x, end=" ")
print()
Anything you can take items from one at a time can follow in — strings, lists, tuples, dictionaries, sets. L13 explains the machinery behind that (iterables).
range()
When you want to repeat something n times, use range():
print(list(range(5))) # 0 through 4
print(list(range(2, 8))) # 2 through 7
print(list(range(0, 10, 3))) # step of 3
print(list(range(10, 0, -2))) # counting down
range(a, b) includes the start and stops before the end — the same convention as slicing.
total = 0
for i in range(1, 101):
total += i
print("1 through 100 sums to", total)
for or while?
Reach for for | Reach for while |
|---|---|
| the number of repetitions is known | the number of repetitions is not known |
| you are walking through an existing collection | you are looping until some condition holds |
| most of the time | reading input until it is valid; iterating toward a tolerance |
Prefer for whenever you can. The count is fixed in advance, so there is no update step to forget and no way to loop forever.
# the 3n+1 problem (Collatz): the number of steps is unknown, so while it is
def collatz_steps(n):
steps = 0
while n != 1:
if n % 2 == 0:
n = n // 2
else:
n = 3 * n + 1
steps += 1
return steps
for start in [6, 7, 27]:
print(f"starting from {start}: {collatz_steps(start)} steps to reach 1")
Starting from 27 takes 111 steps — the sequence climbs all the way to 9232 before falling back. Nobody has yet proved that every positive integer eventually reaches 1.
break and continue
break— leave the loop immediatelycontinue— skip the rest of this pass and start the next one
# break: stop at the first hit
for i in range(2, 50):
if 100 % i == 0:
print("the smallest divisor of 100 above 1 is", i)
break
# continue: skip what you do not want
for i in range(1, 11):
if i % 3 == 0:
continue
print(i, end=" ")
print()
for-else and while-else
A Python-specific construct that plenty of people never learn.
The else runs when the loop finishes normally. If a break cut it short, the else is skipped.
def is_prime(n):
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
print([n for n in range(2, 30) if is_prime(n)])
That used early returns. With for-else it can look like this:
def find_divisor(n):
for i in range(2, n):
if n % i == 0:
print(f"{n} is divisible by {i}")
break
else:
print(f"{n} is prime")
find_divisor(15)
find_divisor(17)
Bitwise operators
L2 said everything is bits. These operators work on them directly:
| Operator | Name | Effect |
|---|---|---|
& | and | 1 only where both bits are 1 |
| | or | 1 where either bit is 1 |
^ | xor | 1 where the bits differ |
~ | not | flips every bit |
<< | left shift | shift left, filling with zeros |
>> | right shift | shift right |
a, b = 0b1100, 0b1010 # 12 and 10
print(f"a = {a:04b}")
print(f"b = {b:04b}")
print(f"a & b = {a & b:04b} = {a & b}")
print(f"a | b = {a | b:04b} = {a | b}")
print(f"a ^ b = {a ^ b:04b} = {a ^ b}")
Shifting left by one multiplies by two; shifting right by one divides by two, rounding down:
n = 5
print(n, "<< 1 =", n << 1) # 10
print(n, "<< 3 =", n << 3) # 40
print(n, ">> 1 =", n >> 1) # 2
One practical trick — testing for oddness:
for n in [7, 8, 9, 10]:
print(n, "odd" if n & 1 else "even")
n & 1 picks off the lowest bit, and a lowest bit of 1 means odd. It is equivalent to n % 2 == 1 and marginally faster — though in Python the gap is negligible, so favor readability and write %.
More about functions
L3 covered the basic shape. Here are the things you can do with parameters.
Default values
Give a parameter a default and callers may leave it out:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
print(greet("Alex"))
print(greet("Alex", "Good morning"))
Parameters with defaults must come after those without, or it is a syntax error:
def bad(greeting="Hello", name):
return f"{greeting}, {name}"
Keyword and positional arguments
def describe(name, age, city):
return f"{name}, {age}, from {city}"
print(describe("Alex", 18, "Shanghai")) # by position
print(describe(name="Alex", age=18, city="Shanghai")) # by name
print(describe("Alex", city="Shanghai", age=18)) # mixed
When mixing, positional arguments come first. The reason is mechanical: Python has to settle which arguments are matched by position before the rest can be claimed by name.
*args and **kwargs
For when the number of arguments is not fixed:
def total(*numbers): # * collects the positional arguments into a tuple
print("received:", numbers, type(numbers))
return sum(numbers)
print(total(1, 2, 3))
print(total(1, 2, 3, 4, 5))
print(total())
def config(**options): # ** collects the keyword arguments into a dict
print("received:", options)
for key, value in options.items():
print(f" {key} = {value}")
config(debug=True, level=3, name="test")
Both can appear together, always in the order def f(normal, *args, **kwargs):
def show(first, *rest, **opts):
print("first =", first)
print("rest =", rest)
print("opts =", opts)
show(1, 2, 3, mode="fast", retry=2)
The names args and kwargs are conventional; * and ** are the actual syntax. But stick with those names — everyone uses them, and anything else only puzzles your reader.
Same name, and the earlier one is gone
Python has no function overloading. A later definition simply replaces an earlier one of the same name:
def f(x):
return x * 2
def f(x, y):
return x + y
print(f(3, 4))
def f(x):
return x * 2
def f(x, y):
return x + y
print(f(3)) # the one-argument f no longer exists
This differs from C++ and Java. When you want behavior that varies with the number of arguments, use defaults or *args.
Type hints
What they are
Since Python 3.5 you can annotate parameters and return values:
def c_to_f(c: float) -> float:
return c * 9 / 5 + 32
print(c_to_f(37))
c: float says the parameter should be a float; -> float says the function gives one back.
Python does not enforce any of this. Pass a string and it will happily try, then fail:
def c_to_f(c: float) -> float:
return c * 9 / 5 + 32
print(c_to_f("37"))
So why write them
If nothing enforces them, why the extra typing? Three reasons, in increasing order of importance.
One: they are documentation that cannot go stale. Comments drift away from the code they describe. An annotation sits on the parameter itself.
Two: your editor can act on them. With the Python extension in VS Code, an annotated function gets sharper completion and earlier warnings. A lot of small mistakes light up as you type instead of at runtime.
Three: they are the specification you hand to an AI. L15 develops this properly, but start building the instinct now. When you want an AI to write a function, the effective move is not a paragraph of description — it is writing the signature and the types first and letting it fill in the body.
def merge_sorted(a: list[int], b: list[int]) -> list[int]:
"""Merge two already-sorted integer lists into one sorted list."""
...
Those three lines are more precise than a hundred words of prose. What goes in, what comes out, what it does — all of it is there.
The common forms
def f1(x: int) -> str:
return str(x)
def f2(items: list[int]) -> int:
return sum(items)
def f3(name: str, age: int = 18) -> str: # a default and a type together
return f"{name} is {age}"
def f4(x: float) -> None: # nothing returned, so None
print(x)
print(f1(42), f2([1, 2, 3]), f3("Ada"))
f4(3.14)
Indentation, and the mistakes it invites
Python marks blocks by indentation, and that economy has a price: get the indentation wrong and the program means something else, usually without any error at all.
def check(scores: list[int]) -> None:
for s in scores:
if s >= 60:
print(s, "passed")
print("check complete") # outside the for, so printed once
check([50, 70, 90])
def check(scores: list[int]) -> None:
for s in scores:
if s >= 60:
print(s, "passed")
print("check complete") # one level deeper, so printed every pass
check([50, 70, 90])
Four spaces apart, completely different behavior, and neither one raises an error.
Summary
- Sequence plus branching plus repetition is enough to express any computable process
if-elif-elsetakes the first true branch; order multi-way branches from strictest to loosest- Chained comparisons like
1 < x < 10work, and the middle value is evaluated once match-casematches structure and binds names, but do not overuse it- Prefer
for; keepwhilefor when the number of repetitions is unknown breakleaves the loop,continueskips a pass; in nested loopsbreakescapes one level only- A loop’s
elsemeans “never broken out of” — recognize it, write it sparingly - Parenthesize bitwise operations whenever they share a line with anything else
- Defaults go last, and must not be mutable objects
- Python has no overloading; a second definition replaces the first
- Type hints are unenforced but worth writing: documentation, editor support, and a specification for an AI
- Four spaces, and never mix in tabs
Exercises
- Write
bmi_category(bmi: float) -> str, classifying as underweight below 18.5, normal below 24, overweight below 28, and obese otherwise. Mind the branch order. - Without
sum(), use a loop to total every number from 1 to 100 that is divisible by 3 or by 5. - Write
count_vowels(s: str) -> int, counting vowels (aeiou, either case) in a string. - Use nested loops to print the lower triangle of a multiplication table, neatly aligned.
- These two produce different output. Explain why:
for i in range(5): if i == 3: break else: print("ran to completion") for i in range(5): if i == 3: continue else: print("ran to completion") - Write
apply_all(*funcs, value), passingvaluethrough each function in turn and returning the final result. For instanceapply_all(abs, int, value=-3.7)should give3. Work out whyvaluehas to be passed by keyword. - Add type annotations to every function you wrote for Lab 3.
The lab problems for this lecture are in Lab 4.