EK9 in Depth

Functions as Values: the Type Hierarchy

Streams and Pipelines handed named functions to filter by and map with, and added in a footnote that "a one-in, one-out function that is not pure, for example, is a Routine" and that the rest of the family was a matter for this volume. Abstract Types, Subtypes and Polymorphism put it as "One function type, answered by many concrete functions", with "the full story" deferred to the same place. Here it is. In Java you reach for Predicate<Integer> or declare a @FunctionalInterface; in C# you write Func<int, bool> or a delegate; in Rust a closure satisfies an Fn trait. In every one of them you choose or declare the interface a function will be passed as. In EK9 you do neither. You write the function, and the compiler synthesises its type from its shape and its purity. Anywhere that type is expected, the function goes in by name.

From shape to type

Nothing in the next program says that isEven is a Predicate, or that byLength is a Comparator. The functions are plain, and each variable in the program names the type the compiler gave them:

#!ek9
defines module book.functiontypes

  defines function

    defaultGreeting() as pure
      <- rtn as String: "hello"

    show() as pure
      -> text as String
      Stdout().println(`show: ${text}`)

    showPair() as pure
      ->
        text as String
        n as Integer

      Stdout().println(`${text}=${n}`)

    isEven() as pure
      -> n as Integer
      <- rtn as Boolean: n mod 2 == 0

    doubled() as pure
      -> n as Integer
      <- rtn as Integer: n + n

    describe() as pure
      -> n as Integer
      <- rtn as String: `number ${n}`

    byLength() as pure
      ->
        first as String
        second as String
      <-
        rtn as Integer: length first <=> length second

    longerThan() as pure
      ->
        text as String
        limit as Integer
      <-
        rtn as Boolean: length text > limit

    tagged() as pure
      ->
        text as String
        n as Integer
      <-
        rtn as String: `${text}#${n}`

  defines program

    PureShapes()
      stdout <- Stdout()

      greeting as Supplier of String: defaultGreeting
      showing as Consumer of String: show
      pairShowing as BiConsumer of (String, Integer): showPair
      evenCheck as Predicate of Integer: isEven
      doubling as UnaryOperator of Integer: doubled
      describing as Function of (Integer, String): describe
      ordering as Comparator of String: byLength
      longerCheck as BiPredicate of (String, Integer): longerThan
      tagging as BiFunction of (String, Integer, String): tagged

      stdout.println(greeting())
      showing("consumed")
      pairShowing("width", 3)
      stdout.println(`isEven(4) ${evenCheck(4)}`)
      stdout.println(`doubled(21) ${doubling(21)}`)
      stdout.println(describing(3))
      stdout.println(`byLength ${ordering("ccc", "a")}`)
      stdout.println(`longerThan ${longerCheck("hello", 3)}`)
      stdout.println(tagging("item", 9))

      words <- ["ccc", "a", "bb"]
      sorted <- cat words | sort by byLength | collect as List of String
      stdout.println($sorted)
hello
show: consumed
width=3
isEven(4) true
doubled(21) 42
number 3
byLength 1
longerThan true
item#9
["a", "bb", "ccc"]

Each variable is declared as the synthesised type and called through it. The last pipeline is the payoff of the whole scheme: sort by wants a Comparator, and byLength is one because it takes two Strings, returns an Integer and is pure. There is no implements Comparator<String>, no adapter and no wrapper object. The same holds for the named functions Volume 1 passed to filter by and map with.

The library types are themselves functions. ek9 -h Predicate shows one:

    Predicate of type T as pure abstract
      -> t as T
      <- r as Boolean?

It is an abstract function: a signature with no body. Your isEven conforms to it because the compiler worked out that its shape and purity match; it never said is Predicate. That relationship is the type hierarchy of the chapter's title. The rules that place a function in it are short enough to hold in a table.

