2024-02-26: Functional Programming in Fantom

I recently took a look at the book "Functional Programming in Java", 2nd Edition, by Venkat Subramaniam. The focus of the book is on Java's lambda expressions and streams library, which leads me to wonder: what does Fantom have to say on these ideas? Fantom has closures and rich collection libraries. I thought it would be interesting to review Fantom's abilities in this area, using the early part of the above book as a guide; the later part of the book considers more general design questions or Java-specific material, some of which is also applicable to Fantom code.

Illustrating Anonymous Function Arguments

The first example from the book aims to highlight the difference between a procedural and more functional solution to the same problem: take a list of prices, and then calculate the total of the discounted rate of items valued over 20:

A procedural Fantom version may look like this:

class Main
{
  static Void main(Str[] args)
  {
    prices := Int[10, 30, 17, 20, 18, 45, 12]

    totalOfDiscountedPrices := 0.0f
    for (i := 0; i < prices.size; i += 1) 
    {
      if (prices[i] > 20)
      {
        totalOfDiscountedPrices += prices[i] * 0.9f
      }
    }

    echo("Total of discounted prices: $totalOfDiscountedPrices")
  }
}

The functional version relies on common operations working directly on lists, using a closure (an anonymous function) as an argument to specify the required behaviour of each list method for this problem:

  prices := Int[10, 30, 17, 20, 18, 45, 12]

  totalOfDiscountedPrices := prices
    .findAll { it > 20 }                                      // <1>
    .map |Int x -> Float| { x * 0.9f }                        // <2>
    .reduce(0.0f) |Float r, Float v->Float| { return r + v }  // <3>

  echo("Total of discounted prices: $totalOfDiscountedPrices")
  1. The "findAll" method tests each item against an it-block.
  2. Types are explicitly provided to the "map" function's argument, to ensure the list is converted to one of the expected type.
  3. Finally, "reduce" collapses a list of items into a single value, based on the given function.

As we can see, the intent of the code is somewhat clearer from the top-level methods: findAll finds prices with the given condition, map converts items using a formula, and reduce combines the items into a single result.

Fantom's syntax is very clean here: methods do not need parentheses if there are no arguments, and, when the last argument is a function, it can be written "outside" the usual argument list - see reduce, which takes the initial item and the reducer function as its two arguments.

You may be concerned about efficiency: I expect the fact that each Fantom operation creates a new list will mean the functional-style will be inherently less efficient, which may be something you need to watch.

First-Class Functions

A typical test of a language's functional credentials is to see if functions are "first-class" values: in Fantom's case, this is true. What do we mean by "first-class" values? Definitions vary a little, but the key properties are that you can:

Here's an example of this. Two functions are created and given names sum and diff. These can then be passed to another function (method) called doCalc, which applies the passed function to some values and returns a result. The code also illustrates how the functions can be created directly at the time of the call to doCalc. The makeAdder method accepts one argument, and then returns a function which accepts a second argument and returns the sum of the two arguments.

class ExampleClosure
{
  static Void main(Str[] args)
  {
    sum := |Int a, Int b -> Int| { return a + b }         // <1>
    diff := |Int a, Int b -> Int| { return a - b }

    echo("Sum:  " + doCalc(10, 2, sum))                   // <2>
    echo("Diff: " + doCalc(10, 2, diff))
    echo("Times: " + doCalc(10, 2, |Int a, Int b -> Int| { return a * b }))
    echo("Divide: " + doCalc(10, 2) |Int a, Int b -> Int| { return a / b })
   
    add1 := makeAdder(1)                                  // <3>
    add2 := makeAdder(2)
    echo("Add 1: " + add1(10))                            // <4>
    echo("Add 2: " + add2(10))
  }

  // This method accepts a function of two numbers, and returns the result of 
  // applying this function.
  static Int doCalc(Int a, Int b, |Int,Int -> Int| calc)  // <5>
  {
    return calc(a, b)                                     // <6>
  }
  
  // This method takes a number and returns a function which uses that number.
  static |Int -> Int| makeAdder(Int x)                    // <7>
  {
    return |Int y -> Int| { return x + y }                // <8>
  }
}
  1. Create a function and assign it to a variable, sum.
  2. Pass the function as a parameter to the doCalc method. Notice how the function can be passed using a variable, directly as a value, and that the function argument can be passed from outside the argument parentheses.
  3. Call a method which returns a function, and store the result.
  4. Apply a function stored in a variable reference.
  5. Last parameter is a function value.
  6. Applies the provided function value to compute a result.
  7. Notice the return type: returns a function value.
  8. Create and return a function value.

Functions are "closures", which means they can reference variables in their environment. Hence, this technique for adding the numbers in a list:

items := Int[1,2,3,4,5]     // <1>
sum := 0                    // <2>
items.each |Int x|          // <3>
{
  sum += x                  // <4>
}
echo("Sum is: $sum")        // <5>
  1. Create a list to iterate over.
  2. A local variable to hold the result.
  3. Pass a closure to the each method defined on the List collection.
  4. Within the closure, modify the local variable sum.
  5. Show the final result, which should be 15.

