Skip to content

functional programming ​

editable examples

Every example on this page can be edited and run here: click the pencil to open it in an editor, change it, and run it in your browser. Errors, hovers and completions come from the ghūl compiler as you type.

The functional programming examples are whole programs you can run here, or build from the ghul-examples repository.

ghūl supports a functional style of programming. Functions are values, and they capture the variables around them. Local variables are immutable unless declared mut, arrays and tuples can't be changed, and List, Map and Set are read-only views. Unions with an exhaustive case model data by cases. Pipes, generators and list comprehensions process sequences without changing them. The compiler proves most functions store-free, and takes a pure declaration on trust where the proof falls short.

Mutable state is there when a program needs it: a let mut variable, a LIST, a public property. A few things work differently from ML-family languages: functions are not curried, function literals are not generic, and a function is not defined clause by clause. Each has a substitute: curry, a generic named function, and a function whose body is a case.

functions as values ​

A function literal, a named function and an operator are all values. Each can be held in a variable, passed to another function, or returned from one:

ghul
// a function literal, held in a variable
let triple = (n: int) => n * 3
write_line("triple(5): {triple(5)}")
// a named function, passed by name
double(n: int) -> int => n * 2
write_line("doubled: {[1, 2, 3] |> map(double)}")
// an operator, passed as a value
⊕(total: int, digit: int) -> int => total * 10 + digit
write_line("digits: {[4, 0, 2] |> reduce(0, `⊕)}")

