Streams and Pipelines in EK9

The Introduction touched on pipelining; read that section first. This page has the detail.

Concept

A pipeline streams zero or more Objects through a sequence of commands, as the Unix shell does and as chained methods (filter, map, reduce) do in other languages. Along the way a command can stop an Object going further, or transform it into a different type, enriching or simplifying the data it holds. Whatever emerges at the end is then used.

So a stream has three phases:

Starting the stream

There are two ways to start a stream:

Any type of Object can go through a pipeline, including function delegates. That matters when you want 'asynchronous' processing.

Pipeline processing

The pipe '|' joins the parts of the pipeline, as in Unix. The commands are:

EK9 adds:

Items are pulled on demand. Once a head has its 'n' items nothing more is pulled, so the stages before it run only for the items it needs: cat items | filter by expensive | head 1 stops at the first match, and head also ends a stream over a source that never ends.

The stop reaches back only as far as the nearest stage that must see every item before it can produce anything: sort, tail and join (and group and split) always read all of their input, and a head after them limits only what they pass on. So a stage function that prints or records something runs once per item actually pulled, and a tee records exactly the items that flow past it.

cat a, b is one stream: all of a flows through first, then b. Each source is pulled only when it is reached, so with cat a, b | head 2, when a has two items b is never read, and a source that never ends can follow a finite one.

async | head n gives the first 'n' results to complete, so which items they are (and their order) can change from run to run.

A function called by async runs on another thread, so anything it captured and mutates (a field, a list, a counter) must be changed inside a MutexLock. Otherwise the compiler reports E08251, including for > target, >> target and | tee in target. Stdout/Stderr may be shared across workers; each line is written whole, but the order of lines from concurrent workers is not guaranteed.

Collection or Reduction

Zero or more Objects flow out of the end of the pipeline, and you decide what happens to them:

#!ek9
defines module site.streamsAndPipelines.sink.replaces

  defines function

    entryFor()
      -> number as Integer
      <- rtn as DictEntry of (String, Integer): DictEntry($number, number)

  defines program

    SinkReplacesDemo()
      stdout <- Stdout()

      names <- ["p"]
      cat ["q", "r"] > names
      stdout.println(`after >: ${names}`)

      cat ["s"] >> names
      stdout.println(`after >>: ${names}`)

      letters <- Set() of String
      letters += "x"
      cat ["y", "z"] > letters
      stdout.println(`set after >: ${letters}`)

      scores <- Dict() of (String, Integer)
      scores += DictEntry("old", 0)
      for i in 1 ... 2 | map with entryFor > scores
      stdout.println(`dict after >: ${scores}`)

      nothing <- List() of String
      cat nothing > names
      stdout.println(`empty source: ${length names}`)

The first '>' leaves names holding only "q" and "r" - the "p" it held is gone - and '>>' then adds "s". A Set and a Dict behave the same way, and a source with no items leaves the collection empty. It prints:

after >: ["q", "r"]
after >>: ["q", "r", "s"]
set after >: {"y", "z"}
dict after >: {1=1, 2=2}
empty source: 0

A '>' into a collection is all or nothing. The pipeline fills a new collection of the target's own type and, only when the pipeline has run to its end, that collection replaces what the target held. So every source reads the target as it was, even when the target is also a source (cat names, ["x"] > names reads names in full first); a pipeline that throws part-way leaves the target exactly as it was; and anything else that refers to the same collection sees the result. '>>' adds to the target as items arrive, as it always did.

#!ek9
defines module site.streamsAndPipelines.sink.all.or.nothing

  defines constant

    badNumber <- 3

  defines function

    checked()
      -> number as Integer
      <- rtn as String: String()
      if number == badNumber
        throw Exception("bad item")
      rtn: $number

  defines program

    SinkAllOrNothingDemo()
      stdout <- Stdout()

      names <- ["a", "b"]
      cat names, ["x"] > names
      stdout.println(`both sources read: ${names}`)

      kept <- ["old"]
      try
        for i in 1 ... 5 | map with checked > kept
      catch
        -> ex as Exception
        stdout.println(`failed part-way, still: ${kept}`)

      alias <- kept
      cat ["new"] > kept
      stdout.println(`an alias sees it: ${alias}`)

It prints:

both sources read: ["a", "b", "x"]
failed part-way, still: ["old"]
an alias sees it: ["new"]

A PriorityQueue and a JSON array are replaced in just the same way, and '>>' adds to them. A queue keeps what it was built with - its comparator and its size limit - when it is replaced, so the items that arrive are ordered, and trimmed, exactly as before. A JSON target follows its shape: an array (or a JSON that is not yet set) is replaced by the array the stream collects, while a JSON object keeps merging each streamed object into itself, as it always did - now all or nothing.