Shape If pure If not pure
no parameters, a return Supplier of R Producer of R
one parameter, no return Consumer of T Acceptor of T
two parameters, no return BiConsumer of (T, U) BiAcceptor of (T, U)
one parameter, Boolean back Predicate of T Assessor of T
one parameter, the same type back UnaryOperator of T Routine of (T, T)
one parameter, another type back Function of (T, R) Routine of (T, R)
two parameters of one type, Integer back Comparator of T BiRoutine of (T, T, Integer)
two parameters, Boolean back BiPredicate of (T, U) BiAssessor of (T, U)
two parameters, another type back BiFunction of (T, U, R) BiRoutine of (T, U, R)
anything else, such as three parameters Any Any

A shape can fit two rows, and the more specific wins: a pure one-in, same-type-out function is a UnaryOperator, not a Function, and (as the end of this chapter shows) assigning it to a Function of (T, T) is refused. The names mirror java.util.function on purpose, so a Java reader can read the pure column at sight.1 The surprise is the right-hand column, which Java does not have.

Purity forks the hierarchy

Here are the impure twins of those shapes. Every function in the file below is the same as a pure one above, except that it is not marked as pure:

#!ek9
defines module book.impuretwins

  defines function

    nextTicket()
      <- rtn as Integer: 7

    audit()
      -> text as String
      Stdout().println(`audit: ${text}`)

    auditPair()
      ->
        text as String
        n as Integer

      Stdout().println(`audit ${text}=${n}`)

    looksEven()
      -> n as Integer
      <- rtn as Boolean: n mod 2 == 0

    bump()
      -> n as Integer
      <- rtn as Integer: n + 1

    labelled()
      -> n as Integer
      <- rtn as String: `number ${n}`

    longerThan()
      ->
        text as String
        limit as Integer
      <-
        rtn as Boolean: length text > limit

    tagged()
      ->
        text as String
        n as Integer
      <-
        rtn as String: `${text}#${n}`

    byLength()
      ->
        first as String
        second as String
      <-
        rtn as Integer: length first <=> length second

  defines program

    ImpureTwins()
      stdout <- Stdout()

      ticketing as Producer of Integer: nextTicket
      auditing as Acceptor of String: audit
      pairAuditing as BiAcceptor of (String, Integer): auditPair
      evenGuess as Assessor of Integer: looksEven
      bumping as Routine of (Integer, Integer): bump
      labelling as Routine of (Integer, String): labelled
      longerCheck as BiAssessor of (String, Integer): longerThan
      tagging as BiRoutine of (String, Integer, String): tagged
      ordering as BiRoutine of (String, String, Integer): byLength

      stdout.println($ticketing())
      auditing("accepted")
      pairAuditing("height", 4)
      stdout.println(`looksEven(5) ${evenGuess(5)}`)
      stdout.println(`bump(1) ${bumping(1)}`)
      stdout.println(labelling(4))
      stdout.println(`longerThan ${longerCheck("hello", 3)}`)
      stdout.println(tagging("item", 9))
      stdout.println(`byLength ${ordering("ccc", "a")}`)

      evens <- cat [1, 2, 3, 4] | filter by looksEven | collect as List of Integer
      stdout.println($evens)

      words <- ["ccc", "a", "bb"]
      sorted <- cat words | sort by byLength | collect as List of String
      stdout.println($sorted)
7
audit: accepted
audit height=4
looksEven(5) false
bump(1) 2
number 4
longerThan true
item#9
byLength 1
[2, 4]
["a", "bb", "ccc"]

A function that is not pure is not a Predicate, it is an Assessor; the same-shape rows split the same way, and the comparator that is not pure is a BiRoutine, not a Comparator. The pipeline at the end of the program is worth noticing: the stream verbs filter by and sort by accepted the non-pure twins. The fork is in the types.

The fork matters most to pure code. A pure function may call only pure code, and a function parameter is code the compiler has not seen. A parameter's type is the only thing that can say whether the function behind it is pure, and with the fork in the type it does:

judge() as pure
  -> test as Assessor of Integer
  <- rtn as Boolean: test(6)
