Problem. A dispatcher that grows a new branch for every interchangeable behaviour keeps selection and implementation in the same function.[2] Baseline complexity. For m requests and three operations, the exact branch implementation is O(m) time and O(1) extra space, with an average of two branch tests per request in this workload. Solution. Move each operation behind the same callable shape and select it from a strategy registry, so the caller can change behaviour without editing the dispatcher.[1][2] Measured result. On 180,000 deterministic integer requests, the branch dispatcher had a median of 57,697.108 microseconds and the registry version 86,667.735 microseconds. The registry was 0.666 times the branch time on this run, so the refactor improved separation of concerns, not speed.
Reproduce the result
Complete code
from __future__ import annotationsimport platformimport statisticsimport sysimport timeitfrom collections.abc import CallableStrategy = Callable[[int, int], int]# The baseline keeps every algorithm inside one growing dispatcher.def apply_branching(operation: str, left: int, right: int) -> int: if operation == "add": return left + right if operation == "subtract": return left - right if operation == "multiply": return left * right raise ValueError(f"unknown operation: {operation}")# Each behaviour has the same callable shape and can be selected at runtime.def add(left: int, right: int) -> int: return left + rightdef subtract(left: int, right: int) -> int: return left - rightdef multiply(left: int, right: int) -> int: return left * rightSTRATEGIES: dict[str, Strategy] = { "add": add, "subtract": subtract, "multiply": multiply,}def apply_strategy(operation: str, left: int, right: int) -> int: try: strategy = STRATEGIES[operation] except KeyError as error: raise ValueError(f"unknown operation: {operation}") from error return strategy(left, right)REQUESTS = [ (operation, index, index + 2) for index in range(60_000) for operation in ("add", "subtract", "multiply")]def run_branching() -> int: total = 0 for operation, left, right in REQUESTS: total += apply_branching(operation, left, right) return totaldef run_strategy() -> int: total = 0 for operation, left, right in REQUESTS: total += apply_strategy(operation, left, right) return total# Check behaviour before measuring speed.assert apply_branching("add", 4, 3) == 7assert apply_strategy("add", 4, 3) == 7assert apply_branching("subtract", 4, 3) == 1assert apply_strategy("subtract", 4, 3) == 1assert apply_branching("multiply", 4, 3) == 12assert apply_strategy("multiply", 4, 3) == 12assert run_branching() == run_strategy()branch_checks = sum( 1 if operation == "add" else 2 if operation == "subtract" else 3 for operation, _, _ in REQUESTS)strategy_lookups = len(REQUESTS)print("correctness=passed")print(f"requests={len(REQUESTS)}")print(f"branch_checks={branch_checks}")print(f"strategy_lookups={strategy_lookups}")print(f"result={run_branching()}")measurements: dict[str, list[float]] = {}for name, function in (("branching", run_branching), ("strategy", run_strategy)): function() # one warm-up call outside the recorded samples measurements[name] = timeit.repeat(function, repeat=7, number=5) print( f"{name}_samples_us=" + ",".join(f"{sample / 5 * 1_000_000:.3f}" for sample in measurements[name]) + f" median_us={statistics.median(measurements[name]) / 5 * 1_000_000:.3f}" )branch_median = statistics.median(measurements["branching"]) / 5strategy_median = statistics.median(measurements["strategy"]) / 5print(f"median_ratio_branching_over_strategy={branch_median / strategy_median:.3f}x")print(f"python={sys.version.split()[0]}")print(f"platform={platform.platform()}")print("timing_policy=7 repeats, number=5, one warm-up call, timeit.default_timer")
Output
correctness=passedrequests=180000branch_checks=360000strategy_lookups=180000result=72005399890000branching_samples_us=58498.727,57447.913,56895.384,59025.768,57900.695,57697.108,57515.272 median_us=57697.108strategy_samples_us=86886.837,88093.916,86667.735,87383.792,85924.737,85243.846,85459.033 median_us=86667.735median_ratio_branching_over_strategy=0.666xpython=3.13.5platform=Linux-6.12.47+rpt-rpi-v8-aarch64-with-glibc2.41timing_policy=7 repeats, number=5, one warm-up call, timeit.default_timer
Environment
The run used Python 3.13.5 on Linux 6.12.47+rpt-rpi-v8-aarch64-with-glibc2.41. The programme uses only the Python standard library: statistics, timeit, platform, sys and collections.abc. The command was python3 strategy_benchmark.py.
Methodology
The input contains 180,000 requests, repeating add, subtract and multiply across 60,000 integer positions. Correctness checks run before timing. Each implementation receives one warm-up call, then timeit.repeat records seven samples, each containing five complete passes over the requests. The reported median is per pass, and the ratio is branch time divided by registry time. The script also derives 360,000 branch tests and 180,000 registry lookups from the fixed request sequence. Memory was not measured. The numbers describe this interpreter, processor, input mix and implementation; they are not a universal ranking.[4]
The problem
The example is deliberately small: a calculator has three operations, and a caller chooses the operation by name. The baseline puts all three algorithms and the selection rule in one dispatcher. Adding another operation means editing that function, then testing the old paths again.
That arrangement is perfectly serviceable while the list is short. It becomes awkward when the algorithms have different dependencies, configuration or release schedules. A routing service, pricing engine or file exporter can end up with a large conditional block whose unrelated cases change together.
The Strategy pattern names a different boundary. It lets a programme choose an algorithm at runtime and keep the algorithm independent from the code that uses it.[1] Refactoring.Guru describes the pattern as a family of algorithms placed behind interchangeable objects, with a context delegating the work to the selected strategy.[2]
Baseline complexity
For the exact benchmark, run_branching visits every request once, so its loop is O(m). The arithmetic inside each branch is constant time for the tested small integers. The function holds no collection that grows with m, which gives it O(1) extra space.
The branch count is a separate fact from the asymptotic result. With the order add, subtract, multiply, the dispatcher tests one condition for the first operation, two for the second and three for the third. The fixed sequence therefore produces 360,000 branch tests across 180,000 requests. A different ordering or frequency would produce a different count.
The registry version is also O(m) time. Its dictionary has three entries, so its extra storage is O(k), where k is the number of registered strategies. In this run, k = 3. The pattern does not change the problem’s asymptotic class. It changes where the code for each behaviour lives.
The solution
Each function accepts two integers and returns an integer. That shared shape is the strategy interface in this Python example. The registry maps a name to a callable, and apply_strategy performs the lookup before calling the selected function. Python can represent strategies with first-class functions, classes or instances, which is why a class hierarchy is not mandatory here.[1]
The caller still decides which operation it wants. The dispatcher no longer needs to know how addition, subtraction or multiplication works. A fourth operation can be added by writing one function and registering it. Existing operation functions stay untouched, and tests can exercise each strategy without constructing the whole calculator.
A type alias documents the callable contract for static analysis, but Python does not enforce annotations at runtime.[5] If a larger codebase needs an explicit class-based interface, the standard abc module provides the machinery for defining abstract base classes.[3] That is a design choice, not a requirement of the pattern.
What the measurement shows
Both implementations returned the same result, and the script checked that before collecting timings. The branch dispatcher used 360,000 conditional tests in the fixed workload. The registry performed 180,000 dictionary lookups. Fewer selection steps did not mean a shorter run here.
The median branch time was 57,697.108 microseconds. The median registry time was 86,667.735 microseconds, making the branch version 1.502 times as fast when the ratio is expressed in the other direction. The registry pays for a dictionary lookup, an extra function call and the valid-path try block on every request. Those costs matter when the algorithms themselves are tiny.
That result is useful precisely because it is unglamorous. Strategy is mainly a structural refactor. It can reduce the cost of changing one behaviour without promising a faster hot loop. Python’s timeit module is designed for small execution-time comparisons and warns against common measurement traps, so it is suitable for this narrow dispatch test.[4]
Where it stops helping
If the variants are stable, short and local to one function, an if statement may be easier to read. A registry also moves validation elsewhere: an unknown name now fails at lookup, and a badly shaped callable fails when called. Those are manageable costs, but they are still costs.
The benchmark does not measure code review time, defect rates, import costs or the effect of large real algorithms. It uses three integer operations with a fixed, evenly repeated request mix. A workload with expensive strategies would make dispatch overhead a smaller fraction of total time, while a workload with one overwhelmingly common branch could favour a different conditional order.
The registry grows with the number of available strategies. Stateful strategies may need objects rather than plain functions, and shared mutable state can make testing harder. The pattern is a good fit when behaviour changes independently or selection happens at runtime. It is needless ceremony when there is only one behaviour and no credible second one.
The practical test is simple: if adding a variant forces edits inside a function that should only coordinate the work, extract the variants behind one small interface. If the function is still short and the choices rarely change, leave the branches alone. The benchmark says nothing about that decision; the shape of the code does.
Sources
[1] Strategy pattern – Wikipedia
[2] Strategy – Refactoring.Guru
[3] abc — Abstract Base Classes — Python documentation
[4] timeit — Measure execution time of small code snippets — Python documentation
Leave a comment