#!ek9
defines module site.streamsAndPipelines.sink.queue.and.json

  defines program

    SinkQueueAndJsonDemo()
      stdout <- Stdout()

      topTwo <- (PriorityQueue() of Integer).withSize(2)
      topTwo += 100
      cat [4, 8, 6] > topTwo
      stdout.println(`queue after >: ${topTwo.list()}`)

      cat [9] >> topTwo
      stdout.println(`queue after >>: ${topTwo.list()}`)

      numbers <- JSON().array()
      cat [JSON(1), JSON(2)] >> numbers
      cat [JSON(3)] > numbers
      stdout.println(`array after >: ${numbers}`)

      cat [JSON(4)] >> numbers
      stdout.println(`array after >>: ${numbers}`)

It prints:

queue after >: [8, 6]
queue after >>: [9, 8]
array after >: [3]
array after >>: [3,4]

'>>' adds as items arrive, so it must never add to the List that 'cat' is still reading: cat names >> names is an error (E08277), the same thing GNU cat says as "input file is output file", and so is a 'tee' into that List. Read a copy, or replace the List with '>'. Where the compiler cannot see that two names are one List (an alias, or one List passed as both source and target), the program raises an Exception saying the List changed while it was being read.

#!ek9
defines module site.streamsAndPipelines.sink.not.itself

  defines program

    SinkNotItselfDemo()
      stdout <- Stdout()

      //'cat numbers >> numbers' is E08277: '>>' would add to the List while 'cat' is still reading it.
      //Read a copy and append that.
      numbers <- [1, 2, 3]
      copy <- List() of Integer
      copy += numbers
      cat copy >> numbers
      stdout.println(`appended: ${numbers}`)

      //'>' fills a fresh List and swaps it in when the stream is finished, so reading what it held is fine.
      cat numbers | head 3 > numbers
      stdout.println(`replaced: ${numbers}`)

It prints:

appended: [1, 2, 3, 1, 2, 3]
replaced: [1, 2, 3]

A file output follows the shell too. TextFile(path).output() opens the file at once, but it empties the file only when something is first written: '>' clobbers the file, while '>>' keeps what the file holds and adds to it. To keep a header, write the header first (with println) and then use '>>' for the lines that follow it: a '>' after the header would empty the file again, header included.

#!ek9
defines module site.streamsAndPipelines.sink.files

  defines function

    linesOf()
      -> path as FileSystemPath
      <- rtn as List of String: List()
      cat TextFile($path).input() > rtn

  defines program

    SinkFilesDemo()
      stdout <- Stdout()
      tempDir <- FileSystemPath().withTemporaryDirectory()
      path <- tempDir + `ek9_site_sink_files_${$GUID()}.txt`
      created <- path.createFile()
      if created.isError()
        stdout.println("could not create the file")

      try
        -> output <- TextFile(path).output()
        output.println("header")
        cat ["line 1", "line 2"] >> output
      stdout.println(`header then >>: ${linesOf(path)}`)

      try
        -> output <- TextFile(path).output()
        cat ["line 3"] >> output
      stdout.println(`appended: ${linesOf(path)}`)

      try
        -> output <- TextFile(path).output()
        cat ["fresh"] > output
      stdout.println(`clobbered: ${linesOf(path)}`)

It prints:

header then >>: ["header", "line 1", "line 2"]
appended: ["header", "line 1", "line 2", "line 3"]
clobbered: ["fresh"]

Give your own records and classes a '|' operator and they can be the terminal ('collector'/'sink') of a pipeline.

If you know the Unix/Linux shell, this syntax will be familiar.

Exceptions in pipelines

A pipeline is a single unit. When your code in a stage throws an Exception and does not catch it itself, the whole pipeline stops: no further item is pulled from the source, no later stage runs and nothing more reaches the terminal. The exception then goes to the nearest enclosing try, in the function that runs the pipeline or in whatever called it. If nothing catches it, the program ends with the exception's own exit code.

This is the same for every stage that runs your code: filter/reject, map, call, flatten, sort with a comparator, group and uniq with a key function, tee into your own sink, your own source iterator, collect into your own type and > into your own StringOutput. The runtime never catches the exception and carries on.

#!ek9
defines module site.streamsAndPipelines.exceptions

  defines function

    parsePrice()
      -> text as String
      <- rtn as Integer: Integer(text)
      if not rtn?
        throw Exception(`Not a price: ${text}`, 3)

    parsePriceOrZero()
      -> text as String
      <- rtn as Integer: 0
      try
        rtn: parsePrice(text)
      catch
        -> ex as Exception
        Stdout().println(`skipped: ${ex.reason()}`)

  defines program

    PipelineExceptionDemo()
      stdout <- Stdout()
      prices <- ["12", "oops", "30", "45"]

      try
        total <- cat prices | map with parsePrice | collect as List of Integer
        stdout.println(`unreachable: ${total}`)
      catch
        -> ex as Exception
        stdout.println(`pipeline stopped: ${ex.reason()}`)

      recovered <- cat prices | map with parsePriceOrZero | collect as List of Integer
      stdout.println(`recovered: ${recovered}`)

