Coding by Hand
Python home

Functional Programming

A redstone repeater takes a signal in one side and pushes a signal out the other side. Same input, same output, every time. It does not remember the last signal. It does not have a mood. It does not change anything in the world except the wire on the far side. A pure function is a redstone repeater written in Python: hand it the same arguments and it hands you back the same answer, and it does not poke at any other part of your program while it works. The whole style called functional programming is built on stacking pure functions together until they do something big.

The idea is older than computers themselves. In 1932 a mathematician at Princeton named Alonzo Church wrote down a tiny system called the lambda calculus. It had three rules: you can name a function, you can apply a function to a value, and that is the whole language. Church was trying to figure out what a "computation" even was, on paper, with no machine. His student Alan Turing built the other half of the answer with the Turing machine. The two systems turned out to be equivalent. In 1958 John McCarthy at MIT took Church's lambda calculus and turned it into a real programming language called LISP, which let a programmer pass functions around as values, the way you pass a number or a string. Decades later the Haskell language pushed the idea to its limit by banning side effects entirely. In 2004 two engineers at Google, Jeffrey Dean and Sanjay Ghemawat, published a paper called MapReduce showing that if you wrote your data processing as a map step followed by a reduce step, Google could spread the work across 10,000 machines and the answer would still come out the same. The paper started the entire era of big-data computing. Pure functions were the reason it worked.

A redstone pipeline: raw signal in, pure functions in series, final signal out.
A redstone pipeline: raw signal in, pure functions in series, final signal out.

A pure function has two rules. First, given the same inputs, it returns the same output, every single call. Second, it does not modify anything outside itself — no global variables, no input lists changed in place, no files written. The opposite is an impure function: a method that mutates state. Most of what you wrote in the OOP lesson was impure. Deck.shuffle() reordered self.cards. Deck.deal(n) shortened the deck. Those are useful, but if a bug shows up after a deal, you have to ask "who has touched this deck since I made it?" A pure function never raises that question.

Open the cards.py file from the last lesson. The Card and Deck classes stay exactly as they were. Add a new file in the same folder called score.py, and write a pure scoring function that takes a list of 5 cards and returns a number for how strong the hand is. The scoring is the simplest version of poker scoring: count how many of each rank appear, and reward pairs, three of a kind, and four of a kind. You will swap in a real poker scorer in the project lesson — the shape of the function is what matters here.

from cards import Card
 
 
def rank_counts(cards):
    counts = {}
    for c in cards:
        counts[c.rank] = counts.get(c.rank, 0) + 1
    return counts
 
 
def score_hand(cards):
    counts = rank_counts(cards)
    multiples = sorted(counts.values(), reverse=True)
    if multiples[0] == 4:
        return 700
    if multiples[0] == 3 and multiples[1] == 2:
        return 600
    if multiples[0] == 3:
        return 300
    if multiples[0] == 2 and multiples[1] == 2:
        return 200
    if multiples[0] == 2:
        return 100
    return 0
 
 
hand = [Card("A", "s"), Card("A", "h"), Card("K", "c"), Card("K", "d"), Card("9", "s")]
print(score_hand(hand))

Run it with python score.py. Output:

200

Two pair scores 200. Call score_hand(hand) 1000 more times and the answer stays 200. The function does not change hand. It does not write to a file. It does not touch a global. You can move it to another project, drop it into a test, or run it on a different planet. Same input, same output.

The reason that purity matters is what comes next: the moment your function is pure, you can hand it to other functions that run it for you. The first one is map. map(f, items) takes a function f and a list of items, and returns a new sequence where each item has been replaced by f(item). The new sequence is computed lazily — Python does not actually run f on each item until you ask for the result, which is why we wrap it in list(...) to force the work.

import random
from cards import Deck
from score import score_hand
 
 
def deal_hand(deck):
    return deck.deal(5)
 
 
hands = []
for _ in range(5):
    deck = Deck()
    deck.shuffle()
    hands.append(deal_hand(deck))
 
scores = list(map(score_hand, hands))
print(scores)

Output (yours will vary because the deck is shuffled with a random seed):

[100, 0, 100, 200, 0]

map walked the list of 5 hands and ran score_hand on each one, with no for-loop in your code. Same answer as if you wrote a loop, but the intent is clearer at a glance: "score every hand."

The trick that makes this fun to learn is to wrap map in a tracer that prints each input and its output as the work happens. You write a small function called traced_score that calls score_hand and also prints the trace, then hand traced_score to map instead. The print statements show the function being called once per hand, in order.

def traced_score(cards):
    result = score_hand(cards)
    print(f"hand {cards} -> score {result}")
    return result
 
 
traced = list(map(traced_score, hands))

Output:

hand [As, Ah, Kc, Kd, 9s] -> score 200
hand [Qh, Js, 7d, 4c, 2s] -> score 0
hand [10d, 10c, 5h, 3s, 8d] -> score 100
hand [Kc, Kh, 6c, 6d, 2h] -> score 200
hand [9s, 4d, Jc, 7h, Ad] -> score 0

The trace makes the laziness real. Each line shows up in order: map pulled one hand, scored it, printed, and moved to the next. Nothing about the hands changed during scoring. The original hands list is the same after the trace as it was before. That is the contract of a pure function paying off.

A small question for the reader: in the trace above, the hand [K♣, K♥, 6♣, 6♦, 2♥] scored 200. Why not 100?

Two kings and two sixes is two separate pairs. The scoring function counted the rank appearances, sorted them, saw [2, 2, 1], and matched the two-pair rule before it ever checked the one-pair rule. The order of the if branches matters: stronger hands have to be checked first, which is exactly how real poker hand evaluation works.

A decorator drawn as gift wrap around an inner function, adding behavior without changing it.
A decorator drawn as gift wrap around an inner function, adding behavior without changing it.

Functions in Python are first-class values. That means a function can be stored in a variable, passed to another function as an argument, and returned from a function as a result. The decorator is the cleanest use of this idea. A decorator is a function that takes a function and returns a new function that wraps the original with extra behavior. The most useful decorator to write yourself is a timer that measures how long any function takes to run.

import time
 
 
def timer(fn):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} took {elapsed * 1000:.3f} ms")
        return result
 
    return wrapper
 
 
@timer
def score_many(hands):
    return [score_hand(h) for h in hands]
 
 
big_hands = []
for _ in range(10000):
    deck = Deck()
    deck.shuffle()
    big_hands.append(deck.deal(5))
 
scores = score_many(big_hands)
print(f"first 5 scores: {scores[:5]}")

Output:

score_many took 18.412 ms
first 5 scores: [100, 0, 200, 0, 100]

The @timer line above score_many is sugar for score_many = timer(score_many). Python ran the original function definition, handed it to timer, and bound the wrapper back to the same name. From the caller's view, score_many still takes a list of hands and returns a list of scores. From the inside, every call now prints how long it took. You did not have to touch the body of score_many. That is the power of a pure function plus first-class functions: you can wrap behavior on top without rewriting the thing you are wrapping.

Pure functions are clean. They do not break when you call them from 10 threads. They are easy to test because there is no state to set up. They are easy to compose into pipelines with map and friends. They are also a poor fit for things like a poker game that has to track whose turn it is, what the pot size is, and which cards are still in the deck. A program made entirely of pure functions either has no state or hides its state in giant argument lists that get passed around for every call. The next lesson sets the rule that real Python is a mix: classes for the things that own state, pure functions for the work you do with that state.