CS1602Introduction to Computation
Lecture 4Part 1 Foundations of ComputationAI Level 0

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:

  1. initialize — n = 1
  2. the condition — n <= 5
  3. 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 forReach for while
the number of repetitions is knownthe number of repetitions is not known
you are walking through an existing collectionyou are looping until some condition holds
most of the timereading 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 immediately
  • continue — 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:

OperatorNameEffect
&and1 only where both bits are 1
|or1 where either bit is 1
^xor1 where the bits differ
~notflips every bit
<<left shiftshift left, filling with zeros
>>right shiftshift 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-else takes the first true branch; order multi-way branches from strictest to loosest
  • Chained comparisons like 1 < x < 10 work, and the middle value is evaluated once
  • match-case matches structure and binds names, but do not overuse it
  • Prefer for; keep while for when the number of repetitions is unknown
  • break leaves the loop, continue skips a pass; in nested loops break escapes one level only
  • A loop’s else means “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

  1. 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.
  2. Without sum(), use a loop to total every number from 1 to 100 that is divisible by 3 or by 5.
  3. Write count_vowels(s: str) -> int, counting vowels (aeiou, either case) in a string.
  4. Use nested loops to print the lower triangle of a multiplication table, neatly aligned.
  5. 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")
  6. Write apply_all(*funcs, value), passing value through each function in turn and returning the final result. For instance apply_all(abs, int, value=-3.7) should give 3. Work out why value has to be passed by keyword.
  7. Add type annotations to every function you wrote for Lab 3.

The lab problems for this lecture are in Lab 4.