The first pipeline stops at "oops"; "30" and "45" are never parsed. To skip a bad item and keep going, catch inside the function the stage calls, as parsePriceOrZero does: the second pipeline gives [12, 0, 30, 45]. It prints:

pipeline stopped: Not a price: oops
skipped: Not a price: oops
recovered: [12, 0, 30, 45]

Examples

The examples below each have an explanation after them. Other pages show join, call and async; the duration/date example shows '>>', and Integer pipeline shows 'reducing' or 'accumulating'.

To keep only the best N of the Objects that reach the end of a pipeline, by some weighting, use a priority queue with a finite size.

The Preamble and setup

The examples use a library of published 'Books' and their 'Authors', and extract information about the books with pipelines. The first few are trivial; the later ones build up a significant pipeline. The setup source is shown once here and used by every example.

defines constant
  MINIMUM_BOOKS <- 3
  SENIOR_AGE <- 50

defines type

  AuthorId as Integer constrain
    > 0

  Age as Integer constrain
    > 0

  Name as String constrain as
    matches /^[a-zA-Z -]+$/

  BookTitle as String constrain as
    matches /^[a-zA-Z0-9 -+]+$/

  <?-
    Simple enumeration for controlling filtering
  -?>
  BookFilterSelection
    SkipTwo
    JustFirst
    JustLast

defines class

  <?-
    Models the author of books.
  -?>
  Author
    id as AuthorId?
    age as Age?
    firstname as Name?
    surname as Name?

    Author() as pure
      ->
        id as AuthorId
        age as Age
        firstname as Name
        surname as Name
      require id? and age? and firstname? and surname?
      this.id:=? AuthorId(id)
      this.age:=? Age(age)
      this.firstname:=? Name(firstname)
      this.surname:=? Name(surname)

    Author() as pure
      -> author as Author
      this(id: author.id(), age: author.age(), firstname: author.firstname(), surname: author.surname())

    <?-
      Stop creation of empty Author.
    -?>
    default private Author() as pure

    id() as pure
      <- rtn as AuthorId: id

    age() as pure
      <- rtn as Age: age

    firstname() as pure
      <- rtn as Name: firstname

    surname() as pure
      <- rtn as Name: surname

    operator == as pure
      -> author as Author
      <- rtn as Boolean: id == author.id()

    operator #? as pure
      <- rtn as Integer: #?id

    operator $ as pure
      <- rtn as String: `Author: ${firstname} ${surname}, Age: ${age}`

    override operator ? as pure
      <- rtn as Boolean: age? and firstname? and surname?

    operator :=:
      -> author as Author
      id :=: author.id()
      age :=: author.age()
      firstname :=: author.firstname()
      surname :=: author.surname()

  Book
    title as BookTitle?
    author as Author?
    published as Date?

    Book() as pure
      ->
        title as BookTitle
        author as Author
        published as Date
      require title? and author? and published?
      this.title:=? BookTitle(title)
      this.author:=? Author(author)
      this.published:=? Date(published)

    <?-
      Stop creation of empty Book.
    -?>
    default private Book() as pure

    author() as pure
      <- rtn as Author: author

    published() as pure
      <- rtn as Date: published

    operator $ as pure
      <- rtn as String: `Title: ${title}, ${author}, Published: ${published}`

    operator #? as pure
      <- rtn as Integer: #? $this

    operator #^ as pure
      <- rtn as String: $this

    operator <=> as pure
      -> book as Book
      <- rtn as Integer: published() <=> book.published()

    override operator ? as pure
      <- rtn as Boolean: title? and author? and published?

  <?-
    Model the concept of a library, just an in-memory map for now
  -?>
  Library
    books as List of Book?

    Library() as pure
      -> withBooks as List of Book
      books :=? withBooks

    Library() as pure
      this([
        Book(BookTitle("Java"), Author(id: AuthorId(1), age: Age(50), firstname: Name("John"), surname: Name("Doe")), 1998-01-01),
        Book(BookTitle("C++"), Author(id: AuthorId(1), age: Age(42), firstname: Name("John"), surname: Name("Doe")), 1990-01-07),
        Book(BookTitle("Scala"), Author(id: AuthorId(1), age: Age(67), firstname: Name("John"), surname: Name("Doe")), 2015-03-02),
        Book(BookTitle("Python"), Author(id: AuthorId(1), age: Age(62), firstname: Name("John"), surname: Name("Doe")), 2010-12-02),
        Book(BookTitle("HTML"), Author(id: AuthorId(2), age: Age(58), firstname: Name("Mark"), surname: Name("Pickford")), 2008-07-02),
        Book(BookTitle("CSS"), Author(id: AuthorId(4), age: Age(51), firstname: Name("Mark"), surname: Name("Keely")), 2008-04-02),
        Book(BookTitle("ADA"), Author(id: AuthorId(5), age: Age(44), firstname: Name("Ada"), surname: Name("Lovelace")), 1988-01-02),
        Book(BookTitle("Dart"), Author(id: AuthorId(6), age: Age(47), firstname: Name("Peter"), surname: Name("Dove")), 2020-01-02),
        Book(BookTitle("C#"), Author(id: AuthorId(7), age: Age(60), firstname: Name("William"), surname: Name("Fence")), 2012-10-02),
        Book(BookTitle("Javascript"), Author(id: AuthorId(3), age: Age(52), firstname: Name("James"), surname: Name("Pickford")), 2008-03-02),
        Book(BookTitle("C"), Author(id: AuthorId(1), age: Age(42), firstname: Name("John"), surname: Name("Doe")), 1990-01-07),
        Book(BookTitle("C++"), Author(id: AuthorId(7), age: Age(38), firstname: Name("William"), surname: Name("Fence")), 1990-04-02),
        Book(BookTitle("C"), Author(id: AuthorId(7), age: Age(38), firstname: Name("William"), surname: Name("Fence")), 1990-04-14),
        Book(BookTitle("Haskell"), Author(id: AuthorId(7), age: Age(30), firstname: Name("William"), surname: Name("Fence")), 1982-04-14),
        Book(BookTitle("Lisp"), Author(id: AuthorId(7), age: Age(25), firstname: Name("William"), surname: Name("Fence")), 1977-09-24)
        ])

    <?-
      While you would never really 'iterate' through all the books in a library - this
      just enables the demonstration of streaming.
      Clearly you would normally have multiple maps/indexes etc to locate books via a search
      mechanism.
    -?>
    iterator() as pure
      <- rtn as Iterator of Book: books.iterator()

    operator + as pure
      -> book as Book
      <- rtn as Library: Library(books + book)

    override operator ? as pure
      <- rtn as Boolean: books?

