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 ghul-examples repository has fuller functional-programming examples to build and run locally, in a GitHub Codespace or a dev container.

ghūl supports a functional style of programming: functions are first-class values, the common data types are read-only by default, unions and pattern matching model data by cases, and pipes transform sequences without mutating them.

first-class functions

Functions are values. A function literal constructs one, and the result can be called, assigned to a variable, passed to another function, or stored in a data structure, like any other value:

ghul
let f = i => i * 2
write_line("f(123): {f(123)}")
// assigned to another variable
let g = f
write_line("g(456): {g(456)}")
// passed to another function
let apply_twice = (f, i) => f(f(i))
write_line("apply_twice(f, 7): {apply_twice(f, 7)}")

closures

A function literal captures the variables of its enclosing scope. An immutable let is captured by value - a snapshot taken when the literal is constructed - and a let mut is captured by reference, so the function and the enclosing scope share one live variable that either side can read or reassign:

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}")

filter, map, reduce

ghūl pipes provide filter, map and reduce as well as other ways to work with sequences of values. Each is a global function in Ghul.Pipes taking the sequence as its first argument, so the thread-first operator |> feeds one into the next:

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

recursion

Methods, global functions and anonymous functions can all call themselves recursively. A named function calls itself by name; an anonymous function has no name, so the rec keyword refers to the function itself:

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

An anonymous function cannot refer to a variable that is not yet defined, so there is no direct way to write two anonymous functions that call each other. Write mutually recursive functions as named functions, which can refer to each other whatever order they are defined in:

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

read-only by default

While ghūl supports imperative code, it also aims to make pure functions and predictable shared data low friction: the types and traits below expose no way to change a value after it is constructed. The guarantee has two limits. It is shallow: a read-only structure can still hold references to objects that are themselves mutable. And it binds only ghūl code: code written in another .NET language is not required to honour it. Within those limits, data shared through these types cannot be changed by the code you pass it to.

lists and maps are read-only views

The standard traits Collections.List[T] and Collections.Map[K, V] expose no mutating members. The mutable LIST and MAP implement them, so a function that accepts List[T] can read the list it is given but cannot change it.

arrays are read-only

The ghūl array type T[] has no assign indexer: elements can be read but not replaced. An array literal constructs a plain array, so the same applies to it.

ghul
let numbers = [1, 2, 3, 4, 5]
let element = numbers[3] // elements can be read
numbers[3] = 6
indexer is read-only in int[]

tuples are immutable

Tuple elements have no assign accessors, and tuples are value types, so a tuple passed to other code is a copy: nothing can change a tuple you hold.

ghul
let tuple = (1, 2, 3, 4, 5)
let element = tuple.`3 // elements can be read
tuple.`3 = 6
3: int is not publicly assignable

unions are read-only

A union value is fixed at construction: variant fields cannot be assigned, and nothing can change which variant a value holds. Methods can be added to a union with partial and impl blocks, but each must be pure: a union method that assigns a field of any object is reported.

properties are not publicly assignable by default

A property is readable from anywhere but assignable only within its defining type, unless it is declared public:

ghul
struct THING(name: string)
let thing = THING("a thing")
thing.name = "change it"
THING.name: string is not publicly assignable

The members a primary constructor generates are ordinary properties, so the same applies to them: they are set at construction and cannot be publicly assigned afterwards unless the parameter carries the public modifier.

pipe operations build new sequences

Pipe operations do not mutate their source: map, filter and the rest produce a new sequence and leave the input as it was:

ghul
let list = [1, 2, 3, 4, 5]
let doubled = list |> map(x => x * 2)
write_line("doubled: {doubled}")
// the original list is unchanged:
write_line("list: {list |> join(", ")}")
doubled: 2, 4, 6, 8, 10
list: 1, 2, 3, 4, 5

pure functions

A function or method can carry a postfix pure modifier, declaring that it assigns no field, property, or array element of any object. Most function bodies are proven pure with no modifier needed; the declaration covers the rest, and every override of a pure member must itself be pure. A function type can be pure too, so a signature can require that only pure functions are passed to it:

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

A class or struct can opt in to the same discipline for the whole type: declared pure on its header, every member must be proven or declared not to assign any field, property, or array element after construction. The details, including what purity means to type narrowing, are under methods.

Expression bodies and value-producing if, case, and parenthesised blocks help in writing pure functions; see expression-oriented programming.

higher-order functions

A higher-order function takes another function as an argument, or returns one. Global functions and methods can do this generically:

higher-order generic global functions

ghul
apply[T](f: T -> T, x: T) -> T =>
f(x)
apply_if[T](f: T -> T, x: T, predicate: T -> bool) -> T =>
if predicate(x) then f(x) else x fi

