LEVIATHAN v962456e · 962456eee1

Metaprogramming

The reifiable subset

The expressions that may appear in a reified lambda and the tree node each one becomes.

since 0.1.0-alpha.1linuxwindowswasm

Description

A reified lambda body is limited to the constructs in this table. Each becomes one node kind of the expr tree. Anything else is a compile error that names the construct.

in the lambda body tree node
u.f, u.f.g: a member chain rooted at a lambda parameter Field(["f"]), Field(["f", "g"])
a chain that does not start at a lambda parameter: a captured local, this.x, obj.field, this.x.y one Bind(slot) for the value of the whole chain, read once
an int, float, bool or string literal, None, or an enum member E::M Lit(value); an enum member is Lit of its integer value
l op r with op one of ==, !=, <, <=, >, >=, &&, ||, +, -, *, /, % Bin("op", l, r); operands are never reordered
!e, -e Un("!", e), Un("-", e)
a call to one of the allowed methods Call("name", receiver, [arguments])
u.field = value as the entire body, when the target type is expr::Expr<(E) => int> Assign(Field(["field"]), value), the "set" shape
anything else a compile error

A captured value must have type string, int, float or bool. The only exception is an Array<T> used as the receiver of contains: it gets a Bind slot, but the binds array holds None there because it cannot hold an array. The closure still sees the array, and a consumer that needs its elements keeps its own reference. A capture of any other type is a compile error (capture of a non-value type).

Fields, literals, operators and calls

class Address {
    string city = "Oslo";
}

class User {
    string name;
    int age;
    bool active;
    Address address = Address();
    new User(string n, int a, bool act) { name = n; age = a; active = act; }
}

string lit(string | int | float | bool | None v) {
    match (v) {
        string => { return "\"" + v + "\""; }
        int => { return v.toString(); }
        float => { return v.toString(); }
        bool => { return v.toString(); }
        else => { return "None"; }
    }
}

string dump(expr::Node n) {
    match (n) {
        expr::Bin => {
            expr::Bin b = n;
            return "Bin(${b.op}, ${dump(b.l)}, ${dump(b.r)})";
        }
        expr::Un => {
            expr::Un x = n;
            return "Un(${x.op}, ${dump(x.e)})";
        }
        expr::Call => {
            expr::Call c = n;
            return "Call(${c.name}, ${dump(c.recv)}, ${dump(c.args[0])})";
        }
        expr::Field => {
            string p = n.path.joinToString(".");
            return "Field(${p})";
        }
        expr::Lit => { return "Lit(" + lit(n.v) + ")"; }
        expr::Bind => { return "Bind(${n.slot})"; }
        else => { return "?"; }
    }
}

void show(expr::Expr<(User) => bool> e) {
    console.writeln(dump(e.tree));
}

show((u) => u.address.city == "Oslo");
show((u) => !u.active || -u.age < 0);
show((u) => None != u.name);
show((u) => u.age % 2 == 0 && u.age * 2 - 1 > 0);
show((u) => u.name.startsWith("A") || u.name.contains("da"));
show((u) => u.age == 1 || u.age == 1.5);
Bin(==, Field(address.city), Lit("Oslo"))
Bin(||, Un(!, Field(active)), Bin(<, Un(-, Field(age)), Lit(0)))
Bin(!=, Lit(None), Field(name))
Bin(&&, Bin(==, Bin(%, Field(age), Lit(2)), Lit(0)), Bin(>, Bin(-, Bin(*, Field(age), Lit(2)), Lit(1)), Lit(0)))
Bin(||, Call(startsWith, Field(name), Lit("A")), Call(contains, Field(name), Lit("da")))
Bin(||, Bin(==, Field(age), Lit(1)), Bin(==, Field(age), Lit(1.500000)))

Rules

  • Operands of a Bin keep the order in which they are written: None != u.name is Bin("!=", Lit(None), Field(["name"])), not a swapped comparison.
  • A captured expression becomes a single Bind for its entire value. Arithmetic on captured values stays in the tree: with a captured lo, u.age < lo + 50 is Bin("<", Field(["age"]), Bin("+", Bind(0), Lit(50))).
  • A chain such as this.owner.limit is one Bind, not a Field, because it does not start at a lambda parameter.
  • A string interpolation inside a reified lambda is rejected, because it expands to calls that are not in the allowed list.
  • The set shape is only recognized when the assignment is the whole body and the function type returns int. The same assignment in a bool lambda is a compile error.

Examples

Captures, enums and the set shape

enum Role { Admin, Guest }

class Owner {
    string name = "Ada";
    int limit = 40;
}

class Account {
    int age;
    Role role;
    new Account(int a, Role r) { age = a; role = r; }
}

class Filters {
    Owner owner = Owner();

    string text(expr::Node n) {
        match (n) {
            expr::Bin => {
                expr::Bin b = n;
                return "(${this.text(b.l)} ${b.op} ${this.text(b.r)})";
            }
            expr::Field => {
                string p = n.path.joinToString(".");
                return p;
            }
            expr::Bind => { return "$${n.slot}"; }
            expr::Lit => {
                string | int | float | bool | None v = n.v;
                match (v) {
                    int => { return "int ${v}"; }
                    else => { return "lit"; }
                }
            }
            expr::Assign => {
                expr::Assign a = n;
                return "set ${this.text(a.target)} = ${this.text(a.value)}";
            }
            else => { return "?"; }
        }
    }

    void run() {
        expr::Expr<(Account) => bool> mine = (a) => a.age < this.owner.limit && a.age > 0;
        console.writeln(this.text(mine.tree));
        console.writeln(mine.binds.length());
        expr::Expr<(Account) => bool> admin = (a) => a.role == Role::Admin;
        console.writeln(this.text(admin.tree));
        console.writeln(admin.fn(Account(30, Role::Admin)));
        expr::Expr<(Account) => int> setter = (a) => a.age = 41;
        console.writeln(this.text(setter.tree));
    }
}

Filters().run();
((age < $0) && (age > int 0))
1
(role == int 0)
true
set age = int 41
class User { string name; int age; }

// error: cannot reify block body
expr::Expr<(User) => bool> a = (u) => { return u.age > 1; };

// error: cannot reify conditional expression
expr::Expr<(User) => bool> b = (u) => u.age > 1 ? true : false;

// error: cannot reify capture of a non-value type
User other = User();
expr::Expr<(User) => bool> c = (u) => other == u;
not run — shows a compile error

Notes

The allowed method calls are listed on the whitelist page. Constructs that do not appear in the table, such as indexing, match, is, nested lambdas, await and conditional expressions, are errors; the diagnostics page lists the messages.

See also

  • Expression reification — lambdas as data — A lambda literal in a position typed expr::Expr<F> compiles to an ordinary closure plus a walkable tree of its checked body, which is what query builders translate to other languages.
  • Captured values and the binds array — How a reified lambda records the outside values it uses, once and in order, in the binds array, and what that guarantees.
  • Method calls in a reified lambda, and like / ilike — The six method calls that may appear in a reified lambda, and the exact matching rules of string.like and string.ilike.
  • Reification errors — The compile errors a reified lambda can produce, what each means, and how to fix it.
  • expr — The expression-reification tree: runtime, walkable descriptions of lambda bodies.