The Model

The model is a few types (strong typing of simple values) and classes for Books, Authors and the Library. Because pipelines are the functional part of EK9, the model uses pure for immutability, and the compiler enforces it.

Strictly, the accessor methods should return clones/copies of the properties, so that nothing outside could alter the Objects provided or returned.

The Programs

The first program outputs all the books to 'Standard Out'.

Program 1

defines program

  <?-
    Just send all the books in the library out to stdout.
    Because Library has an iterator method 'cat' will use it to get each book.
  -?>
  JustCatBooks()
    stdout <- Stdout()
    library <- Library()

    cat library > stdout

It prints:

Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: HTML, Author: Mark Pickford, Age: 58, Published: 2008-07-02
Title: CSS, Author: Mark Keely, Age: 51, Published: 2008-04-02
Title: ADA, Author: Ada Lovelace, Age: 44, Published: 1988-01-02
Title: Dart, Author: Peter Dove, Age: 47, Published: 2020-01-02
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02
Title: Javascript, Author: James Pickford, Age: 52, Published: 2008-03-02
Title: C, Author: John Doe, Age: 42, Published: 1990-01-07
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: Haskell, Author: William Fence, Age: 30, Published: 1982-04-14
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24

No processing has taken place yet. Because the Book class provides the '#^' (promote) operator, a Book can be sent straight to stdout: it is converted (promoted) to a String.

Program 2

This program sorts the Books by Author ID and outputs them all to 'Standard Out'.

sort is the first stream pipeline command here. Its single parameter is a function: a named function, a call to a higher order function that returns one, or a dynamic function. Here it is a simple pure function.

A library of short pure functions, with a set of records, traits and classes, gives you reuse, and lets you write unit tests and examples for each part.

defines function

  comparingAuthor() as pure
    ->
      book1 as Book
      book2 as Book
    <-
      rtn as Integer: book1.author().id() <=> book2.author().id()

defines program

  <?-
    Now sort the books before outputting.
    This uses a specific comparator.
  -?>
  SortBooksByAuthor()
    stdout <- Stdout()
    library <- Library()

    cat library | sort by comparingAuthor > stdout

It prints, in Author ID order:

Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: C, Author: John Doe, Age: 42, Published: 1990-01-07
Title: HTML, Author: Mark Pickford, Age: 58, Published: 2008-07-02
Title: Javascript, Author: James Pickford, Age: 52, Published: 2008-03-02
Title: CSS, Author: Mark Keely, Age: 51, Published: 2008-04-02
Title: ADA, Author: Ada Lovelace, Age: 44, Published: 1988-01-02
Title: Dart, Author: Peter Dove, Age: 47, Published: 2020-01-02
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: Haskell, Author: William Fence, Age: 30, Published: 1982-04-14
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24

Program 3

John Doe has two books published on the same date (1990-01-07). This program groups the books by the same author published on the same date. group joins only adjacent items whose keys are equal, so the books are first sorted by author and then by date ('comparingAuthorThenDate'); each group is a List of Book, output on its own line.

