The same three steps, three ways of writing them. Only one of them reads in the order things happen.
# Nested calls: read inside out, so the first step is written last
exclaim(lower(strip(' HELLO ')))
# compose, the mathematical convention: also right to left
compose(exclaim, lower, strip)(' HELLO ')
# pipeline: left to right, in the order they run
pipeline(strip, lower, exclaim)(' HELLO ') # -> 'hello!'
# The whole implementation
value = argument
for step in functions:
value = step(value)
- compose and pipeline differ only in direction
f∘g means 'apply g, then f', because that is how the notation composes on paper — so compose reads right to left. A pipeline is the same machinery with the loop the other way round, and it reads like a shell pipe or a method chain. Neither is more correct; what matters is that the name tells the reader which one they are looking at.
- The loop variable is the value, not the function
One name holding the running value, reassigned by each step. That is the entire pipeline — no recursion, no functools.reduce required, and the shape makes it obvious where you would add logging, timing or a guard between steps.
- No steps has to mean the identity, not an error
An empty pipeline is what you get from pipeline(*[]) when a list of steps happens to be empty — a configuration with nothing enabled, a filter chain with no filters. Returning the argument unchanged makes that case work without the caller special-casing it, and it falls out of the loop for free: zero iterations, value untouched.
pipeline returns a function. Inside that function, keep one variable holding the value so far and let each step replace it.
def run(value): loop over functions, value = step(value), return value. Then return run. With no steps the loop does not run and the original value comes straight back, which is the identity case handled for free.
*functions already gives you a tuple — a snapshot, not a reference to whatever list the caller unpacked — so the build-time capture is free. Do not convert it to an iterator anywhere, or the second call finds it exhausted.
def pipeline(*functions): def run(value): for step in ____: value = ____(value) return ____ return ____
def pipeline(*functions):
def run(value):
# No steps means no iterations, so the argument comes back
# unchanged — the identity case, with no special handling.
for step in functions:
value = step(value)
return value
return run
Python builds a new tuple from whatever was passed, so a caller doing pipeline(*steps) and then editing steps cannot reach inside the pipeline. Had the signature taken a single list instead, the pipeline would hold the caller’s object and change meaning underneath them — the same class of bug as the discount buttons capturing an index into a live list.
Each step’s output is the next step’s input and nothing else needs keeping, so one name is enough. It also means the pipeline uses constant memory however many steps there are — and that inserting a print, a timer or an assertion between steps is a one-line change inside a loop you can see.
functools.reduce(lambda value, step: step(value), functions, value) is the same function in one expression, and most readers will take longer to be sure of it than the loop takes to read. Reduce earns its place when the operation is a familiar one — summing, merging — and loses to a loop when the reader has to work out what is being accumulated.
Two or three steps used once are clearer written out as three lines with names — a pipeline is worth building when the steps are chosen at runtime, reused, or configured from data. It also assumes every step takes and returns exactly one value, so anything needing extra arguments has to be wrapped in functools.partial first, and a step that fails halfway leaves you with no idea which one it was unless you build that in. For real data work, pandas' .pipe and scikit-learn’s Pipeline already do this with the error reporting, naming and introspection attached.
- Give the returned function a __name__ listing its steps, so a traceback says which pipeline blew up.
- Add a debug flag that prints the value between steps, and notice how little the loop has to change.
- Build pipeline out of compose, or compose out of pipeline, and confirm the two agree once you reverse the arguments.