Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Implementing Collection

The built-in collections implement Collection through compiler intrinsics, and those aren’t going anywhere — they are the fastest path for Array and Map. But the trait needs only three methods, and all three are ordinary Graphix: any structure you can fold, you can give the whole collection API. This chapter is the reference for what those implementations look like written out, and what they cost.

The operations as loops

Over an array-backed structure, the operations are index tail recursions, compiled to a native loop:

// The collection operations written as plain Graphix loops over an
// array — the spellings a Collection implementation for your own
// array-backed type would use. Each is an index tail recursion, and the
// whole loop compiles to a native kernel.
// fold: the primitive (the loop pays its own bounds check, `$` and
// select per element; array::len is a fast call).
let rec fold_go = |a: Array<'a>, f: fn(acc: 'b, x: 'a) -> 'b, i: i64, acc: 'b| -> 'b
    select i < array::len(a) {
        true => fold_go(a, f, i + 1, f(acc, a[i]$)),
        false => acc
    };

// find: stops at the match, the early exit a fold cannot express. For
// a type of your own that is the point; against Array's intrinsic it
// wins only on a hit near the front.
let rec find_go = |a: Array<'a>, f: fn(x: 'a) -> bool, i: i64| -> ['a, null]
    select i < array::len(a) {
        false => null,
        true => select f(a[i]$) {
            true => a[i]$,
            false => find_go(a, f, i + 1)
        }
    };

// map: by construction over the length — at parity with the intrinsic.
let map_go = |a: Array<'a>, f: fn(x: 'a) -> 'b| -> Array<'b>
    array::init(array::len(a), |i| f(a[i]$));

let xs = [1, 2, 3, 4, 5];
let s = #[native] fold_go(xs, |acc, x| acc + x, 0, 0);
let m = select #[native] find_go(xs, |x| x > 3, 0) { null as _ => -1, x => x };
let d = #[native] map_go(xs, |x| x * 10);
println("sum=[s] found=[m] mapped=[d]");
sys::exit(s ~ 0)

fold is the primitive; the hand-written loop pays its own bounds check, $ and select per element, so it runs several times slower than the intrinsic. find as a loop stops at the match, an early exit the fold-derived default cannot express. That is what a Collection implementation for your own type gains; against Array’s intrinsic, which is faster per element and forks across cores, the loop wins only when the hit is near the front. map by construction over the length runs close to the intrinsic.

flat_map without the quadratic

The tempting derivation — fold with array::concat — copies the accumulator on every step and is O(n²) by construction. Cons onto a List instead and finish with one reverse walk:

// flat_map without the O(n²) of repeated array concatenation: cons
// every produced element onto a List (O(1) per element) and finish
// with one reverse walk into an array. Linear, and the nested fold
// fuses into one kernel with the outer loop.
let flat_map_go = |a: Array<'a>, f: fn(x: 'a) -> Array<'b>| -> Array<'b> {
    let nil: List<'b> = [<>];
    let l = array::fold(a, nil, |acc, x|
        array::fold(f(x), acc, |acc, y| list::cons(y, acc)));
    list::to_array_rev(l)
};

let r = #[native] flat_map_go([1, 2, 3], |x| [x, x * 10]);
println("[r]");
sys::exit(r ~ 0)

This is linear, and the nested fold fuses into one kernel with the outer loop — about 3.5x the intrinsic, all of it the per-element Cons construction.

A full implementation for your own type

A program-defined linear (or tree) structure implements the three required methods as natural recursions over its constructors and inherits map, filter, find, find_map and len from the defaults:

// A program-defined linear structure with a full Collection
// implementation: write the three required methods as natural
// recursions over the constructors and every derived method (map,
// filter, find, len, ...) comes for free. The tail-recursive fold
// runs as a native loop; the value-building recursions are an
// activation per element.
type L<'a> = [`C('a, L<'a>), `N];

let rec fold_l = |l: L<'a>, acc: 'b, f: fn(acc: 'b, x: 'a) -> 'b throws 'e|
    -> 'b throws 'e
    select l {
        `N => acc,
        `C(x, rest) => fold_l(rest, f(acc, x), f)
    };

let rec filter_map_l = |l: L<'a>, f: fn(x: 'a) -> Option<'b> throws 'e|
    -> L<'b> throws 'e
    select l {
        `N => `N,
        `C(x, rest) => select f(x) {
            null as _ => filter_map_l(rest, f),
            y => `C(y, filter_map_l(rest, f))
        }
    };

let rec append_l = |a: L<'a>, b: L<'a>| -> L<'a>
    select a { `N => b, `C(x, rest) => `C(x, append_l(rest, b)) };

let rec flat_map_l = |l: L<'a>, f: fn(x: 'a) -> L<'b> throws 'e|
    -> L<'b> throws 'e
    select l {
        `N => `N,
        `C(x, rest) => append_l(f(x), flat_map_l(rest, f))
    };

impl Collection for L<'_> {
    let fold = |l, init, f| fold_l(l, init, f);
    let filter_map = |l, f| filter_map_l(l, f);
    let flat_map = |l, f| flat_map_l(l, f)
};

use Collection::*;
let l: L<i64> = `C(1, `C(2, `C(3, `N)));
let doubled = map(l, |x| x * 2);
let s = #[native] fold(doubled, 0, |a, x| a + x);
let n = len(filter(l, |x| x > 1));
println("sum=[s] kept=[n]");
sys::exit(s ~ 0)

The tail-recursive fold_l compiles to a native loop over the conses; the value-building recursions (filter_map_l, append_l) are an activation per element — see Recursion. This shape is not a toy: the same spelling over the built-in List — written with list patterns, [<>] and [<x, rest..>] — runs faster than the list fold intrinsic at 100k elements (the intrinsic pays a convert-to-array boundary; the recursion just walks the conses).

The measured comparisons behind the numbers above live in bench/collection/README.md in the Graphix repository, one row per operation and derivation, re-run as the compiler moves.