defines function

  dateBookPublished() as pure
    -> book as Book
    <- rtn as Date: book.published()

  comparingAuthorThenDate() as pure
    ->
      book1 as Book
      book2 as Book
    <-
      rtn as Integer: comparingAuthor(book1, book2)
    if rtn == 0
      rtn: book1 <=> book2

defines program

  <?-
    Example of grouping books by the same author that were published on the same date.
    'group' only joins ADJACENT books whose keys are equal, so the books are first sorted by
    author and then by published date; each run of equal dates then becomes one group.
    The groups are output in the order they were created, each as a List of Book on its own line.
  -?>
  GroupBooksByPublishedDate()
    stdout <- Stdout()
    library <- Library()
    cat library | sort by comparingAuthorThenDate | group by dateBookPublished > stdout

It prints:

[Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07, Title: C, Author: John Doe, Age: 42, Published: 1990-01-07]
[Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01]
[Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02]
[Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02]
[Title: HTML, Author: Mark Pickford, Age: 58, Published: 2008-07-02]
[Title: Javascript, Author: James Pickford, Age: 52, Published: 2008-03-02]
[Title: CSS, Author: Mark Keely, Age: 51, Published: 2008-04-02]
[Title: ADA, Author: Ada Lovelace, Age: 44, Published: 1988-01-02]
[Title: Dart, Author: Peter Dove, Age: 47, Published: 2020-01-02]
[Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24]
[Title: Haskell, Author: William Fence, Age: 30, Published: 1982-04-14]
[Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02]
[Title: C, Author: William Fence, Age: 38, Published: 1990-04-14]
[Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02]

John Doe's two books of 1990-01-07 are one group; every other book is a group of its own. Sorted only by author, those two books would not be adjacent, and group would not join them.

Program 4

This program orders each author's books by published date, keeping the books in author order.

defines function

  compareDatePublished() as pure
    ->
      book1 as Book
      book2 as Book
    <-
      rtn as Integer: book1.published() <=> book2.published()

  orderOnPublishedDate() as pure
    -> books as List of Book
    <- rtn as List of Book: cat books | sort by compareDatePublished | collect as List of Book

defines program

  <?-
    This example outputs the books in author order, but also for each of those authors sorts their
    books by published date.
  -?>
  ProcessByAuthor()
    stdout <- Stdout()
    library <- Library()

    //This is the nearest ek9 gets to a lambda - it's a bit 'wordy'.
    //In effect we are creating a function delegate that is a 'Function' that accepts a 'Book' and
    //returns an 'AuthorId' type. It is pure in nature - it does not mutate the book in anyway.
    //The arguments of the 'Function' inferred from its definition and so can just be used in the parenthesis.

    authorId <- () is Function of (Book, AuthorId) as pure function (r:=? t.author().id())

    cat library
      | sort by comparingAuthor
      | group by authorId
      | map by orderOnPublishedDate
      | flatten
      > stdout

The list is ordered by Author ID first, then grouped into Lists of Books, one per author.

Next, map uses the function orderOnPublishedDate. The Object in the pipeline is now a 'List of Book', not a 'Book', so orderOnPublishedDate accepts a 'List of Book'. It orders that list with a pipeline of its own and returns a new, ordered list, using the terminal command collect as.

To get a stream of 'Book' again rather than of 'List of Book', flatten (thank you origin?) outputs the Books of each List in sequence. flatten works with Optional and Result too.

With pipelines (and chained methods in other languages) it can be hard to know the type at any point, because processing can change it. In EK9 the strong and explicit typing of functions and methods tells you:

Summary

Each function has a single purpose, can be reused and can be tested in isolation, and the pipeline itself stays readable. It takes more thought to design than an imperative loop with state variables.

It prints, in author order and, for each author, in publication date order:

Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07
Title: C, Author: John Doe, Age: 42, Published: 1990-01-07
Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: HTML, Author: Mark Pickford, Age: 58, Published: 2008-07-02
Title: Javascript, Author: James Pickford, Age: 52, Published: 2008-03-02
Title: CSS, Author: Mark Keely, Age: 51, Published: 2008-04-02
Title: ADA, Author: Ada Lovelace, Age: 44, Published: 1988-01-02
Title: Dart, Author: Peter Dove, Age: 47, Published: 2020-01-02
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24
Title: Haskell, Author: William Fence, Age: 30, Published: 1982-04-14
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02

Program 5

This program limits the output to authors with three or more books.

defines function

  sufficientBooks() as pure
    -> books as List of Book
    <- rtn as Boolean: length books >= MINIMUM_BOOKS