Lists

The List is an ordered collection of items, indexed by an integer.

There are five kinds of operations supported on lists which accept functions as one of their parameters, distinguished largely by the kind of result obtained from the function when applied to all items in the list in turn:

Kind of Operation Kind of Result Methods on List
collapse to value return a single overall value from function results all any join max min reduce
iteration applies function to all, or sublist of, items each eachr eachNotNull eachRange
search return a new value or list of list values based on function results binaryFind binarySearch eachWhile eachrWhile exclude find findAll findIndex
split return multiple results based on function results groupBy groupByInto
transform return a list of values based on function results flatMap map mapNotNull sort sortr

Collapse to Value

Methods in this group apply a function to all items in the list, but "collapse" the results into a single value.

The basic method is reduce - in fact, this method is so powerful you can define most, if not all, the other methods described on this page in terms of it.

The reduce method takes two arguments: an initial value, which can be any kind of value, and a function. The idea is that the function combines a partial result with the next list item to obtain a new partial result: starting from the initial value, the function is applied to each list item in turn, finally returning the overall result. To achieve this, the function itself takes two arguments: something of the same type as the initial value (the partial result), and the next item in the list. The function returns something of the same type as the initial value.

For example, consider how to add up a list of integers: the initial value will be 0, and the function will take the current sum, and return a new sum consisting of the current sum added to the next item in the list:

fansh> [1,2,3,4,5].reduce(0) |Int sum, Int item -> Int| { return sum + item }
15

The initial value can be a more complex type: let's consider returning a map of each integer linked to its square:

fansh> [1,2,3,4,5].reduce(Int:Int[:]) |Int:Int squares, Int item -> Int:Int| { return squares[item] = item*item }
[1:1, 2:4, 3:9, 4:16, 5:25]

The following methods are special applications of reduce:

Iteration

Iteration applies the given function to all, or a sublist of, the items in the list. There is no specific return value, and the function is assumed to generate side-effects. The function receives two arguments: the item in the list and the item's index position in the list.

The basic method here is each, which applies a given function to every item in the list:

fansh> s := Str["a", "b", "c", "d"]
[a, b, c, d]
fansh> s.each |Str item, Int index| { echo("Item $item at $index") }
Item a at 0
Item b at 1
Item c at 2
Item d at 3

As Fantom allows us to use functions with less than the expected number of arguments, our call to each can be restricted to just the item, and this can be the focus of an "it-block":

fansh> s.each |Str item| { echo("Item is $item") }
Item is a
Item is b
Item is c
Item is d
fansh> s.each { echo("Item is $it") }
Item is a
Item is b
Item is c
Item is d

Two small variations of each are eachr and eachNotNull:

The final method restricts iteration to a sub-part of the list:

Search methods use their function to locate one or more items in the list.

The two basic methods here are find and findAll. They each use a boolean function accepting two arguments, the usual item in the list and its index position. However, the first returns the first item in the list where the function returns true, and the second returns a new list of all items in the list where the function returns true.

fansh> [1,2,3,4,5].find { isEven }
2
fansh> [1,2,3,4,5].findAll { isEven }
[2, 4]
fansh> [1,2,3,4,5].find { it > 6 }
null
fansh> [1,2,3,4,5].findAll { it > 6 }
[,]

The following two methods are variations on the basic methods:

The following two methods are also related to find, but they search on the function's computed value, stopping when a non-null computed value is found:

Finally, in the case where your list is ordered, there are two efficient search algorithms.

In the following example, the first attempt looks for the position of "3", and returns its index of 1. The second attempt looks for the position of "4", but fails to find it, returning -3.

fansh> [1,3,5,7,9].binaryFind |Int item->Int| { return 3 <=> item }
1
fansh> [1,3,5,7,9].binaryFind |Int item->Int| { return 4 <=> item }
-3

In the following example, the list of strings is ordered by size, so the comparator function to compare items by size is provided:

fansh> ["a", "g", "cat", "badger"].binarySearch("cat")
-2
fansh> ["a", "g", "cat", "badger"].binarySearch("cat") |Str a, Str b->Int| { return a.size <=> b.size }
2

Split

The "split" group of methods divide a list into sublists based on the results of a function: all items in the list for which the function produces the same value are placed into the same group. The groups are stored in a map, with the function value as the key and list of items as its value.

The basic method here is groupBy - as with each, the provided function is passed two arguments, the item in the list and its index:

fansh> [1,2,3,4,5].groupBy { isOdd }
[false:[2, 4], true:[1, 3, 5]]

The isOdd method is called on each item in the list: it returns false for even numbers and true for odd numbers, and you can see those items in the returned map.

An extension is groupByInto, which extends an existing map - in this example, we group words by their size:

