Skip to content

Functional concepts

The functional features are available when a file declares @paradigm functional. The gate covers the five collection builtins, do notation, and the monad keyword. Gating is per file: a file without @paradigm functional cannot call map directly, but it can call a user-defined function that internally uses map. See the paradigm system for the full gating rules and the paradigms guide for a walkthrough.

Five builtins operate on collections. Each takes the collection first and a lambda last.

BuiltinShapeDescription
mapmap(xs, f)a new slice holding f applied to each element
filterfilter(xs, pred)a new slice of the elements where pred is true
reducereduce(xs, f) -> (T, error)folds with the first element as the seed, over the rest
foldfold(xs, init, f)threads an accumulator left to right through f(acc, x)
foreachforeach(xs, f) -> voidapplies f to each element, for its side effects

The collection argument is a slice-typed value or an array, viewed as a slice. The function argument is a lambda, and lambdas capture outer variables by immutable copy, taken when the lambda is created; see functions and lambdas.

map and filter build their results in fresh heap-backed slices, so a result may be returned from the function that produced it, unlike a slice viewing a frame-local array, which is rejected as an escape.

reduce has no separate seed, so an empty collection has nothing to reduce. It therefore returns (T, error), and the error fires on an empty input with the message reduce on empty slice. The must-handle rule applies; see error handling. fold takes an explicit initial accumulator and has no error case.

builtins.dusk
@paradigm functional
func main() -> int32 {
nums: int64[] = [1, 2, 3, 4, 5]
doubled := map(nums, lambda (n: int64) -> int64 { return n * 2 })
evens := filter(nums, lambda (n: int64) -> bool { return n % 2 == 0 })
sum := fold(nums, 0, lambda (acc: int64, n: int64) -> int64 { return acc + n })
prod, e := reduce(nums, lambda (a: int64, b: int64) -> int64 { return a * b })
e.ignore()
foreach(doubled, lambda (n: int64) -> void { println(n) })
foreach(evens, lambda (n: int64) -> void { println(n) })
println(sum)
println(prod)
return 0
}

The monad keyword declares a named group of monadic operations. A monad provides a unit operation that wraps a value and a bind operation that chains computations. The block belongs to the functional paradigm: declaring one outside @paradigm functional is a compile error.

monad Identity {
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x)
}
func unit(x: int64) -> int64 {
return x
}
}

The block’s methods are namespaced under the monad’s name, as Identity.bind and Identity.unit. The namespace is what lets several monads coexist in one module, each with its own bind and unit; a do Name { ... } block selects which pair to use.

Do notation sequences monadic computations. A do block contains any number of x <- e binds and bare expressions, and must end in an expression, which is the block’s result. A bare expression is an anonymous sequencing step. Other statement forms are not allowed inside a do block.

r := do {
a <- 7
b <- 3
a * b
}

The block desugars, before name resolution and type checking, into nested calls:

do { x <- m; y <- n; x + y }

becomes

bind(m, lambda (x) { return bind(n, lambda (y) { return unit(x + y) }) })

A bare do { ... } desugars against the top-level bind and unit in scope. A named do Name { ... } desugars against Name.bind and Name.unit from the matching monad block. The continuation lambda’s parameter and return types are read from the chosen bind’s second parameter, a function type (A) -> B.

baredo.dusk
@paradigm functional
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x)
}
func unit(x: int64) -> int64 {
return x
}
func main() -> int32 {
r := do {
a <- 7
b <- 3
a * b
}
println(r)
return 0
}

Two monads in one program, each selected by name:

monads.dusk
@paradigm functional
monad Identity {
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x)
}
func unit(x: int64) -> int64 {
return x
}
}
monad Doubler {
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x) * 2
}
func unit(x: int64) -> int64 {
return x
}
}
func main() -> int32 {
a := do Identity {
x <- 10
y <- 20
x + y
}
println(a)
b := do Doubler {
x <- 3
y <- 4
x + y
}
println(b)
return 0
}

Do notation currently desugars against a monad whose bind has concrete types. A bind generic over the element type is not yet monomorphized through do, so ground monads work and fully generic ones wait on a later release. The spec’s target shape (a do block chaining Maybe<T>-returning functions) is therefore a fragment today, not a compiling program:

result: Maybe<int32> = do {
x <- maybe_divide(10, 2)
y <- maybe_divide(x, 0)
z <- maybe_add(y, 1)
return z
}

The spec lists five monads to ship through import: Maybe<T>, Either<L, R>, Result<T, E>, IO<T>, and the list monad. Today the library ships two of them, std.functional.maybe and std.functional.either, as plain enums with helper functions rather than monad blocks, pending the generic-bind support described above. Result, IO, and the list monad are planned.

Importing a module is separate from declaring a paradigm, so a file can use Maybe and its helpers without @paradigm functional:

maybe.dusk
@import std.functional.maybe
func main() -> int32 {
m: Maybe<int64> = Maybe.Some(42)
println(unwrap_or(m, 0))
none: Maybe<int64> = Maybe.None
println(unwrap_or(none, 99))
return 0
}

See std.functional for the full API of both modules, and enums for the sum types that back them.