defines program
  <?-
    This example shows two things, firstly it inlines the dynamic function (not keen on this but it can be done).
    Then it filters the lists that do not have sufficient books (in this case three or more).
    Only then does it order those books on their published date.
  -?>
  ProcessByAuthorWithThreeOrMoreBooks()
    stdout <- Stdout()
    library <- Library()

    cat library
      | sort by comparingAuthor
      | group by () is Function of (Book, AuthorId) as pure function (r:=? t.author().id())
      | filter with sufficientBooks
      | map by orderOnPublishedDate
      | flatten
      > stdout

The pipeline is now long, so it uses the vertical layout, which is easier to read (if longer on the page).

The filter command lets through only a 'List of Book' holding three or more books.

Why EK9 does it this way. You could argue that 'sufficient number of books' is only an 'if statement' or a 'lambda'. EK9's aim is to capture business logic: a function called 'sufficientBooks' makes it explicit.

It prints:

Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07
Title: C, Author: John Doe, Age: 42, Published: 1990-01-07
Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24
Title: Haskell, Author: William Fence, Age: 30, Published: 1982-04-14
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02

Program 6

This program also omits the first two books of each author with three or more books.

defines program

  <?-
    Similar to the other examples, but this one just skips the first two books by
    each author (if they have 3 or more books).
    Note, there is a sort of common pattern to these, if an intermediate operation needs
    a function, you can either define a basic function or just create a dynamic function.
  -?>
  ProcessByAuthorWithThreeOrMoreBooksIgnoreFirstTwo()
    stdout <- Stdout()
    library <- Library()

    authorId <- () is Function of (Book, AuthorId) as pure function (r:=? t.author().id())
    excludingFirstTwoBooks <- () is Function of(List of Book, List of Book) as pure function
      r:=? cat t | skip 2 | collect as List of Book

    cat library
      | sort by comparingAuthor
      | group by authorId
      | filter with sufficientBooks
      | map by orderOnPublishedDate
      | map by excludingFirstTwoBooks
      | flatten
      > stdout

It prints:

Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02

The dynamic function excludingFirstTwoBooks skips books per author. It could have been a standard function, or a dynamic function returned by a higher order function. Programs 7 and 8 follow the same theme. Program 7 uses a higher order function to select the 'skip two' filter; Program 8 also shows only the first and only the last book of each author with three or more books.

Reuse

When you need different views, summaries or filters of a set of data, a set of reusable functions like these gives you the reuse. An imperative loop with loop variables and intermediate collections tends not to be reusable, because the logic is nested in the loops and their conditions.

For a single requirement, such as the latest book by author ID=1, a loop is tempting. But software that lives long gets more queries, and a pipeline makes it obvious how to extend and reuse what you have written. A short contrast comes later, and Design note: pipelines over loops makes the argument in full.

Program 7

This program uses an enumeration and a higher order function that returns a function based on the enumeration value.

defines function

  bookFilter() as pure abstract
    -> books as List of Book
    <- filtered as List of Book?

  <?-
    Example of a higher order function and dynamic functions.
    There are other ways to solve the mapping of an enumerations requirement to a function.
    This uses a switch/given expression in a pure context
  -?>
  suitableBookFilter() as pure
    -> selection as BookFilterSelection
    <- rtn as bookFilter: given selection
      <- theFilter as bookFilter?
      when BookFilterSelection.SkipTwo
        theFilter:=? () is bookFilter as pure function (filtered:=? cat books | skip 2 | collect as List of Book)
      when BookFilterSelection.JustFirst
        theFilter:=? () is bookFilter as pure function (filtered:=? cat books | head 1 | collect as List of Book)
      when BookFilterSelection.JustLast
        theFilter:=? () is bookFilter as pure function (filtered:=? cat books | tail 1 | collect as List of Book)
      default
        theFilter:=? () is bookFilter as pure function (filtered:=? books)

defines program

  <?-
    Example of using a higher order function to create the book filter.
  -?>
  ProcessByAuthorUsingHigherOrderFunction()
    stdout <- Stdout()
    library <- Library()
    authorId <- () is Function of (Book, AuthorId) as pure function (r:=? t.author().id())

    stdout.println("Omit first two books where author has three or more books")
    cat library
      | sort by comparingAuthor
      | group by authorId
      | filter with sufficientBooks
      | map by orderOnPublishedDate
      | map with suitableBookFilter(BookFilterSelection.SkipTwo)
      | flatten
      > stdout

The higher order function 'switches' on the enumeration to return the right dynamic function. The pipeline code itself could be refactored the same way, into a function.

Program 8

This program is that refactoring: it shows functional composition and the value of strong typing of functions and higher order functions.

defines function

  filterBooksToOutput()
    ->
      library as Library
      filterSelection as BookFilterSelection
      output as StringOutput

    authorId <- () is Function of (Book, AuthorId) as pure function (r:=? t.author().id())

    cat library
      | sort by comparingAuthor
      | group by authorId
      | filter with sufficientBooks
      | map by orderOnPublishedDate
      | map with suitableBookFilter(filterSelection)
      | flatten
      > output