A function named with an operator is written with a backtick, since an operator is not an identifier. A static member operator is named through its type, as in V.`+. The built-in operators on int, double and the other scalar types are instructions rather than functions, so they can't be passed as values; write a function literal such as (a, b) => a + b instead.

closures ​

A function literal captures the variables of the scope it is written in. An immutable let variable is captured by value, as it stood when the literal was constructed. A let mut variable is captured by reference: the function and the enclosing scope share one variable, and either can read or assign it:

ghul
// an immutable let is captured by value
let base = 10
let add_base = n => n + base
write_line("add_base(5): {add_base(5)}")
// a mut variable is captured by reference: the function and
// the enclosing scope share it
let count mut = 0
let next = () => ( count = count + 1; count )
write_line("next(): {next()}")
write_line("next(): {next()}")
write_line("count: {count}")

higher-order functions ​

A higher-order function takes a function as an argument, or returns one. Global functions and methods can do both, and can be generic:

ghul
// takes a function
apply_twice[T](f: T -> T, x: T) -> T => f(f(x))
// returns a function
twice[T](f: T -> T) -> T -> T => x => f(f(x))
// a generic method that takes a function
class SHELF[T](items: List[T]) is
map_all[U](f: T -> U) -> List[U] => [f(item) for item in items]
si
let times_2 = (x: int) => x * 2
write_line("apply_twice(times_2, 5): {apply_twice(times_2, 5)}")
write_line("apply_twice on text: {apply_twice(s => "<{s}>", "x")}")
let quadruple = twice(times_2)
write_line("quadruple(5): {quadruple(5)}")
let lengths = SHELF(["one", "three", "seven"]).map_all(s => s.length)
write_line("lengths: {lengths}")

A function literal has one type, taken from its context, so it can't be generic. Where the same code has to work for several types, write it as a generic global function or method.

data by cases ​

A union holds one of several variants, and case and if let take a union value apart. The compiler checks a case over a union for exhaustiveness, so a case that covers every variant doesn't need an else arm:

ghul
area(s: Shape) -> double =>
// case over a union is checked for exhaustiveness: every variant
// is covered here, so no else arm is needed
case ►s
when c: CIRCLE then 3.14159d * c.radius * c.radius
when q: SQUARE then q.side * q.side
esac
write_line("{area(CIRCLE(2.0d))}")
write_line("{area(SQUARE(3.0d))}")
12.56636
9

Guards, destructuring and nested patterns are covered in unions and pattern matching.

An optional type T? holds a value that may be absent. It does the job an Option or Maybe type does in other languages. ?? supplies a fallback, ?. reads a member only when the value is present, and if let tests and unwraps in one step:

ghul
find_first[T](xs: T[], predicate: T -> bool) -> T? is
for x in xs do
if predicate(x) then
return x
fi
od
return null
si
let first_even = find_first([1, 3, 4, 7, 8], n => n % 2 == 0) // T = int, a value type
let first_long = find_first(["a", "bb", "ccc"], s => s.length > 2) // T = string, a reference type
write_line("first even: {first_even ?? -1}")
write_line("first long: {first_long ?? "none"}")
first even: 4
first long: ccc

Optional types have their own page.

~> is the thread-first operator |> for a value that may be absent. When the value on its left is present, ~> passes it, unwrapped, to the call on its right. When the value is absent, the call is skipped, its arguments are not evaluated, and the result is absent. The result is always optional, so a chain of ~> stages usually ends with ??:

ghul
for text in ["8080", "99999", "http"] do
// each '~>' stage runs only when the value before it is present
let shown = text |> parse_port() ~> in_range() ~> label() ?? "no port"
write_line("{text}: {shown}")
od

|> and ~> can be mixed in one chain: a |> stage always runs, and a ~> stage runs only when the value before it is present.

defining functions by cases ​

ghūl doesn't define a function clause by clause. Two things do that job. Overloads choose between functions by the types of the arguments. A case as the body of a function chooses between arms by the values of the arguments; over several arguments, the case is over a tuple of them. The compiler checks the arms for exhaustiveness as it does any other case:

ghul
// one arm for each case of the arguments
gcd(a: int, b: int) -> int =>
case (a, b)
when (_, 0) then a
else gcd(b, a % b)
esac
// one overload for each argument type, and within the Shape
// overload one arm for each variant
describe(n: int) -> string => "the number {n}"
describe(s: string) -> string => "the text '{s}'"
describe(s: Shape) -> string =>
case ►s
when (r): CIRCLE then "a circle of radius {r}"
when (w, h): RECTANGLE /\ w == h then "a square of side {w}"
when (w, h): RECTANGLE then "a {w} by {h} rectangle"
esac
write_line("gcd(48, 18): {gcd(48, 18)}")
write_line(describe(42))
write_line(describe("forty-two"))
write_line(describe(CIRCLE(1.5)))
write_line(describe(RECTANGLE(2.0, 2.0)))
write_line(describe(RECTANGLE(2.0, 3.0)))

A named function calls itself by name. A function literal has no name, so it calls itself with rec:

ghul
// factorial
let factorial = n rec =>
if n == 0 then 1 else n * rec(n - 1) fi
write_line("factorial(5): {factorial(5)}")
// fibonacci
let fibonacci = n rec =>
if n <= 1 then n else rec(n - 1) + rec(n - 2) fi
write_line("fibonacci(10): {fibonacci(10)}")
factorial(5): 120
fibonacci(10): 55

A function literal can't refer to a variable that is defined after it. For two function literals that call each other, declare one as a let mut variable and assign the literal to it afterwards. Global functions and methods can refer to each other in either order, so mutually recursive functions are simpler to write as those:

ghul
is_even(n: int) -> bool =>
if n == 0 then true else is_odd(n - 1) fi
is_odd(n: int) -> bool =>
if n == 0 then false else is_even(n - 1) fi

immutability by default ​

A value that nothing can change is safe to share. The compiler reports an error for each of these assignments:

ghul
let total = 10
total = 11
let numbers = [1, 2, 3]
numbers[0] = 6
let pair = (1, "one")
pair.`0 = 2
let thing = THING("a thing")
thing.name = "another thing"
add_one(names: List[string]) is
names.add("one")
si
top-level value cannot be reassigned
indexer is read-only in int[]
0: int is not publicly assignable
THING.name: string is not publicly assignable
member add not found in List[string]
  • A let variable can't be reassigned. Declare it let mut to allow reassignment.
  • An array's elements can be read but not replaced, and an array literal constructs a plain array.
  • A tuple's elements can't be assigned. A tuple is a value type, so code you pass a tuple to gets a copy.
  • A property can be assigned only inside the type that declares it, unless it is declared public. The members a primary constructor synthesises are properties too, so the same applies to them.
  • List[T], Map[K, V] and Set[T] have no members that change the collection. The mutable LIST, MAP and SET implement them, so a function that takes a List[T] can read the list it is given but not change it.
  • A union value is fixed when it is constructed: its variant and its fields can't be changed. A method added to a union with partial and impl blocks can store to the heap, but the compiler reports an impure-union-method warning for it.

