We can divide our program's memory into regions, for safer and faster code.
(These are planned features, see the roadmap for plans!)
Every function has its current region, but can access other regions as well.
We often call main's region the main region.
A common pattern is for a function to regard all of its parameters as coming from a read-only region.
Notice the <a' ro>. a' means a is a region, followed by its permission (ro which means read-only).
When main passes in allShips from the main region into the &a'List<Ship> parameter, biggestShipName:
This locks down the entire main region. Nobody can modify it.
Because of this, the compiler is free to do much more powerful optimizations. 0
The compiler also keeps track of all references' regions, and makes sure that no reference outlive their region.
struct Ship {
name str;
size int;
}
func biggestShipName<a' ro>(
ships &a'List<Ship>)
str {
biggest = ships.0;
foreach ship in ships {
if (ship.size > biggest.size) {
set biggest = ship;
}
}
return biggest.name;
}
exported func main() {
allShips = List(
Ship("Serenity", 9),
Ship("Raza", 7));
n = biggestShipName(allShips);
println(n);
}
Serenity
A mutex is a special wrapper around a region that can be opened by someone for reading and writing, or opened by multiple people for only reading.
We can make a mutexed region with the Mutex function.
It accepts a regioned object. A regioned object is an object where all of its (directly or indirectly) owned objects refer only to each other and are referred to only by each other.
Here, we're making a regioned object with a region call, the a' makeGame().
Mutex will take the regioned object, and wrap it in a Mutex object.
We can open the mutex for reading via its rlock method, which will return a RLock object, which contains a read-only reference to our regioned object.
There's a corresponding rwlock method for read-write access.
The compiler keeps track of all references' regions, and makes sure that no references outlive their lock.
struct Game {
bases List<Base>;
ships List<Ship>;
}
struct Base { ... }
struct Ship {
name str;
from &Base;
}
func makeGame() Game {
bases List<Base> = …;
ships = List(
Ship("Serenity", bases.0),
Ship("Raza", bases.1));
return Game(bases, ships);
}
exported func main() {
mutex = Mutex(a' makeGame());
// Open for reading
roLock = mutex.rlock();
roGame = lock.root;
println(roGame.ships.0.name);
drop(roLock);
// Open for writing
rwLock = mutex.rwlock();
rwGame = lock.root;
set rwGame.ships.0.from =
rwGame.bases.0;
drop(rwLock);
}
One can establish a pure region inside another function with a pure block.
struct Ship { fuel int; }
func addAndPrint(ships List<Ship>) {
ships.add(Ship(10));
pure block {
foreach ship in ships {
println(ship.fuel);
}
// Cannot ships.add(Ship(15)) here.
}
}
Inside the pure block, we cannot mutate any pre-existing data, but any accesses to that data are completely free and have zero overhead.
One region cannot "open" another to gain access to it. Rather, we make a new region that has access to them both.
Even in the above example, when we opened the mutex (using rlock or rwlock, we were creating a temporary new region.
Making constraint references and weak references into read-only regions are completely free; the compiler optimizes them into raw pointers.
We can move an object to another region by putting the destination region's lifetime annotation (a') in front of the data.
We call this transmigrating.
Here we're transmigrating a Drawable from region a' to region b'.
Notice how we put the destination region b' in front of the object baseInA.
Transmigrating has a cost though: Vale must turn the object into a regioned object first!
To turn an object into a regioned object, it must secede, which means it must sever any references between things it indirectly owns ("descendants"), and everything outside ("outsiders").
Specifically, it:
This can be costly. 1
Luckily, we can avoid this cost!
struct Vec2 { x float; y float; }
// Represents a quadratic curve
struct Curve {
origin Vec2;
scale Vec2;
}
func getYForX(curve &Curve, x float) {
origin.y + scale.y * pow(
x * scale.x + origin.x, 2)
}
struct Drawable {
segments List<Vec2>;
}
func segmentify(curve &Curve) Drawable {
results = List<Vec2>();
foreach x in range(0, 10) {
y = curve.getYForX(x);
results.add(Vec2(x, y));
}
return Drawable(results);
}
func segmentifyAndSend<a', b'>(
curve &a'Curve,
drawables &b'List<Drawable>) {
// Assembles Drawable
drawableInA = segmentify(curve);
// Transmigrate it to region b'
drawableInB = b' drawableInA;
// Can now add it to drawables
drawables.add(drawableInB);
}
The cost depends on various factors:
There are three ways to avoid secede costs:
To illustrate that last point, we can change segmentify.
Notice how segmentify now accepts its parameter from a different region a'.
Because of this, segmentify gets its own region. It's making its Vec2s and Drawable in this new region.
Because the return isn't a', the compiler knows that Drawable is a regioned object.
Now, segmentifyAndSend doesn't cause a secede!
func segmentify<a'>(
curve &a'Curve) Drawable {
results = List<Vec2>();
foreach x in range(0, 10) {
y = curve.getYForX(x);
results.add(Vec2(x, y));
}
return Drawable(results);
}
func segmentifyAndSend<a', b'>(
curve &a'Curve,
drawables &b'List<Drawable>) {
// Assembles Drawable
drawableInA = segmentify(curve);
// No secede, because drawableInA
// is a regioned object.
drawableInB = b' drawableInA;
// Can now add it to drawables
drawables.add(drawableInB);
}
With the bump keyword on a region declaration, all the region's allocations will use a bump allocator. This is especially useful for calling pure functions which have short lifetimes. 2
Regions are useful in many situations:
More explanation soon, stay tuned or swing by our discord for more information!
Next: FFI: Externs and Exports