defines program

  <?-
    Next logical step is to extract the above and put it into a function called 'filterBooksToOutput'
    Then it can just be called with appropriate parameters.
  -?>
  ProcessByAuthorUsingParameterisedFunction()
    stdout <- Stdout()
    library <- Library()

    stdout.println("Omit first two books where author has three or more books")
    filterBooksToOutput(library, BookFilterSelection.SkipTwo, stdout)

    stdout.println("First book where author has three or more books")
    filterBooksToOutput(library, BookFilterSelection.JustFirst, stdout)

    stdout.println("Last book where author has three or more books")
    filterBooksToOutput(library, BookFilterSelection.JustLast, stdout)

Simple (enumerated) types and the switch expression let you structure a range of solutions from small reusable functions. With the polymorphism of traits, classes and functions, you can change which filter functions are used and where the results are output.

Abstract functions, simple types and records with a select number of operators make refactoring and functional composition easier.

It prints each selection in turn; the first is the same as Program 7:

Omit first two books where author has three or more books
Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02
First book where author has three or more books
Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24
Last book where author has three or more books
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02

Program 9

This program tees parts of the pipeline off and saves them for later. It also removes items duplicated by a key (here the publication date).

defines function

  bookSigningEvent() as pure
    -> book as Book
    <- rtn as String: `Date: ${book.published()}`

defines program

  UniquePublishingDatesFromAuthorWithThreeOrMoreBooks()
    stdout <- Stdout()
    library <- Library()
    authorId <- () is Function of (Book, AuthorId) as pure function (r:=? t.author().id())

    authorsBooks as List of Book: List()

    booksByEachAuthor as List of List of Book: List()

    stdout.println("Unique signing events on day of publication by authors with three or more books.")

    cat library
      | sort by comparingAuthor
      | group by authorId
      | tee in booksByEachAuthor
      | filter by sufficientBooks
      | map by orderOnPublishedDate
      | flatten
      | tee in authorsBooks
      | uniq by dateBookPublished
      | sort by compareDatePublished
      | map with bookSigningEvent
      > stdout

    stdout.println("Books From author with three or more books")
    cat authorsBooks > stdout

    stdout.println(`There are ${length booksByEachAuthor} authors in total with any number of books`)

It prints:

Unique signing events on day of publication by authors with three or more books.
Date: 1977-09-24
Date: 1982-04-14
Date: 1990-01-07
Date: 1990-04-02
Date: 1990-04-14
Date: 1998-01-01
Date: 2010-12-02
Date: 2012-10-02
Date: 2015-03-02
Books From author with three or more books
Title: C++, Author: John Doe, Age: 42, Published: 1990-01-07
Title: C, Author: John Doe, Age: 42, Published: 1990-01-07
Title: Java, Author: John Doe, Age: 50, Published: 1998-01-01
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24
Title: Haskell, Author: William Fence, Age: 30, Published: 1982-04-14
Title: C++, Author: William Fence, Age: 38, Published: 1990-04-02
Title: C, Author: William Fence, Age: 38, Published: 1990-04-14
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02
There are 7 authors in total with any number of books

Each tee stores the contents streaming past it in a separate collection. uniq limits the output by a key: the function dateBookPublished makes the publication date the uniqueness key.

Program 10 (The Final Example)

This program uses only authors aged 50 or over (when they wrote the book). It includes each author once, sorts the authors by surname (then first name when surnames match) and outputs their names.

defines function

  bookAuthor() as pure
    -> book as Book
    <- author as Author: book.author()

  acceptableAuthorAge() as pure
    -> author as Author
    <- rtn as Boolean: author.age() >= Age(50)

  authorName() as pure
    -> author as Author
    <- rtn as String: `${author.firstname()} ${author.surname()}`

  comparingAuthorName() as pure
    ->
      author1 as Author
      author2 as Author
    <-
      rtn as Integer: author1.surname() <=> author2.surname()
    if rtn == 0
      rtn: author1.firstname() <=> author2.firstname()

defines program

  LibraryExample()
    stdout <- Stdout()
    library <- Library()

    cat library
      | map with bookAuthor
      | filter by acceptableAuthorAge
      | uniq
      | sort by comparingAuthorName
      | map with authorName
      > stdout

It prints:

John Doe
William Fence
Mark Keely
James Pickford
Mark Pickford

Program 11 (Split)

split divides a stream into groups at a boundary predicate. Unlike group (which groups consecutive items with the same key), split starts a new group each time the predicate returns true, and the triggering item becomes the first item in the new group.

Here the library is sorted by publication date, then split with 'isModernBook'. Every book published on or after 2000-01-01 starts a new group, so the pre-2000 books are one group and each modern book is a group of its own. head 1 then reduces each group to its first book.

defines function

  isModernBook() as pure
    -> book as Book
    <- rtn as Boolean: book.published() >= 2000-01-01

  firstBookOnly() as pure
    -> books as List of Book
    <- rtn as List of Book: cat books | head 1 | collect as List of Book

