What are pure functions and what is the difference between Consumer and Acceptor?

← Getting Started · Ref: Q54

Pure functions in EK9 are declared with 'as pure' and the compiler ENFORCES purity — no side effects, no mutable external state, no calling impure functions. This is not a hint or annotation — it is a compile-time guarantee.

DECLARING PURE FUNCTIONS

  factorial() as pure
    -> n as Integer
    <- result as Integer: 1
    for i in 1 ... n
      result: result * i

WHY NOT 'result *= i'?
Mutation operators like *=, +=, :=: are NOT pure — they mutate the LHS in place and COULD also affect the RHS as a side effect. Reassignment ('result: result * i') uses the pure '*' operator to create a new value, then reassigns. Only the LHS changes, the RHS is guaranteed untouched.

PURITY IS INHERITED

When an abstract function is declared pure, ALL implementations MUST also be pure:

  mathOperation() as pure abstract
    -> x as Float, y as Float
    <- result as Float?
  add() is mathOperation as pure    //Must be pure — compiler enforces
    -> x as Float, y as Float
    <- result as Float: x + y

CONSUMER vs ACCEPTOR — THE KEY DISTINCTION
EK9 provides built-in abstract function types. The most important pair is Consumer and Acceptor — they have the SAME signature but different purity:

  Consumer of type T as pure abstract    //PURE — cannot mutate
    -> t as T
  Acceptor of type T as abstract         //NOT pure — can mutate
    -> t as T

Both take a single parameter and return nothing. Consumer is pure — it can only READ the value. Acceptor is NOT pure — it can modify state. This distinction is why Optional and Result have both whenOk(Consumer) for read-only access and whenOk(Acceptor) for mutation.

OTHER BUILT-IN ABSTRACT FUNCTION TYPES

EK9 provides a complete set, all generic:

  Supplier of T as pure abstract         //No input, returns T
    <- r as T?
  Predicate of T as pure abstract        //Takes T, returns Boolean
    -> t as T
    <- r as Boolean?
  UnaryOperator of T as pure abstract    //Takes T, returns T
    -> t as T
    <- r as T?
  Comparator of T as pure abstract       //Compares two T values
    -> t1 as T, t2 as T
    <- r as Integer?
  Assessor of T as abstract              //Like Predicate but NOT pure
    -> t as T
    <- r as Boolean?

And Bi-parameter variants: BiConsumer of (T, U), BiAcceptor of (T, U), BiPredicate of (T, U).

NOTICE THE PATTERN: Pure variants (Consumer, Predicate, UnaryOperator, Comparator) guarantee safety. Non-pure variants (Acceptor, Assessor) allow mutation when needed. This gives you precise control over what operations can and cannot do.

No other mainstream language provides this. Java has Consumer but it can mutate freely. Rust has Fn/FnMut but these are structural traits. EK9 gives you NOMINAL purity enforcement with a complete type hierarchy.

See Q49 for defining functions. See Q50 for how ':=?' guarded assignment is essential in pure returns. See Q53 for how purity affects closure capture. See Q55 for passing these as delegates. See Q48 for how Result uses Consumer and Acceptor. See Q89 for how Predicate, Comparator, and UnaryOperator are used as stream pipeline stages. See Q106 for traits which define behavioral contracts. See Q215 for sanitized parameters. See Q235 for complete stream operations reference mapping each operation to its function type. See Q262 for observer pattern using function delegates as event listeners. See Q273 for purity as security boundary.

Example

defines module qa.pureconsumeracceptor

  defines function

    mathOperation() as pure abstract
      ->
        x as Float
        y as Float
      <- result as Float?

    addOp() is mathOperation as pure
      ->
        x as Float
        y as Float
      <- result as Float: x + y

    factorial() as pure
      -> n as Integer
      <- result as Integer: 1
      for i in 1 ... n
        result: result * i

    printValue()
      -> item as String
      stdout <- Stdout()
      stdout.println(`Processing: ${item}`)

  defines program

    PureDemo()
      stdout <- Stdout()

      // Pure function — compiler enforces no side effects
      stdout.println(`factorial(6): ${factorial(6)}`)

      // Pure abstract function — implementations must be pure
      op as mathOperation: addOp
      stdout.println(`pure delegate: ${op(5.0, 3.0)}`)

      // Consumer is pure — read-only access
      r1 <- Result("Steve", Integer())
      if r1?
        stdout.println(`ok: ${r1.ok()}`)

      // Built-in types use Consumer/Acceptor distinction
      opt <- Optional("Hello")
      if opt?
        stdout.println(`optional: ${opt.get()}`)

Common mistakes

E05150 — If the abstract parent is declared 'as pure', the implementation must also be 'as pure'. Omitting 'as pure' when the parent requires it triggers E05150 — purity mismatch. See ek9 -h E05150 for details.

Incorrect:

addOp() is mathOperation

Correct:

addOp() is mathOperation as pure

E08120 — In a pure function, mutation operators like *= are forbidden because they modify the LHS in-place. This triggers E08120 — mutating variables not allowed in pure scope. Use reassignment (result: result * i) to create a new value instead. See ek9 -h E08120 for details.

Incorrect:

result *= i

Correct:

result: result * i
Other ways to ask this
  • How does EK9 enforce function purity?
  • Why does EK9 have both Consumer and Acceptor?
  • What built-in abstract function types does EK9 provide?

Coming from another language?

Java: Consumer<T> can mutate freely (no purity), Function<T,R> can have side effects, no distinction between pure and impure functional interfaces, @FunctionalInterface is just documentation. Python: no built-in abstract function types, no purity concept, any function can have side effects. JavaScript: no type system for functions, no purity enforcement, everything can mutate. Rust: Fn (immutable borrow), FnMut (mutable borrow), FnOnce (takes ownership) — similar concept but structural not nominal, no named types for closures. Go: no abstract function concept, no purity enforcement, all functions can have side effects. C#: Action<T> and Func<T,R> delegates but no purity enforcement, no Consumer/Acceptor distinction. Kotlin: (T) -> Unit for both pure and impure, no compile-time purity, no distinction. Swift: (T) -> Void for both, @Sendable for some safety but no purity enforcement. EK9: Consumer is PURE (read-only), Acceptor is NOT pure (can mutate), compiler enforces this distinction, complete set of built-in abstract function types with pure/non-pure variants, purity is inherited through implementations.

Keywords: migrate, enforce, first, purity, comparator, unary, predicate, start, supplier, effect, beginner, consumer, pure, operator, function, abstract, assessor, immutable, intro, side, side-effect, acceptor