fansh> results := ["ape", "bear", "cat", "deer"].groupBy { size }
[3:[ape, cat], 4:[bear, deer]]
fansh> ["elephant", "fox", "gorilla", "hare"].groupByInto(results) { size }
[3:[ape, cat, fox], 4:[bear, deer, hare], 7:[gorilla], 8:[elephant]]

Notice from the call to groupByInto that "fox" is added to the group for 3, "hare" to the group for 4, and new groups are added.

Transform

The "transform" group of methods apply a function to each item in the list and return a list of results.

There are two sets of methods in this group: the first return new lists of values using the results of a function, which receives each item in the list and its index position in turn; the second rearrange (sort) the items in the list using an optional comparator function.

The basic function for this group is map, which returns a new list of values:

fansh> [1,2,3,4,5].map { it*it }
[1, 4, 9, 16, 25]
fansh> [1,2,3,4,5].map |Int item, Int index->Str| { return "[$index] => $item" }
[[0] => 1, [1] => 2, [2] => 3, [3] => 4, [4] => 5]

Two variations make minor modifications to the list of returned values:

Two methods destructively rearrange the items in a list: sort and sortr. The latter sorts the list in reverse order. These methods take an optional function to define the comparator:

fansh> ["badger", "cat", "hare", "elephant"].sort
[badger, cat, elephant, hare]
fansh> ["badger", "cat", "hare", "elephant"].sort |Str a, Str b->Int| { return a.size <=> b.size }
[cat, hare, badger, elephant]
fansh> ["badger", "cat", "hare", "elephant"].sortr
[hare, elephant, cat, badger]
fansh> ["badger", "cat", "hare", "elephant"].sortr |Str a, Str b->Int| { return a.size <=> b.size }
[elephant, badger, hare, cat]

Maps

The Map is a collection of values, indexed by keys.

As with the List, there are various kinds of operations supported on maps which accept functions as one of their parameters. There is no "split" style method, but there are analogous examples of the other four kinds of operations:

Kind of Operation Kind of Result Methods on Map
collapse to value return a single overall value from function results all any join reduce
iteration applies function to all, or sublist of, values each
search return a new value or list of list values based on function results eachWhile exclude find findAll
transform return a new list of values based on function results map mapNotNull

These methods are essentially identical to those on lists, but use (value,key) parameter pairs for their function in place of (value,index). Methods like findAll and map now return maps, preserving the keys from the original map.

The following three examples illustrate the similarities and differences:

fansh> [1:"one",2:"two",3:"three"].each |Str value, Int key| { echo("$key => $value") }
1 => one
2 => two
3 => three
fansh> [1:"one",2:"two",3:"three"].findAll |Str value, Int key->Bool| { key.isOdd }
[1:one, 3:three]
fansh> [1:"one",2:"two",3:"three"].map |Str value, Int key -> Int| { value.size }
[1:3, 2:3, 3:5]

There are three methods specific to maps which take functions as one of their arguments. These are:

Finally, it's worth noting that maps can be decomposed into two lists by using the keys and vals methods to get lists of the keys or values, respectively. This means you can use some list methods along with a map, e.g. to find which keys are associated with the same value:

fansh> bylength := ["one":3, "two":3, "three":5, "four":4, "five":4]
[four:4, one:3, two:3, three:5, five:4]
fansh> bylength.keys.groupBy { bylength[it] }
[3:[one, two], 4:[four, five], 5:[three]]

Strings

As with the List, there are various kinds of operations supported on strings which accept functions as one of their parameters. However, these are now reduced to a small "core set" of methods:

Kind of Operation Kind of Result Methods on Map
collapse to value return a single overall value from function results all any
iteration applies function to all, or sublist of, values each eachr
search return a new value or list of list values based on function results eachWhile

These methods all work analogously to those on lists, except that the function is given each (Int) character and its index in turn:

fansh> "aBcDeF".all { isUpper }
false
fansh> "aBcDeF".any { isUpper }
true
fansh> "aBcDeF".each |Int char, Int index| { echo("$char ${char.toChar} at $index") }
97 a at 0
66 B at 1
99 c at 2
68 D at 3
101 e at 4
70 F at 5

Strings can be converted to or from lists of chars, using chars and Str.fromChars, respectively. These methods allow the use of useful list methods. For example, to keep only the upper case letters in a string:

fansh> Str.fromChars("aBcDeF123gH".chars.findAll { isUpper })
BDFH

Other Important Uses

Files

Functions are used throughout the standard library. A good example is in the File library, where two methods provide a basic iteration structure with functions filling out the details.

(OUTPUT TRUNCATED!)

Range

The range datatype supports each, eachWhile and map.

fansh> (10..15).each { echo(it) }
10
11
12
13
14
15
fansh> (10..15).map { "Id: $it" }
[Id: 10, Id: 11, Id: 12, Id: 13, Id: 14, Id: 15]

Page from Peter's Scrapbook, output from a VimWiki on 2024-02-26.