defines program

  <?-
    Split books by publication date: every modern book (published 2000 or later)
    starts a new group, then take just the first book from each group.
  -?>
  SplitBooksByEra()
    stdout <- Stdout()
    library <- Library()

    stdout.println("First book from each group, a new group starting at each modern book")

    cat library
      | sort by compareDatePublished
      | split with isModernBook
      | map by firstBookOnly
      | flatten
      > stdout

  <?-
    To get exactly two eras, group by era instead: once sorted by date the
    pre-2000 books and the modern books are each one run of equal keys.
  -?>
  GroupBooksByEra()
    stdout <- Stdout()
    library <- Library()

    stdout.println("First book from each era (pre-2000 and 2000+)")

    cat library
      | sort by compareDatePublished
      | group by isModernBook
      | map by firstBookOnly
      | flatten
      > stdout

It prints:

First book from each group, a new group starting at each modern book
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24
Title: Javascript, Author: James Pickford, Age: 52, Published: 2008-03-02
Title: CSS, Author: Mark Keely, Age: 51, Published: 2008-04-02
Title: HTML, Author: Mark Pickford, Age: 58, Published: 2008-07-02
Title: Python, Author: John Doe, Age: 62, Published: 2010-12-02
Title: C#, Author: William Fence, Age: 60, Published: 2012-10-02
Title: Scala, Author: John Doe, Age: 67, Published: 2015-03-02
Title: Dart, Author: Peter Dove, Age: 47, Published: 2020-01-02

To get exactly two eras, 'GroupBooksByEra' (in the same source) uses group by isModernBook instead: once the books are sorted by date, the pre-2000 books and the modern books are each one run of equal keys. It prints:

First book from each era (pre-2000 and 2000+)
Title: Lisp, Author: William Fence, Age: 25, Published: 1977-09-24
Title: Javascript, Author: James Pickford, Age: 52, Published: 2008-03-02

The difference between split and group is the function they take:

Like group, split produces a stream of List of T. Use flatten to unpack the groups back into individual items, or map to process each group independently (as above). If the boundary marker itself is unwanted, a map function can trim it from each group — the data is preserved, and you decide what to keep.

Summary

Pipelines are the most functional part of EK9. With functions, traits, records, classes and collections, they remove the need for deep, layered loops with stateful variables.

They also tolerate missing data: if a book's published date had been unset, there would still have been no null pointer exception.

Developers from an imperative or Object-Oriented background can take time to get used to this. Many OO languages now have 'chained'/'fluent' APIs, so EK9's clean, simple and composable syntax should be easier to understand and adopt. The syntax follows the Unix/Linux shell 'pipe' because the concept is so similar.

Why EK9 does it this way. The same functionality takes less code in an imperative style, and EK9 supports that style. But small, rounded, documented components are more reliable, reusable and easier to understand over the long term. The design pattern is a standard one, so it is clear from the page what is being done; nested loops take longer to 'grok', and each time similar functionality is needed they are written slightly differently, which leads to confusion and defects. The design note has the full argument.

For contrast, here is a partial procedural/imperative version, without the author name sorting.

defines program
  ImperativeLibraryExample()
    stdout <- Stdout()
    library <- Library()

    uniqueAuthors as Dict of (AuthorId, Author): Dict()

    for book in library
      author <- book.author()
      if author.age() >= SENIOR_AGE
        if uniqueAuthors not contains author.id()
          uniqueAuthors += DictEntry(author.id(), author)

    for author in uniqueAuthors.values()
      stdout.println(`${author.firstname()} ${author.surname()}`)

The uniq step is done with a Dictionary/Map; a List would also work. It prints (the author names are not sorted):

John Doe
Mark Pickford
Mark Keely
William Fence
James Pickford

The code is shorter (without sorting the author names), and for one-off processing it may be the better approach. Once you need to make alterations here and there, the pipeline is easier to change.

Pipeline Complexity

Pipelines have a cognitive cost: at each stage the reader must understand what flows through and how it changes. So each stage adds weighted complexity to the containing function's complexity score (see Code Quality).

Aim for 3–5 stages. At 6–7 stages, consider whether intermediate variables would read better. The weights count towards the function's complexity limit (E11010), so a function with long pipelines and other logic is pushed to split into focused helpers.

Why EK9 does it this way. EK9 applies the same structural quality rules to pipelines as to nested if statements and deeply recursive logic, so that declarative pipelines stay readable, not only correct.

Conclusion

Pipelines give you a standard, flexible set of tools to process collections of Objects clearly. The functional approach reduces the number of methods needed on classes and gives more reuse of code. It takes more thought, and sometimes more code, than an imperative approach, but when you review it, how it works is clear from the page. With EK9 you have the choice.

Next Steps

Advanced class methods is next. It is not about pipelines; it follows on from Classes.