These guarantees are shallow: a read-only structure can hold references to objects that are themselves mutable. They also apply only to ghūl code, so code written in another .NET language can change a value ghūl treats as read-only.

pure functions ​

A postfix pure modifier declares that a function stores nothing on the heap, and calls nothing that does. The compiler proves most functions store-free without it. Where the proof falls short, write pure: the compiler takes the declaration on trust. What it does check is that every override or implementation of a pure member is pure too.

A function type can be pure, so a function can require that the function it is given is pure:

ghul
// pure: square assigns no field, property, or array element
square(x: int) -> int pure => x * x
// a pure function type: this slot accepts only pure functions
apply(f: (int) -> int pure, x: int) -> int => f(x)
write_line("apply(square, 5): {apply(square, 5)}")
write_line("apply(anonymous, 5): {apply(x => x + 1, 5)}")
apply(square, 5): 25
apply(anonymous, 5): 6

Declaring a class, struct or trait pure applies the same rule to every instance member. What purity means for type narrowing is covered under methods.

Expression bodies, and the values an if, a case or a parenthesised block produces, make functions without assignments easier to write; see expression-oriented programming.

sequences ​

filter, map, reduce ​

The pipe combinators are global functions in Ghul.Pipes. Each takes the sequence as its first argument, so the thread-first operator |> chains them. They produce new sequences and leave their source as it was:

ghul
let numbers = [1, 2, 3, 4, 5]
// map
let doubled = numbers |> map(x => x * 2)
write_line("doubled: {doubled}")
// filter
let evens = numbers |> filter(x => x % 2 == 0)
write_line("evens: {evens}")
// reduce
let sum = numbers |> reduce(0, (acc, x) => acc + x)
write_line("sum: {sum}")
// the source is left as it was
write_line("numbers: {numbers}")

To keep a sequence's elements, construct a collection from it. ARRAY(p), LIST(p) and SET(p) take any sequence, and MAP(p) takes a sequence of key and value pairs, throwing on a repeated key. string(p) puts the elements' text together with nothing between them, and string(p, separator) puts separator between each pair. The element types come from the sequence, so a constructor can end a |> chain, and _(p) constructs whichever of these the context expects:

ghul
let people = [("ada", 36), ("alan", 41), ("grace", 85)]
// a constructor ends a chain as a collecting function does
let names = people |> map(((name, _)) => name) |> ARRAY()
let ages = MAP(people)
let seen: SET[string] = _(names)
write_line("names: {names |> string(", ")}")
write_line("alan: {ages["alan"]}")
write_line("seen: {seen.contains("grace")}")
write_line("word: {"stressed" |> reverse() |> string()}")

The collecting functions do the same job written as functions.

list comprehensions ​

A list comprehension makes an array from one or more sequences. Write it in square brackets: the element first, then a for clause for each sequence, and optionally if clauses to keep only the elements you want:

ghul
let words = ["apple", "fig", "banana", "kiwi"]
// the words longer than three letters, in upper case
let long = [w.to_upper() for w in words if w.length > 3]
write_line("long: {long}")
// every ordered pair of different numbers from 1 to 3
let pairs = [(a, b) for a in 1::3 for b in 1::3 if a != b]
write_line("pairs: {pairs}")
// the result is an array, so you can pass it to a pipe
let total = [w.length for w in words] |> sum()
write_line("total length: {total}")

Each for clause iterates over its sequence the way a for loop does, so it accepts any sequence a loop accepts, and its variable can destructure each element: for (key, value) in counts. A clause can use the variables of the clauses before it, and the element can use all of them. The clauses nest in the order you write them, so in the second example b runs through 1::3 once for each value of a, and the if removes the pairs where the two are equal.

An if clause narrows what it tests, as an if statement does: in [name.length for name in names if name?], name has type string in the element.

The result is an array. Its element type is the type of the element expression, or the element type of the array the context expects. The closing bracket ends the comprehension, so you can use one on either side of |>.

A comprehension's loops are its own. You can't break or continue out of one, and it can't contain a yield, an await or a try. A function literal inside a comprehension is a separate function body, so these restrictions don't apply inside it.

