12. Auto-mapping and the @ Operator
Implicit and Explicit Auto-mapping
Functions that expect a scalar argument will automatically map over arrays or lists passed in that position. The postfix @ operator gives explicit control over mapping depth, overload selection, and Cartesian products.
12.1 Implicit Auto-mapping
-- A function expecting Int, given an array of Int, maps automatically
fn double(x Int) Int = x * 2;
let arr = [1, 2, 3, 4, 5];
arr double println;
-- [2, 4, 6, 8, 10]
-- Mixed: one auto-mapped arg, one scalar
fn add(a Int, b Int) Int = a + b;
arr add(10) println;
-- [11, 12, 13, 14, 15]
-- Multiple auto-mapped args (zip semantics, shortest length)
arr add([10, 20, 30, 40, 50]) println;
-- [11, 22, 33, 44, 55]
-- Deep auto-mapping: nested arrays unwrap to any depth
let nested = [[1, 2, 3], [4, 5, 6]];
nested double println;
-- [[2, 4, 6], [8, 10, 12]]
12.2 Auto-mapped Struct Construction
struct Point { x Int, y Int }
-- Array in a field: auto-maps to array of structs
let ps = Point { x: [10, 20, 30], y: 4 };
ps println;
-- [Point { x: 10, y: 4 }, Point { x: 20, y: 4 }, Point { x: 30, y: 4 }]
-- Arrays in both fields: zipped element-wise
let ps2 = Point { x: [1, 2, 3], y: [10, 20, 30] };
ps2 println;
-- [Point { x: 1, y: 10 }, Point { x: 2, y: 20 }, Point { x: 3, y: 30 }]
Tuple-struct and enum-case construction auto-map the same way: an array where a scalar payload is expected produces one value per element.
struct Celsius(Float);
[0.0, 37.0, 100.0] Celsius println;
-- [Celsius(0.0), Celsius(37.0), Celsius(100.0)]
enum Shape { circle Float, point }
Shape.circle([1.0, 2.0, 3.0]) println;
-- [Shape.circle(1.0), Shape.circle(2.0), Shape.circle(3.0)]
An explicit @ (and @@, @1/@2, …) requests auto-mapping at every construction site — struct literals, tuple structs, and enum cases, including generic ones. For a generic constructor the type parameter binds from the element type, so @ is what distinguishes “one value per element” from “one value whose field is the whole array”:
struct Box<T> { v T }
Box { v: [1, 2, 3] @ } println; -- [Box<Int> { v: 1 }, Box<Int> { v: 2 }, Box<Int> { v: 3 }]
Box { v: [1, 2, 3] } println; -- Box<[Int]> { v: [1, 2, 3] } (no @: T := [Int])
-- @@ peels two array layers, building a nested result
Box { v: [[1, 2], [3, 4]] @@ } println;
-- [[Box<Int> { v: 1 }, Box<Int> { v: 2 }], [Box<Int> { v: 3 }, Box<Int> { v: 4 }]]
All auto-mapped fields/arguments of a single construction must share the same depth (e.g. you cannot mix @ and @@ in one constructor); non-mapped fields are broadcast across every element.
12.3 Explicit @ Operator
The postfix @ operator forces the next function to see a scalar (by unwrapping one level), even when an array/list overload exists.
fn process(x Int) Int = x * 10;
fn process(arr [Int]) Int = arr[0];
-- Without @: the [Int] overload is selected
[1, 2, 3] process println; -- 1
-- With @: unwrap one level, selects the Int overload, auto-maps
[1, 2, 3] @ process println; -- [10, 20, 30]
-- @@ unwraps two levels
let nested = [[1, 2], [3, 4]];
nested @@ process println; -- [[10, 20], [30, 40]]
12.4 @ on Binary Operators
On a binary operator @ means what it means on a function argument: unwrap
one level of that operand before the operator is applied. Arithmetic and comparison
operators already auto-map implicitly, so there an @ is legal but adds
nothing — both of these print the same result:
println(10 + [1, 2, 3]); -- [11, 12, 13] (implicit auto-mapping)
println(10 + [1, 2, 3] @); -- [11, 12, 13] (same -- the @ is redundant)
The @ earns its keep when the operator already has a meaning for the whole
container, because then implicit auto-mapping never gets a chance. Equality compares
arrays structurally; @ asks for the element-wise comparison instead:
println([1, 2, 3] == [1, 9, 3]); -- false
println([1, 2, 3] @ == [1, 9, 3] @); -- [true, false, true]
println([1, 2, 3] @ == 2); -- [false, true, false]
Concatenation is the same story: $ joins the containers themselves, and
@ asks for it one level down. Everything the scalar $ accepts
— strings, arrays, lists, persistent vectors, tuples — concatenates
elementwise this way:
println(["a", "b"] $ ["c", "d"]); -- [a, b, c, d]
println(["a", "b"] @ $ ["c", "d"] @); -- [ac, bd]
println(["a", "b"] @ $ "!"); -- [a!, b!]
println([[1, 2], [3]] @ $ [9]); -- [[1, 2, 9], [3, 9]]
It also chooses the depth at which nested operands line up. Implicitly one level of each operand is peeled, so row i pairs with element i; peeling two levels of the nested operand instead pairs each row's elements with the array:
let m = [[1, 2], [3, 4]];
println(m + [10, 20]); -- [[11, 12], [23, 24]] (row i + element i)
println(m @@ + [10, 20] @); -- [[11, 22], [13, 24]] (element j + element j)
And it distinguishes a tuple mapped over an array from a tuple of arrays:
println((10, 20) + [1, 2, 3]); -- ([11, 12, 13], [21, 22, 23])
println((10, 20) + [1, 2, 3] @); -- [(11, 21), (12, 22), (13, 23)]
12.5 Cartesian Products with @1, @2, …
A numbered @n assigns its operand to cartesian dimension n. Distinct dimensions produce nested loops (an outer product); operands sharing a dimension are zipped. Dimension n becomes the n-th nesting level of the result.
-- Ordered @ operators produce nested loops (outer product)
fn add(a Int, b Int) Int = a + b;
add([1, 2] @1, [10, 20] @2) println;
-- [[11, 21], [12, 22]]
-- Works on binary operators too
println([1, 2] @1 + [10, 20] @2);
-- [[11, 21], [12, 22]]
fn mul(a Int, b Int) Int = a * b;
mul([1, 2, 3] @1, [10, 100] @2) println;
-- [[10, 100], [20, 200], [30, 300]]
The depth is not limited to two: any number of dimensions (@1 through @9) nest to the corresponding depth.
-- Three dimensions -> a 2x2x2 nested result
fn add3(x Int, y Int, z Int) Int = x + y + z;
add3([1, 2] @1, [10, 20] @2, [100, 200] @3) println;
-- [[[111, 211], [121, 221]], [[112, 212], [122, 222]]]
-- A non-mapped argument is broadcast across every combination
add3([1, 2] @1, [10, 20] @2, 1000) println;
-- [[1011, 1021], [1012, 1022]]
Cartesian mapping over persistent vectors
Cartesian mapping applies uniformly to persistent vectors (and mixtures of arrays and persistent vectors). When any operand is a persistent vector the nested result is built from persistent vectors:
add3(#[1, 2] @1, #[10, 20] @2, #[100, 200] @3) println;
-- #[#[#[111, 211], #[121, 221]], #[#[112, 212], #[122, 222]]]
12.6 Auto-map Field Access
Field access on an array or list of structs/tuples automatically maps, extracting that field from each element.
struct Point { x Int, y Int }
-- Array of structs: field access auto-maps
let points = [Point{1, 2}, Point{3, 4}, Point{5, 6}];
points.x println; -- [1, 3, 5]
points.y println; -- [2, 4, 6]
-- Array of tuples: index access auto-maps
let tuples = [(1, 2), (3, 4), (5, 6)];
tuples.0 println; -- [1, 3, 5]
tuples.1 println; -- [2, 4, 6]
-- List of structs
let point_list = List(Point{1, 2}, Point{3, 4});
point_list.x println; -- List(1, 3)
-- Chained: .field on nested collections maps at each level
let nested = [[(1, 2)], [(3, 4), (5, 6)]];
nested.0 println; -- [[1], [3, 5]]
12.7 Auto-mapping with Lists
-- Lists auto-map the same way as arrays
fn double(x Int) Int = x * 2;
List(1, 2, 3) double println; -- List(2, 4, 6)
-- List arithmetic
println(List(1, 2, 3) + 10); -- List(11, 12, 13)
println(List(1, 2, 3) + List(10, 20, 30)); -- List(11, 22, 33)
-- List @ on binary ops
println(List(1, 2, 3) @ + 10); -- List(11, 12, 13)
-- Mixed list + array: result is List when a List is present
fn add(a Int, b Int) Int = a + b;
println(add(List(10, 20, 30), [1, 2, 3])); -- List(11, 22, 33)