EK9     : Error   : E08130: 'test' on line 39 position 25: 'test(6) => Boolean <- Assessor(t as Integer) as abstract of Integer': is not marked 'pure', but call is made in a scope that is marked as 'pure'
             See: ek9 -h E08130
           forAI: purityRules

An Assessor parameter might do anything, so calling it from a pure function is E08130, exactly as calling label was in Operators and Pragmatic Purity, in Full. A Predicate parameter is known to be pure, so the same call is legal. The purity rules reach through function parameters because purity is part of the function's type.

The reverse mistake is refused too. A variable of type Predicate, or a parameter of one, will not take the impure looksEven:

evenCheck as Predicate of Integer: looksEven
stdout.println($acceptsPredicate(looksEven))
EK9     : Error   : E50030: 'evenCheck' on line 46 position 6: 'evenCheck as Boolean <- Predicate(t as Integer) as abstract of Integer' on line 46 in 'functionTypeMismatches.ek9' and 'Boolean <- looksEven(n as Integer)': types are not compatible with each other
             See: ek9 -h E50030
EK9     : Error   : E06270: 'acceptsPredicate' on line 47 position 22: found function 'Boolean <- acceptsPredicate(test as Boolean <- Predicate(t as Integer) as abstract of Integer)' but used with supplied arguments '(Boolean <- looksEven(n as Integer))' not appropriate: parameter mismatch
             See: ek9 -h E06270
           forAI: methodResolution

A declaration is E50030, "types are not compatible"; an argument is E06270, a parameter mismatch. Both messages print the type the compiler expected as a signature, Boolean <- Predicate(t as Integer) as abstract of Integer, which is the clearest view of the synthesis you will get. The impure comparator is the same error:

ordering as Comparator of String: byLength
EK9     : Error   : E50030: 'ordering' on line 49 position 6: 'ordering as Integer <- Comparator(t1 as String, t2 as String) as abstract of String' on line 49 in 'functionTypeMismatches.ek9' and 'Integer <- byLength(first as String, second as String)': types are not compatible with each other
             See: ek9 -h E50030

When no shape fits

Three parameters match no row, so the function falls back to Any. Every function can be held as an Any, which is the sense in which nothing is left untyped, but an Any cannot be called, and a three-parameter function is not a BiFunction:

held as Any: clamp
stdout.println($held(15, 1, 10))

asBiFunction as BiFunction of (Integer, Integer, Integer): clamp
EK9     : Error   : E07480: 'held' on line 54 position 22: 'held as Any' used with supplied arguments '(15 as Integer, 1 as Integer, 10 as Integer)': is not a function delegate
             See: ek9 -h E07480
EK9     : Error   : E50030: 'asBiFunction' on line 56 position 6: 'asBiFunction as Integer <- BiFunction(t as Integer, u as Integer) as abstract of (Integer, Integer, Integer)' on line 56 in 'functionTypeMismatches.ek9' and 'Integer <- clamp(n as Integer, low as Integer, high as Integer)': types are not compatible with each other
             See: ek9 -h E50030

The library catalogue is closed, but you are not limited to it, because an abstract function is a function type you write yourself. Declare the signature once, mark the implementations is that signature, and take it as a parameter type:

#!ek9
defines module book.ownfunctiontype

  defines function

    limit() as pure abstract
      ->
        n as Integer
        low as Integer
        high as Integer
      <- rtn as Integer?

    clamp() is limit as pure
      ->
        n as Integer
        low as Integer
        high as Integer
      <- rtn as Integer: n

      if n < low
        rtn: low
      else if n > high
        rtn: high

    apply() as pure
      -> rule as limit
      <- rtn as Integer: rule(15, 1, 10)

  defines program

    OwnFunctionType()
      stdout <- Stdout()
      stdout.println($clamp(15, 1, 10))
      stdout.println($apply(clamp))

      held as Any: clamp
      stdout.println(`held as Any is set: ${held?}`)
10
10
held as Any is set: true