The same clauses written in braces make a lazy comprehension. Its result is a Pipe[T] rather than an array, and it produces its elements one at a time as the pipe is read, so its source can be a sequence that never ends, and nothing is computed for elements that are never read:

ghul
// braces make a pipe, so the source can be one that never ends
let primes = {n for n in from(2) if is_prime(n)}
write_line("first ten primes: {primes |> take(10)}")
// reading it again runs the clauses again
write_line("first three: {primes |> take(3)}")
first ten primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
first three: [2, 3, 5]

A lazy comprehension is a generator literal. It captures the variables it reads as any function literal does, and reading the pipe again runs its clauses again. Inside an interpolated string, {{ is an escaped brace, so leave a space between the interpolation's brace and the comprehension's: "{ {x * 2 for x in xs} }".

Don't write an array comprehension over a sequence that never ends: it makes its whole array before you can use any of it, so it would never finish. Use the lazy form instead. The compiler warns with unbounded-comprehension-source when an array comprehension's source is one of the runtime's sequences that never ends, such as from(1).

generators ​

A function that returns T{} - a sequence, Iterable[T] - and contains yield is a generator. It produces its elements one at a time, as the consumer asks for them, so it can describe a sequence that never ends. yield in produces every element of another sequence, which suits a recursive generator:

ghul
// each yield hands the consumer one element
countdown(start: int) -> int{} is
let n mut = start
while n > 0 do
yield n
n = n - 1
od
si
// yield in hands over every element of another sequence
in_order(tree: Tree) -> int{} is
if let (left, value, right): NODE = ►tree then
yield in in_order(left)
yield value
yield in in_order(right)
fi
si
write_line("countdown: {countdown(5)}")
let tree = NODE(NODE(LEAF, 1, LEAF), 2, NODE(LEAF, 3, LEAF))
write_line("in order, times ten: {in_order(tree) |> map(x => x * 10)}")
countdown: [5, 4, 3, 2, 1]
in order, times ten: [10, 20, 30]

A generator's result is an ordinary sequence, so the pipe combinators chain onto it, and each read of it runs the body from the start. A function literal that contains yield is a generator too, and captures the variables around it as any function literal does. Generators have more detail.

streams ​

stream(initial, advance) in Ghul.Pipes builds a sequence from a state and a step function. The state type S and the element type T are separate type parameters, and the result is a T{}, so the state is hidden from whatever reads the sequence:

ghul
union STREAM[T, S] is
    DONE
    YIELD(value: T, state: S)
si

stream[T, S..](
    initial: S,
    advance: S.. -> STREAM[T, S]
) -> T{}

S.. makes S an argument pack, so when the state is a tuple, the step function can take its elements as separate parameters.

advance takes the current state and returns either DONE, which ends the sequence, or YIELD(value, next_state), which produces an element and the state for the next step. The || infix constructs a YIELD, so a step usually reads value || next_state:

ghul
use Ghul.Pipes
use STREAM.DONE
use STREAM.YIELD
// counting down. State and output are both int
// the sequence ends when the state reaches zero.
let counting = (n: int) =>
stream(
n,
i =>
if i == 0 then
DONE()
else
i || (i - 1)
fi
)
// fibonacci. State is the named tuple
// (prev, current); output is int. The state and
// output types differ.
let fibonacci = stream(
(prev = 1, current = 1),
((prev, current)) =>
current || (
prev = current,
current = prev + current
)
)
// factorial. State is (n, prev); output is int.
let factorial = stream(
(n = 1, prev = 1),
((n, prev)) =>
let next_n = n + 1, next = prev * next_n in
next || (n = next_n, prev = next)
)
// chars of a string: state is an int cursor,
// output is char. The input string is captured by
// the anonymous function; the integer state is hidden inside
// the resulting char{} sequence.
let chars_of = (s: string) =>
let xs = s.to_char_array() in
stream(
0,
i =>
if i == xs.count then
DONE()
else
xs[i] || (i + 1)
fi
)
write_line(
"counting down from 5: {counting(5)}"
)
write_line(
"first 10 fibonacci numbers: {fibonacci |> take(10)}"
)
write_line(
"first 10 factorial numbers: {factorial |> take(10)}"
)
write_line("chars of hello: {chars_of("hello")}")
let indexed =
fibonacci |> zip(factorial) |> take(10) |> index()
for (i, (fib, fact)) in indexed do
write_line("fibonacci {i} is {fib}")
write_line("factorial {i} is {fact}")
od

The type arguments to stream are inferred from the initial state and from what the step function yields.

seeds and caching ​

from(start) counts upwards from start without end, and from(start, step) counts in steps of step. repeat(value) produces the same value without end, and repeat(value, count) produces it count times. A sequence that never ends needs a stage that stops reading it, such as take:

ghul
// from counts upwards without end; take bounds it
let squares = from(1) |> map(n => n * n) |> take(5)
write_line("squares: {squares}")
// from with a step, and repeat with a count
write_line("evens: {from(0, 2) |> take(4)}")
write_line("dashes: {repeat("-", 5) |> join("")}")
// a list of a given size, filled with one value
let seen = repeat(false, 3) |> collect_mutable()
write_line("seen: {seen.count} values, first {seen[0]}")

A pipe computes its elements again each time it is read. memo reads its source once, keeps the elements, and replays them on every later read:

ghul
let pulled mut = 0
let counted = (n: int) -> void is pulled = pulled + 1 si
let slow = from(1) |> take(3) |> peek(counted)
// memo pulls its source once and replays what it cached
let cached = slow |> memo()
write_line("first pass: {cached}")
write_line("second pass: {cached}")
write_line("elements pulled from the source: {pulled}")

combining functions ​

composition ​

The runtime supplies function composition in both reading orders, as >> and << in namespace Ghul, so a file that composes functions needs use Ghul. f >> g applies f and then g, in the same direction as |>. f << g applies g and then f, the order used in mathematics:

ghul
let times_2 = x => x * 2
let add_1 = x => x + 1
let times_2_then_add_1 = times_2 >> add_1
write_line("times_2_then_add_1(5): {times_2_then_add_1(5)}")
let pipeline = times_2 >> add_1 >> x => "[{x}]"
write_line("pipeline(5): {pipeline(5)}")
times_2_then_add_1(5): 11
pipeline(5): [11]

combinators ​

Namespace Ghul also has the common function combinators. curry turns a two-argument function into one that takes its arguments one at a time, and uncurry turns it back. apply calls a function with the rest of its own arguments. memoize returns a function that computes its result once for each distinct set of arguments and returns the stored result for repeated calls. retry returns a function that calls the original again when it throws, up to a given number of attempts:

ghul
// curry takes the arguments one at a time
let add_3 = curry(add)(3)
write_line("add_3(4): {add_3(4)}")
// apply calls a function with the rest of its own arguments
write_line("apply(add, 1, 2): {apply(add, 1, 2)}")
// memoize computes once per distinct argument
let calls mut = 0
let slow_square = (n: int) -> int => ( calls = calls + 1; n * n )
let square = memoize(slow_square)
write_line("{square(9)} {square(9)} {square(3)}, computed {calls} times")

partial application ​

Partial application fixes some of a function's arguments and leaves the rest open. ghūl doesn't have a partial application operator; write a function literal that supplies the fixed arguments:

ghul
let add = (x, y) => x + y
let add_5 = y => add(5, y)
write_line("add_5(3): {add_5(3)}")
let add_10 = y => add(10, y)
write_line("add_10(3): {add_10(3)}")
add_5(3): 8
add_10(3): 13

argument packs ​

A type parameter written with a trailing .., as in [T..], is an argument pack: it stands for however many arguments a call supplies, held as a tuple. A formal typed T.. -> U takes a function of that many parameters, and a formal typed T.. takes the rest of the call's arguments. Together they let one function take a function of any number of parameters, and the arguments to call it with:

ghul
// T.. stands for however many arguments the call supplies
twice[T.., U](f: T.. -> U, v: T..) -> (U, U) => (f(v), f(v))
greet(name: string) -> string => "hello {name}"
join_words(a: string, b: string) -> string => "{a} {b}"
write_line("{twice(greet, "world")}")
write_line("{twice(join_words, "good", "morning")}")
write_line("{twice((a, b, c) => a + b + c, 1, 2, 3)}")

A pack holds at most seven arguments, the size of the largest tuple. Declare the T.. formal last, since it takes every argument after it. A caller that already holds the tuple can pass it in place of the separate arguments. The runtime's apply, memoize and retry take their functions this way, and so do the pipe stages that take a function.