Ok, that claim is a bit silly in today's day and age, but it perfectly sums up the main goal I had while designing and developing the language:
I want to have fun in a systems programming language.
"Fun" is very personal and to each their own.
Metall shall be:
- A systems programming language close to the ... Metal(l).
- Faster than Rust (build and execute) and on par with C (see benchmarks/).
- Memory safe.
- Safe to write concurrent code.
- As complex as needed but not more.
- Pragmatic and not in your way.
Metall is a memory-safe language that uses scoped arena allocators and automatic, scope-based lifetime analysis. No need for lifetime annotations.
use std.io
fun main() void {
let @main = Arena()
let bytes = {
let @inner = Arena()
-- `i` is bound to the scope of `@inner` and cannot outlive it.
-- We could not yield `i` from this block.
let i = @inner.new<U64>(137)
-- But we can pass it to functions. Yes, that's Zig-style deref.
io.println(i.*)
-- This allocation outlives the current scope up to scope of `@main`.
@main.slice(3, 65)
}
io.println(bytes)
}
137
[65, 65, 65]
An object cannot outlive its allocation:
fun main() void {
let x = {
let @inner = Arena()
@inner.new(5)
}
}
test.met:4:9: reference escaping its allocation scope (via block result)
let @inner = Arena()
@inner.new(5)
^^^^^^^^^^^^^
}
The first allocations of an allocator are always on the stack. The size of that (cheap) stack page can be configured - the default is 1024 bytes.
use std.io
fun control_flow(a Int) void {
-- if/else:
if a > 5 {
io.print("hello ")
} else {
io.print("no ")
}
-- There is no `else if`! Use `when`:
when {
case a > 10: io.print("there ")
case a < 10: io.print("never ")
else: io.print("otherwise ")
}
-- Sum types (tagged unions)
union Foo = Str | Int | Bool
let foo = Foo(a)
-- `match` is _only_ a type discriminator.
match foo {
case Str s: io.print(s)
case Int i if i < 10: io.print("small")
case Int i: io.print(i)
-- `match` has to be exhaustive, i.e. all possible variants of
-- union type have to be included. In this case we either need
-- a `case Bool` or `else`.
else b: io.print(b)
}
io.println("!")
}
fun main() void {
control_flow(42)
}
hello there 42!
for is the only loop in Metall, and it takes three forms. It walks an iterable,
repeats while a condition holds, or, written bare, loops until a break. An
iterable is a slice, an array, a range, or any type with a next method. The loop
binds each item in turn. Add , i to also bind the index, or &mut x to change
items in place. break and continue work in every form.
use std.io
fun main() void {
for fruit, i in ["apple", "pear", "plum"][..] {
io.print(i)
io.print(" = ")
io.println(fruit)
}
}
0 = apple
1 = pear
2 = plum
A boolean condition makes it a while:
use std.io
fun main() void {
mut count = 3
for count > 0 {
io.println(count)
count = count - 1
}
}
3
2
1
lo..hi counts from lo up to but not including hi. lo..=hi includes hi.
use std.io
fun main() void {
for i in 0..3 {
io.print(i)
}
io.println("")
for i in 0..=3 {
io.print(i)
}
io.println("")
}
012
0123
A range is a value, not just loop syntax: name it, pass it, return it, then iterate it whenever you like.
use std.io
fun main() void {
let r = 1..4
for x in r {
io.println(x)
}
}
1
2
3
Inside [], a range slices a slice or array instead. Either bound may be
left off: a missing lower bound starts at the front, a missing upper bound
runs to the end, and [..] is the whole slice.
use std.io
fun main() void {
let xs = [10, 20, 30, 40][..]
io.println(xs[1..3])
io.println(xs[2..])
io.println(xs[..2])
io.println(xs[..])
}
[20, 30]
[30, 40]
[10, 20]
[10, 20, 30, 40]
Everything in Metall is immutable by default.
A function literal captures variables from its surrounding scope, named in
[...]: by value or by reference.
use std.io
fun apply(f fun(Int) Int, x Int) Int { f(x) }
fun main() void {
let base = 10
io.println(apply(fun[base](n Int) Int { base + n }, 5))
mut count = 0
let bump = fun[&mut count]() void { count.* += 1 }
bump()
bump()
io.println(count)
}
15
2
A closure's captures live on the frame that built it, so a plain closure cannot be returned:
fun adder(base Int) fun(Int) Int {
fun[base](n Int) Int { base + n }
}
fun main() void {
_ = adder(1)
}
test.met:2:5: reference escaping its allocation scope (via block result)
fun adder(base Int) fun(Int) Int {
fun[base](n Int) Int { base + n }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
@a.closure boxes the captures into an arena, so the closure outlives the frame
and can be returned and stored:
use std.io
fun adder(@a Arena, base Int) fun(Int) Int {
@a.closure(fun[base](n Int) Int { base + n })
}
fun main() void {
let @main = Arena()
let add10 = adder(@main, 10)
let add100 = adder(@main, 100)
io.println(add10(5))
io.println(add100(5))
}
15
105
An enum is a set of named, integer-backed constants. Variants can carry constant
associated data and methods, and you match on them like on a union.
use std.io
-- Discriminants are auto-assigned from 0 if not set explicitly.
enum Perm U8 = execute = 1 | write = 2 | read = 4
-- Methods are namespaced functions, just like on a struct.
pub fun Perm.symbol(p Perm) Str {
match p {
case Perm.read: "r"
case Perm.write: "w"
case Perm.execute: "x"
}
}
fun main() void {
io.println(Perm.read.symbol())
io.println(Perm.execute.symbol())
}
r
x
Each associated-data field becomes a read-only accessor, and every variant also
gets a generated debug_name.
use std.io
enum Planet(label Str, moons U8) U8 =
| mercury("Mercury", 0) = 1
| earth("Earth", 1) = 2
| mars("Mars", 2) = 3
fun main() void {
io.println(Planet.mars.label)
io.println(Planet.mars.moons)
io.println(Planet.earth.debug_name)
}
Mars
2
earth
An open enum declares no variants of its own. Subsets add variants and coerce up to the open root, so a hierarchy can grow across modules.
use std.io
enum Event U16
enum Mouse Event = press | release | move
enum Key Event = down | up
fun describe(e Event) Str {
match e {
-- A bare subset matches all of its variants. A dotted name matches one.
case Mouse: "mouse event"
case Key.down: "key down"
case Key.up: "key up"
-- A match on an open enum is never exhaustive, so it needs an else.
else: "other event"
}
}
fun main() void {
-- A subset value coerces to the open root.
let e = Mouse.move
io.println(describe(e))
io.println(describe(Key.down))
}
mouse event
key down
try e is Mouse narrows the open enum to the subset, or short-circuits through
the else.
use std.io
enum Event U16
enum Mouse Event = press | release | move
enum Key Event = down | up
fun on_mouse(e Event) Str {
let m = try e is Mouse else { return "not a mouse event" }
-- m has type Mouse here, so the match is over just the subset.
match m {
case Mouse.press: "press"
case Mouse.release: "release"
case Mouse.move: "move"
}
}
fun main() void {
io.println(on_mouse(Mouse.move))
io.println(on_mouse(Key.down))
}
move
not a mouse event
enums.variants<T>() returns every variant of an enum.
use std.io
use std.enums
enum Planet(label Str, moons U8) U8 =
| mercury("Mercury", 0)
| earth("Earth", 1)
| mars("Mars", 2)
fun main() void {
let all = enums.variants<Planet>()
for i in 0..all.len {
io.println(all[i].label)
}
}
Mercury
Earth
Mars
A fixed-size array [N]T stores its N elements inline. Its length is part of
the type, which you can let the compiler infer or write out yourself. An array
is not printable on its own. [..] views it as a slice.
use std.io
fun main() void {
let xs = [1, 2, 3]
let ys [3]Int = [4, 5, 6]
io.println(xs[..])
io.println(ys[0])
}
[1, 2, 3]
4
[N of v] builds one by filling every element with a copy of v. The element
type comes from the value, so this is a [4]U8.
use std.io
fun main() void {
let zeros = [4 of U8(0)]
io.println(zeros[..])
}
[0, 0, 0, 0]
Slicing a freshly built array as you define it hands you a mutable slice you own, so you can fill it in place.
use std.io
fun main() void {
mut buf = [3 of Int(7)][..]
buf[1] = 0
io.println(buf)
}
[7, 0, 7]
When every element will be overwritten anyway, unsafe [N uninit T] skips the
initialization. The unsafe marks that the storage starts undefined.
use std.io
fun main() void {
mut scratch = unsafe [3 uninit Int]
scratch[0] = 10
scratch[1] = 20
scratch[2] = 30
io.println(scratch[..])
}
[10, 20, 30]
A string literal is a Str: UTF-8 text. A backslash starts an escape: \n is a
newline, \t a tab, \" a quote, \\ a backslash, \xNN the code point with that
two-hex-digit value (so \xe4 is U+00E4, 'ä'), and \u{...} any Unicode code
point by its hex number.
use std.io
fun main() void {
io.println("line one\nline two")
io.println("quote \" backslash \\ umlaut \xe4 (same as \u{E4})")
}
line one
line two
quote " backslash \ umlaut ä (same as ä)
A byte string, written b"...", is a []U8 of raw bytes rather than text. Reach
for it when you are working with binary data. Here \xNN is a literal byte rather
than a code point, so \xe4 is the single byte 228 (in a Str it would be the
two UTF-8 bytes of 'ä').
use std.io
fun main() void {
let bytes = b"AB\xe4"
io.println(bytes.len)
io.println(bytes)
}
3
[65, 66, 228]
A multi-line string, written m"...", spans several lines and lets you indent the
text to match your code. Two rules make that work:
- The opening
"ends its line, and the closing"sits on its own line. The newline after the opening quote, and the whitespace and newline before the closing quote, are not part of the string. They are there only so each quote can sit on its own line. The string is exactly the lines in between, with no blank line above or below. - The closing quote marks the left edge. The spaces up to its column are stripped from every line, so you indent the block to match your code and that indentation drops out, while anything indented past the edge is kept. Every line must reach the edge with spaces: a tab there, or text that starts before the edge, is an error.
Below, the same block appears twice, differing only in where the closing quote sits. The first closes at eight spaces, so eight come off every line and the list items (two further in) keep those two. The second closes at four, so only four come off and the whole block stays indented:
use std.io
fun main() void {
io.println(m"
shopping list:
- apples
- pears
")
io.println(m"
shopping list:
- apples
- pears
")
}
shopping list:
- apples
- pears
shopping list:
- apples
- pears
An f-string, written f"...", fills its {...} placeholders with the values of the
expressions inside them. Each value must be printable: it needs a fmt method (the
HasFmt shape). Every built-in type has one, and you can add one to your own types.
On its own, an f-string is not yet a string. You choose where its text goes.
.build(@a) allocates a new Str in arena @a. .write_to(sw) appends to a
StrWriter you already have and allocates nothing.
use std.io
fun main() void {
let @a = Arena()
let name = "Ada"
let age = 36
io.println(f"{name} is {age}".build(@a))
io.println(f"next year: {age + 1}".build(@a))
}
Ada is 36
next year: 37
For text that contains braces, wrap the f-string in # marks, written f#"..."#.
Inside, braces are ordinary characters, and you interpolate with #{...}.
use std.io
fun main() void {
let @a = Arena()
let n = 3
io.println(f#"the set {1, 2, 3} has #{n} members"#.build(@a))
}
the set {1, 2, 3} has 3 members
Just like plain strings, an f-string can be a byte string (fb"...") or multi-line
(fm"...").
use std.io
fun main() void {
let @a = Arena()
let n = 42
let bytes = fb"n={n}".build(@a)
io.println(bytes.len)
io.println(fm"
name: Ada
n: {n}
".build(@a))
}
4
name: Ada
n: 42
Put a : after an expression to format it. Each type accepts its own settings:
integers take a base with an optional upper and width, floats take a
precision and a width, and bools take the word to print for each case.
use std.io
fun main() void {
let @a = Arena()
io.println(f"hex: {255:base=16, upper=true}".build(@a))
io.println(f"padded: {7:width=3}".build(@a))
io.println(f"pi: {3.14159:precision=2}".build(@a))
io.println(f"flag: {true:true_str="yes", false_str="no"}".build(@a))
}
hex: FF
padded: 7
pi: 3.14
flag: yes
To make your own type accept those settings, give it a fmt_ext method whose extra
parameters are the settings. A plain fmt is enough for {x}. The {x:...} form
calls fmt_ext instead.
use std.io
struct Temp { degrees Int }
-- `fmt` makes Temp printable, so it works in any f-string.
pub fun Temp.fmt(t Temp, sw &mut StrWriter) void {
sw.write(t.degrees)
}
-- `fmt_ext` adds a setting for the `:` form: which unit to append.
pub fun Temp.fmt_ext(t Temp, sw &mut StrWriter, unit Str = "C") void {
sw.write(t.degrees)
sw.write(unit)
}
fun main() void {
let @a = Arena()
let t = Temp(20)
io.println(f"plain {t}".build(@a))
io.println(f"with unit {t:unit="F"}".build(@a))
}
plain 20
with unit 20F
.write_to assembles a string from several pieces without allocating for each one:
use std.io
fun main() void {
let @a = Arena()
let sw = StrWriter.new(64, @a)
f"x = {1}".write_to(sw)
f", y = {2}".write_to(sw)
io.println(sw.as_str())
}
x = 1, y = 2
Integer arithmetic is checked by default. +, -, and * panic on overflow, <<
and >> panic when the shift amount is out of range for the type, and INT_MIN / -1
panics. When you want wraparound, ask for it: the +%, -%, and *% operators wrap.
use std.io
fun main() void {
-- A checked `+` would panic here. `+%` wraps on purpose.
io.println(U8(255) +% U8(1))
}
0
The compiler's --opt flag selects how aggressively the compiler optimizes
and whether those arithmetic checks survive:
none: checks on, no optimization. The development default.safe: checks on, full optimization. Same behavior asnone, faster code.fast: full optimization, and the overflow, shift-range, andINT_MIN / -1checks are stripped. A plain+that overflows now wraps like+%, and1 << 70produces an undefined value instead of panicking. Bounds checks and the divide-by-zero check stay on at every level.
So the same source line can panic under none/safe and silently wrap under fast.
Reach for fast only once you have measured the need, and write the explicit +%
family wherever wraparound is intended, so the behavior does not depend on the
optimization level.
Generic declaration
A declaration with type parameters. Generic structs, unions, shapes, and functions can be instantiated with different type arguments.
struct Box<T> {
value T
}
fun main() void {
DebugIntern.print_int(Box<Int>(42).value)
}
42
Type parameter
A name declared by a generic declaration. In Box<T>, T is a type parameter.
Type argument
A type supplied for a type parameter. In Box<Int>, Int is a type argument.
Type constraint
A requirement on a type parameter. In T HasValue, HasValue is a type
constraint on T. Other languages often call this a bound.
shape HasValue {
-- A type conforms to HasValue by providing this method.
fun HasValue.value(x HasValue) Int
}
struct Score {
n Int
}
-- Score conforms to HasValue because this method matches the shape.
fun Score.value(s Score) Int {
s.n
}
fun print_value<T HasValue>(x T) void {
DebugIntern.print_int(x.value())
}
fun main() void {
print_value(Score(7))
}
7
Phantom type parameter
A type parameter that is not used by the runtime representation of a type.
struct Open {}
struct Closed {}
struct File<State> {
fd Int
}
fun open_file() File<Open> {
File<Open>(7)
}
fun read_open(f File<Open>) Int {
f.fd
}
fun close(f File<Open>) File<Closed> {
File<Closed>(f.fd)
}
fun main() void {
let open = open_file()
DebugIntern.print_int(read_open(open))
let closed = close(open)
_ = closed
}
7
File<Open> and File<Closed> have the same runtime representation, but they
are different types. This is useful for state, capabilities, brands, units, and
typed handles.
Shape
A contract describing what a type must provide.
Associated type
A type member of a shape. Metall declares a shape's associated types in the shape parameter list.
shape Source<Item> {
-- Item is fixed by the return type of the required next method.
fun Source.next(s &mut Source) ?Item
}
struct Counter {
current Int
}
-- Counter conforms to Source, and this method fixes Counter.Item to Int.
fun Counter.next(c &mut Counter) ?Int {
let value = c.current
c.current = c.current + 1
value
}
fun first<S Source>(s &mut S) ?S.Item {
s.next()
}
fun main() void {
mut c = Counter(5)
match first(&mut c) {
case None: DebugIntern.print_int(0)
else value: DebugIntern.print_int(value)
}
}
5
Here Item is an associated type of Source.
Not every type parameter is an associated type. Box<T> declares a generic
type parameter. Source<Item> declares an associated type because Item can be
projected from an unknown type that satisfies Source, as in S.Item.
Associated type projection
An associated type selected through a constrained type parameter. If In is
constrained by Source, In.Item projects the Item associated type from
In.
shape Source<Item> {
-- Item is the element type produced by a conforming source.
fun Source.next(s &mut Source) ?Item
}
struct Once<Item> {
value ?Item
}
-- Once<Item> conforms to Source, with Source.Item equal to its own Item.
fun Once.next(o &mut Once) ?Item {
let value = o.value
o.value = None()
value
}
struct MapSource<In Source, Out> {
source In
-- In.Item is the associated type projected from the Source constraint.
mapper fun(In.Item) Out
}
fun MapSource.next(m &mut MapSource) ?Out {
match (&mut m.source).next() {
case None: None()
else value: m.mapper(value)
}
}
fun double(x Int) Int {
x * 2
}
fun main() void {
mut source = MapSource(Once(Option(21)), double)
match (&mut source).next() {
case None: DebugIntern.print_int(0)
else value: DebugIntern.print_int(value)
}
}
42
In Source means that In satisfies Source<In.Item>. The slot name is part
of the public shape API: renaming Item changes code that writes In.Item.
Associated type projections can be used in generic data declarations too:
shape Boxer<Inner> {
-- Inner is fixed by the return type of the required inner method.
fun Boxer.inner(b Boxer) Inner
}
struct IntBoxer {
value Int
}
-- IntBoxer conforms to Boxer, and this method fixes IntBoxer.Inner to Int.
fun IntBoxer.inner(b IntBoxer) Int {
b.value
}
struct Box<T Boxer> {
-- T.Inner belongs to the Boxer constraint on T, not to Box itself.
boxer T.Inner
}
fun main() void {
DebugIntern.print_int(Box<IntBoxer>(9).boxer)
}
9
Here Inner is still an associated type of Boxer, not of Box. Box<T>
uses the projection T.Inner because its type parameter T is constrained by
Boxer.
Unconstrained associated type
An associated type that is not fixed by the shape contract and has no default. Unconstrained associated types are rejected.
shape Marker<Item> {}
fun main() void {}
test.met:1:14: open shape slot: Item is not used by the shape contract
shape Marker<Item> {}
^^^^
Item has nowhere to come from. A concrete Token could satisfy
Marker<Int>, Marker<Str>, or any other Marker<X> equally, so Item is not
associated with Token in a useful way.
Template
The compiler-internal form of a generic declaration before concrete type arguments are known.
Monomorphization
The compiler strategy of generating concrete code for each required instantiation of a generic declaration.
Monomorphizer
The compiler pass that performs monomorphization.
Instantiation
The act of applying type arguments to a generic declaration, e.g. Box<Int>.
Synthetic type parameter
A compiler-created type parameter. The monomorphizer uses synthetic type
parameters internally to represent associated type projections such as
In.Item while it checks and instantiates templates.
TODO
TODO
Things to document:
- Union autwrap wraps into the first matching variant. In general, there is no ambiguity because variants - like all types - are nominally typed.
The only thing to watch out for would be mutability:
union Foo = &Int | &mut Intwould wrap a&mut Intinto&Intbecause it comes first.