higher-order generic methods

ghul
class HIGHER_ORDER_FUNCTIONS[T] is
apply(f: T -> T, x: T) -> T static =>
f(x)
apply_if(
f: T -> T, x: T, predicate: T -> bool
) -> T static =>
if predicate(x) then f(x) else x fi
si

higher-order anonymous functions

ghul
let times_2 = x => x * 2
write_line("invoke(times_2, 5): {invoke(times_2, 5)}")
let square = x => x * x
write_line("invoke(square, 5): {invoke(square, 5)}")
// higher order function consumes another function:
let apply_twice = (f: int -> int, x) => f(f(x))
write_line(
"apply_twice(times_2, 5): {apply_twice(times_2, 5)}"
)
// higher order function returns another function:
let create_apply_twice = (f: int -> int) => x => f(f(x))
let apply_twice_times_2 = create_apply_twice(times_2)
write_line(
"apply_twice_times_2(5): {apply_twice_times_2(5)}"
)

Anonymous functions take a single concrete type from context; there is no generic equivalent to the two preceding forms. For polymorphic behaviour, declare a generic global function or method.

function composition

The runtime supplies composition in both reading orders, as Ghul.>> and Ghul.<<. They are library globals rather than operators the language itself owns, so a file that composes functions brings them into scope with use Ghul. f >> g applies f and then g, matching the thread-first operator's direction; f << g applies g and then f, the mathematical reading:

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]

function combinators

The runtime also supplies the common function combinators in namespace Ghul, next to >> and <<. 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 answers repeated calls from a cache, and retry returns one that calls the function again, up to a given number of attempts, when it throws:

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")

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, collected into a tuple. A formal typed T.. -> U takes a function of that many parameters, and a formal typed T.. takes the call's remaining arguments. Together they let one function accept a function of any arity 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 spread formal v: T.. 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 are written this way, and so are the pipe stages that take a function.

currying

A curried function takes its arguments one at a time: each call takes one argument and returns a function that takes the next. In ghūl that is an anonymous function that returns another:

ghul
let curried_add = x => y => x + y
write_line("curried_add(5)(3): {curried_add(5)(3)}")
let add_5 = curried_add(5)
write_line("add_5(3): {add_5(3)}")
let add_10 = curried_add(10)
write_line("add_10(3): {add_10(3)}")

partial application

Partial application fixes some of a function's arguments and leaves the rest open. No special syntax is needed: an anonymous function 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

union types and pattern matching

A union holds one of several variants, and the if let and case patterns take one apart; they are how functional ghūl code models data. A case over a union is checked for exhaustiveness, so covering every variant needs no 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

The full construct - guards, destructuring, nesting - has its own page: unions and pattern matching.

optional types

An optional type T? holds a value that may be absent - the role Option and Maybe types play in other languages, built into the type system. ?? supplies a fallback value, ?. reads a member only when the receiver 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.

the propagating thread-first operator

~> is the thread-first operator |> for a value that might be absent. If the value on its left is present, it is passed to the call on its right, unwrapped. If it 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 ~> mix freely in one chain: a |> stage runs whatever it is given, and a ~> stage runs only when there is something to run on.

lazy sequences

Lazy infinite and finite sequences are expressed with the Ghul.Pipes.STREAM[T, S] union and the stream(initial, advance) factory. State type S and output type T are independent, so the state of a stream is hidden from its consumers; stream() returns a plain Pipe[T].

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

stream[T, S](
    initial: S,
    advance: S -> STREAM[T, S]
) -> Pipe[T]

advance is a step function: it receives the current state and returns either DONE (the sequence is over) or YIELD(value, next_state), the yielded element and the state to feed back in on the next step. The || infix constructs YIELD(value, next_state), so a step body 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 Pipe[char].
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

Type arguments to stream are inferred from the initial-state value and the anonymous function's yield expression.

The factory returns Pipe[T], so combinators like take, filter, map, zip, and index chain straight onto it. The state type does not appear in that result, so consumers never see how a stream is stepped.

Two simpler seeds start a pipe with no source to draw from. from(start) counts upwards from start without end, and from(start, step) counts in steps of step. repeat(value) yields the same value without end, and repeat(value, count) yields it count times. An unbounded seed needs a stage that stops pulling, 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_list()
write_line("seen: {seen.count} values, first {seen[0]}")

A pipe normally recomputes its elements each time it is read. memo reads its source once, keeps what it read, and replays it 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}")

Generators are the other way to a lazy sequence: a function containing yield produces its elements on demand, and its result is a Pipe[T] too.