Functions and partial application¶
fp.functions provides eager application with identity and pipe, function
composition with flow and compose, argument flipping with flip, and partial
application with partial. The reusable callables implement the fixed-arity
protocols in fp.callables. Pipelines and compositions share the
same stage planner and traversal.
Semantics¶
identity(x) = x
pipe(x) = x
pipe(x, f0, f1, ..., fn) = fn(...f1(f0(x))...)
flow(f0, f1, ..., fn)(args) = fn(...f1(f0(args))...)
compose(fn, ..., f1, f0)(args) = fn(...f1(f0(args))...)
flip(f)(a, b, rest) = f(b, a, rest)
partial(f, a, b)(rest) = f(copy(a), copy(b), rest)
These equations describe logical calls. args is zero, one or two arguments for
flow and compose (the arity of the first-applied stage), the two swapped
arguments plus any remaining ones for flip, and a borrowed argument pack for
partial.
The rules shared by every form:
- Construction does not call the target. Creating a composition, flipped
callable or partial stores what a later call needs.
partialcopies its bound arguments at construction; the others only move their components in. - Each reached stage runs once, in order. A raised error stops the chain: later stages do not run, and the payload keeps its exact type.
- Values move or borrow as the receiving stage declares. There are no intermediate collections, dynamic dispatch or extra copies.
- The first stage sees the call's arguments; later stages see one value. A stage
that returns a tuple, a
Resultor a pointer passes it whole. Nothing is unpacked or short-circuited implicitly. - Adjacent stages must agree at compile time. An input type that does not equal the previous stage's output is a compile error naming the stage.
Function composition and monadic bind differ in how they handle a Result.
pipe, flow and compose pass the whole value to the next stage, including
an Err. Result.flat_map calls the next computation only for Ok; an Err
passes through unchanged. Generic code uses flat_map[ResultFamily[E]], and
native Optional.and_then keeps its standard meaning. Separately,
std.iter.chain concatenates sequences.
Pipelines¶
pipe(value, stages...) applies stages left to right. Every stage the planner
sees is a library value whose own contract fixes its types. Two to eight plain
functions reach it through fixed-arity overloads that spell each stage as a thin
signature, infer its error (Never for a non-raising function) and store it as a
_ThinFunction; a variadic pack keeps a plain function's type but not its
signature, which is why these overloads are per stage count. Closures become
stages through as_unary, which moves them into a NativeUnary. Partials and
compositions are stages as they are.
A pipeline's result type is inferred from the stages. functions._stages builds
one compile-time plan (_Plan) from the stages' types: it tabulates each stage's
result type as a TypeList with a known length and reduces it to the final
type. It uses a known-length tabulate because Mojo 1.1's general TypeList
slice leaves a deferred bound that hides concrete result types from callers.
The plain-function overloads of pipe, flow and compose are generated by
scripts/generate_plain_overloads.py, which a tooling test keeps in step with
the source. Each later stage names its own input type, so a call whose functions
do not line up still selects its overload and reaches the per-stage check, which
names the stage and both types instead of a generic mismatch.
Errors combine by identity. Stages that do not raise, including those declared
raises Never, add no error type. If every stage is non-raising, the pipeline
is too. Otherwise every inhabited error type must be identical, and that type
is the pipeline's error.
Different error types must be normalized explicitly. The explicit forms,
pipe[E=Failure](...) and pipe[Results, E](...), state the error or every
result type directly.
Inside an enclosing generic function that forwards library-value stages, store
pipe[E=E](value^, *stages), assert
type_of(result) == R at compile time and return rebind_var[R](result^). The
assertion is required: on Mojo 1.1 a rebind alone accepts some distinct structs
with identical layout, so it cannot serve as the nominal type check.
Chained pipelines¶
piped(value) returns a Piped[T] holding the value, and each then(f) applies
one stage and returns Piped[R]. A chain is therefore a sequence of ordinary
calls, each with one function-typed parameter, so Mojo infers every stage's
types without a planner and without a stage limit. Non-raising stages and library
Unary values go through the single-stage pipe(value, f) overloads; a raising
stage is called directly, because forwarded through pipe its error would be the
callback's F.E, which Mojo 1.1 cannot prove equal to the stated E. There is
no common-error check across a chain: each then raises its stage's own error.
Composition¶
flow(f^, g^, ...) and compose(..., g^, f^) return one concrete type,
Composition, parameterized by its stages, their execution order and the arity
of the first-applied stage. It stores a native tuple of Optional components.
Optional lets a consuming call move each component out exactly once and gives
native cleanup on every error path, without partial tuple moves. The same
_Plan and stage traversal serve pipe and stored compositions; there is no
second loop and no fixed stage count. Plain functions arrive through the same
kind of fixed-arity overloads as in pipe (two to eight functions, the
first-applied one taking zero, one or two arguments) and are stored as
_ThinFunction components.
The first-applied stage may be a thunk, a unary or a binary; the composition
takes its arguments and implements Thunk, Unary or Binary accordingly.
Every later stage is unary. A composition has exactly the receiver modes its
components allow:
| Call | Components allowed |
|---|---|
chain(...), chain.call(...) |
Shared components only |
chain.call_mut(...) |
Shared or mutable components |
chain^.call_once(...) |
Any components; each runs at most once |
State persists between calls. A composition is copyable only when every
component is. flow[T]() gives Identity[T]; one component returns the original
callable unchanged, keeping every capability it had.
Mojo 1.1 cannot select a conformance from a where clause that inspects a
function type directly, so the arity is an explicit parameter of Composition,
computed once by flow and compose.
Flip¶
flip has two forms. For a Binary value it returns Flipped, which calls the
target with its two arguments swapped and has the target's receiver modes. For a
plain function with two or more parameters it returns FlippedFunction, which
stores the function converted to a signature with two owned leading parameters
and a read pack for the rest, the same technique partial uses.
Partial application¶
partial(f, a0, ..., ak) binds a positional prefix of a plain function (no
captures) and returns a Partial:
- Target. A plain function, raising or not.
Eis inferred and isNeverfor a non-raising target, so the partial is non-raising too. - Bound arguments. Zero to eight, each copied once into the partial. Every
call passes the target a fresh copy, so a bound parameter may be read or
owned, and the stored values are never consumed or changed. A value that is
not
Copyableis rejected; it never silently becomes a borrow. - Remaining arguments. Borrowed, never copied or moved. The remaining parameters must therefore be read parameters.
- Results. A result cannot refer to a per-call copy or to the stored values.
- Integration.
PartialisCopyable. With zero, one or two remaining arguments it implementsThunk,UnaryorBinary, so the algebra, effects and owned-argument iteration functions accept it directly.
Copying protects only as far as the bound values' copy semantics: copying a shared handle still shares its referent.
Representation¶
One struct holds the target and a native Tuple of bound values. At the
construction site the target is converted to the signature for its bound
count, for example
def(var a0: A0, var a1: A1, *args: *Rs) raises E thin -> R
with every type inferred from the function. A call copies each bound value and
forwards the remaining arguments as one native pack: one native call, with no
binder, dictionary or callable box. At O3, partial(scale, 3)(x) compiles to the
same instructions as scale(3, x). A client that uses partial builds cold in
about three seconds.
Why the limits exist¶
The limits follow directly from Mojo 1.1.0 (8189361e), checked against the
compiler and the mojo/v1.1.0 standard library source:
| Mojo 1.1 behavior | Consequence |
|---|---|
A stored value can precede a forwarded pack only through its own declared parameter: "concatenating unpacked positional arguments is not supported". A Tuple cannot be unpacked into a call, a signature has one variadic pack, and VariadicPack has no public constructor. |
Each bound count needs its own spelled signature and overload, so the count is capped at eight. |
| A stored function cannot be re-typed to take more leading parameters. | Nested partials cannot be flattened, so they are rejected. |
Converting a function to a signature that passes a read parameter in an owned pack hands the target a dangling value when the type is not ImplicitlyCopyable (a List arrives with the right length and wrong contents). A read pack is correct for every type. |
Remaining arguments travel as a read pack, so remaining parameters must be read. |
| Keyword packs are homogeneous, cannot be forwarded into named parameters, and function parameter names are not reflected. | No keyword binding. |
| A capturing closure does not convert to a pack-typed signature, and a generic struct cannot conform to an inferable function-type trait. | Targets are plain functions; Partial joins library functions through Thunk/Unary/Binary. |
A pack-typed function and a fixed-arity function have different calling conventions, so rebind between them is rejected. Rebinding between two pack-typed signatures that agree at instantiation works. |
Partial.call reuses the pack-typed function by rebinding to the one- or two-element pack type. |
A native closure expresses everything partial does not: keyword binding,
reordering, skipping or binding more than eight arguments, owned or mut
remaining parameters, capturing targets and building on another partial. Its
capture list copies bound values, and it keeps the target's defaults and exact
error type:
var base = 2
def basetwo(text: String) raises {base} -> Int:
return atol(text, base=base)
The library could remove these restrictions if Mojo gains the corresponding native support. Pack concatenation or a public pack constructor would remove the argument cap and allow nested partials to be flattened. Correct owned-pack conversion would allow owned remaining parameters, and heterogeneous keyword packs forwarded into named parameters would allow keyword binding.