limit is now a type with a name, and apply takes any function that is declared is limit. This is "one function type, answered by many concrete functions" in its general form, and the library's Predicate is one that someone declared for you.

An abstract function as a contract

The same device is how you write a family of interchangeable rules. A till needs a tax rule: take a net price, give back a gross. Three rules answer to the one signature, and a pure function chooses among them:

#!ek9
defines module book.taxrules

  defines constant
    STANDARD_RATE <- 0.2
    REDUCED_RATE <- 0.05

  defines function

    taxRule() as pure abstract
      -> net as Float
      <- gross as Float?

    standardRate() is taxRule as pure
      -> net as Float
      <- gross as Float: net * (1.0 + STANDARD_RATE)

    reducedRate() is taxRule as pure
      -> net as Float
      <- gross as Float: net * (1.0 + REDUCED_RATE)

    zeroRate() is taxRule as pure
      -> net as Float
      <- gross as Float: net

    ruleFor() as pure
      -> category as String
      <- rtn as taxRule?

      switch category
        case "food"
          rtn: reducedRate
        case "book"
          rtn: zeroRate
        default
          rtn: standardRate

  defines program

    ShopTill()
      stdout <- Stdout()

      for category in ["food", "book", "lamp"]
        rule <- ruleFor(category)
        stdout.println(`${category}: ${rule(100.0)}`)

      asOperator as UnaryOperator of Float: reducedRate
      stdout.println(`through UnaryOperator: ${asOperator(100.0)}`)
food: 105.0
book: 100.0
lamp: 120.0
through UnaryOperator: 105.0

ruleFor returns a taxRule, and the caller applies whichever it received. This is dynamic selection by a function value rather than by a class, and The Dispatcher is its object-oriented cousin: one contract, the implementation chosen at run time. The last two lines show that the abstract function sits in the same table. taxRule is pure, takes a Float and gives a Float back, so its own super is UnaryOperator of Float, and reducedRate is accepted as one. A reader who reaches for Function first is refused:

asFunction as Function of (Float, Float): reducedRate
EK9     : Error   : E50030: 'asFunction' on line 51 position 6: 'asFunction as Float <- Function(t as Float) as abstract of (Float, Float)' on line 51 in 'functionTypeMismatches.ek9' and 'Float <- reducedRate(net as Float)': types are not compatible with each other
             See: ek9 -h E50030

What it costs

You have to learn the table, and purity makes one row of it surprising. Add a logging call to a pure isEven and the compiler makes you drop as pure (a pure function may call only pure code), and the function is now an Assessor: every place that held it in a Predicate variable or passed it to a Predicate parameter stops compiling with E50030 or E06270. The change falls on whoever maintains the shared function, and it spreads to every site that used it. The error text helps less than it might, because it prints the synthesised signature, not your function's, and you have to know the table to see the difference. A function of three or more parameters has no library type, so you declare one. Nor can you tell the compiler that a function is "really" a Predicate when its purity says otherwise.

What that buys is a type system for functions that nobody declares and nobody can drift out of date: no @FunctionalInterface to write, no adapter between your isEven and a pipeline stage, and one vocabulary that the stream verbs, the callbacks of Optional and Result, in Depth (whenOk takes an Acceptor or a pure Consumer) and your own code share. Above all, purity is in the type. The three rules of Operators and Pragmatic Purity, in Full are checked inside each function, and a call through a function value is judged by the purity in its type, not by reading the function behind it.

Where this stops

The compiler assigns the type from the signature and purity alone. It does not look at what a function does beyond the purity rules, and a function that returns a Boolean for any reason at all is a Predicate. The table covers up to two parameters; past that you write the type. This chapter shows only that filter by and sort by took the non-pure twins in the program above; it does not list what every pipeline stage accepts.

  1. Supplier, Consumer, Predicate, Function, UnaryOperator, BiConsumer, BiPredicate and BiFunction are the java.util.function names, and Comparator is java.util's. Producer, Acceptor, Assessor, Routine and the Bi forms of them are EK9's own, for the functions that are not pure. ↩