Chapter 19: Memory Safety and Resource Management
What you'll learn: how long values and objects live in a Loup script, how the ownership verbs (reads, edits, takes, shares) and modifier/safe control who may change what, what unique adds, how to release files and other resources on time with with, try/finally and del, and which of these features the 1.25 toolchain does not fully implement yet.
You never free memory in a Loup script. Every mode, scripts included, uses the same memory model (Language Reference §1-8.1.1): a class instance lives as long as something refers to it, with the semantics of automatic reference counting (ARC), while structs, enums, tuples and collections are values that are copied. There is no null, no pointer arithmetic, and every index is checked.
That still leaves plenty of ways for a script to go wrong: a function that quietly changes a list its caller still uses, an object used after it was handed off, a lock file left behind when a step throws. This chapter is about those, and about the tools Loup gives you for them.
The Safety Story at a Glance
| Concern | Loup's answer | In Loup 1.25 |
|---|---|---|
| Use after free | An object lives while it is referenced | ✓ |
| Null | Option[T]; there is no null |
✓ checked at compile time |
| Out-of-range index, missing key | IndexError, KeyError at run time |
✓ |
| Integer overflow | NumericOverflowError |
✓ for int8…int32; ⚠ int / int64 wrap |
| A callee changing your data | Parameters are read-only unless declared edits; call sites acknowledge edits:, takes:, shares: |
✓ |
| Using a value after handing it off | takes, unique let |
✓ |
| Unsafe mutation of shared objects | modifier methods, safe {}, safe func |
✓ (the alias checks inside safe are not implemented yet) |
| Leaked files, locks, temp files | with + Closeable / Scoped[T], try/finally, del + finalize() |
✓; ⚠ defer does not run, finalize() runs only on del |
| Reference cycles | weak fields |
⚠ accepted, but weak references are never cleared |
| HTML injection | the escape filter in template fences |
⚠ html fences do not escape automatically |
| Check-then-act races in async code | atomic {} |
✓ checked at compile time |
The ⚠ rows are explained where they come up.
Values and References
Collections, structs, enums and tuples are values: assigning one to another binding, or storing it, makes an independent copy. Class instances are references: two bindings can name the same object and see each other's changes.
@mode(host.scripted)
struct Point {
x: int;
var y: int;
}
class Account {
pub let owner: string;
pub var balance: int = 0;
new(owner: string) {
this.owner = owner;
}
}
# Collections are values: assignment copies.
var original = ["draft"];
var copy = original;
copy.push("final");
println(original);
println(copy);
# Structs are values too.
var p = Point(x = 1, y = 2);
var q = p;
q.y = 20;
println(i"p.y = {$ p.y $}, q.y = {$ q.y $}");
# Class instances are references: both names see one object.
let mine = Account("ada");
let alsoMine = mine;
safe {
alsoMine.balance = 50;
}
println(i"mine.balance = {$ mine.balance $}");
Listing 19-1: Values are copied; class instances are shared
["draft"]
["draft", "final"]
p.y = 2, q.y = 20
mine.balance = 50
The safe { … } around the write to alsoMine.balance is required: writing a field of a shared object is a mutation the compiler wants to see marked. "Mutating Objects: modifier and safe" below explains why.
Because only class instances are shared, most aliasing questions in Loup are about classes, and about the collections you pass to functions.
No Null: Option[T]
A value of type string is always a string. A function that may have nothing to return says so with Option[string], and the compiler makes the caller deal with None:
@mode(host.scripted)
## The value after `key=` in a line such as `user=ada`, if the key matches.
func valueFor(line: string, key: string): Option[string] {
let prefix = key + "=";
if (line.startsWith(prefix)) {
return Some(line.substring(prefix.length(), line.length()));
}
return None;
}
let user: string = valueFor("user=ada", "user");
println(user);
main.lpscr:12:1: error[E0001]: cannot assign 'Option[string]' to binding 'user' of type 'string'; unwrap the Option before use
Supply a default with ??, or handle both cases with match:
@mode(host.scripted)
let user: string = valueFor("user=ada", "user") ?? "anonymous";
println(user);
match valueFor("port=8080", "host") {
Some(host) => println(i"host is {$ host $}"),
None => println("no host configured"),
}
Listing 19-2: Handling an Option with ?? and match
ada
no host configured
opt.unwrap() also gets the value out, but throws UnwrapError on None; use it only where None would be a bug. Chapters 7 and 10 cover Option and Result in depth.
Checked Indexing and Missing Keys
Every index is checked. Reading past the end of a list throws IndexError (Chapter 1 showed one), and reading a missing key with d[key] throws KeyError:
@mode(host.scripted)
let settings: dict[string, string] = {"host": "localhost", "port": "8080"};
println(settings["host"]);
println(settings["user"]);
localhost
Uncaught KeyError: key 'user' not found in dict
at <main> (main.lpscr:5:9)
When a key may legitimately be absent, use get, which returns an Option:
@mode(host.scripted)
let settings: dict[string, string] = {"host": "localhost", "port": "8080"};
println(settings["host"]);
println(settings.get("user") ?? "anonymous");
localhost
anonymous
The subscript is for keys that must be there; get is for keys that might not be. For lists, xs.get(i) is the same as xs[i] and throws too (§1-4.1.5), so check i < xs.length() first when an index can be out of range.
Integer Overflow
The sized integer types int8, int16, int32 (and their unsigned twins) check every operation. A result that does not fit throws NumericOverflowError (§1-4.1.1):
@mode(host.scripted)
let samples: int32 = 2000000000;
let doubled: int32 = samples * 2;
println(doubled);
Uncaught NumericOverflowError: arithmetic overflow: 4000000000 does not fit int32
at <main> (main.lpscr:4:22)
The Language Reference says int (64-bit) is checked the same way. In Loup 1.25 it is not: int and int64 arithmetic wraps around silently. This listing is wrong, and nothing tells you:
@mode(host.scripted)
let bytesPerBlock = 4096;
let blocks = 3000000000000000;
let total = bytesPerBlock * blocks;
println(i"total bytes: {$ total $}");
total bytes: -6158744073709551616
Until int overflow is checked, guard the arithmetic yourself where inputs can be large. The spec's checkedAdd-style helpers are not available yet either, so write the check:
@mode(host.scripted)
const INT_MAX = 9223372036854775807;
## a * b for non-negative a and b, or None if the product does not fit in an int.
func checkedMul(a: int, b: int): Option[int] {
if (a != 0 and b > INT_MAX // a) {
return None;
}
return Some(a * b);
}
let bytesPerBlock = 4096;
match checkedMul(bytesPerBlock, 3000000000000000) {
Some(total) => println(i"total bytes: {$ total $}"),
None => println("too large: the disk size does not fit in an int"),
}
match checkedMul(bytesPerBlock, 1000) {
Some(total) => println(i"total bytes: {$ total $}"),
None => println("too large"),
}
Listing 19-3: Guarding a multiplication that could overflow int
too large: the disk size does not fit in an int
total bytes: 4096000
The Ownership Verbs
A function parameter says what the function will do with the argument (Language Reference §3-2.4.9). The verb goes before the parameter name:
| Verb | Meaning | Call site |
|---|---|---|
reads (the default) |
looks at the value, does not change it | f(x) |
edits |
changes the caller's value in place | f(edits: x) |
takes |
takes ownership; the caller may not use the value again | f(takes: x) |
shares |
keeps a reference after the call returns | f(shares: x) |
The verbs work in every mode. In a script they are not about freeing memory; they make every change to your data visible at the call site.
reads is the default
A parameter without a verb is read-only. A function that tries to change it does not compile:
@mode(host.scripted)
func addDefaultTags(tags: list[string]): void {
if (not tags.contains("untriaged")) {
tags.push("untriaged");
}
}
var tags = ["bug"];
addDefaultTags(tags);
println(tags);
main.lpscr:5:9: error[E_READS_MUTATION]: E_READS_MUTATION: parameter 'tags' is a `reads` borrow (a parameter without a verb is read-only, §3-2.4.9) and cannot be mutated (`push` called on it); declare it `edits` and pass `edits: …` to change the caller's value, or copy it into a local first
The message offers two fixes, and they mean different things. If the function's job is to change the caller's list, declare the parameter edits. Do only that, and the call site is rejected next:
@mode(host.scripted)
func addDefaultTags(edits tags: list[string]): void {
if (not tags.contains("untriaged")) {
tags.push("untriaged");
}
}
var tags = ["bug"];
addDefaultTags(tags);
println(tags);
main.lpscr:10:16: error[E_VERB_REQUIRED]: E_VERB_REQUIRED: parameter 'tags' requires 'edits' verb acknowledgment at call site
The caller has to agree, in writing, that its list will change:
@mode(host.scripted)
func addDefaultTags(edits tags: list[string]): void {
if (not tags.contains("untriaged")) {
tags.push("untriaged");
}
}
## Reads only: the parameter needs no verb.
func summary(tags: list[string]): string {
return tags.join(", ");
}
var tags = ["bug"];
addDefaultTags(edits: tags);
println(summary(tags));
Listing 19-4: An edits parameter and its call-site acknowledgment
bug, untriaged
The acknowledgment must name the same verb as the declaration. addDefaultTags(takes: tags) here is E_VERB_MISMATCH: call-site verb 'takes' does not match parameter 'tags' declared as 'edits'.
The other fix — "copy it into a local first" — is right when the caller's list should stay as it was. Assigning a list copies it, so the function can change its copy and return it:
@mode(host.scripted)
## Returns the tags with the defaults added; the caller's list is untouched.
func withDefaultTags(tags: list[string]): list[string] {
var result = tags;
if (not result.contains("untriaged")) {
result.push("untriaged");
}
return result;
}
let tags = ["bug"];
let labelled = withDefaultTags(tags);
println(tags);
println(labelled);
["bug"]
["bug", "untriaged"]
takes: handing a value off
A takes parameter consumes the argument: after the call, the caller's binding is gone. Use it when the callee becomes the owner — a report handed to a mailer, a job handed to a queue:
@mode(host.scripted)
class Report {
pub let title: string;
pub var lines: list[string] = [];
new(title: string) {
this.title = title;
}
}
## Hands the report to the mailer; the caller must not touch it afterwards.
func send(takes report: Report): void {
println(i"sending '{$ report.title $}' ({$ report.lines.length() $} lines)");
}
let weekly = Report("Weekly status");
send(takes: weekly);
println(weekly.title);
main.lpscr:19:9: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'weekly' was consumed by a `takes` parameter and cannot be used again
If you still need something from the value, take it before the hand-off:
@mode(host.scripted)
let weekly = Report("Weekly status");
let title = weekly.title;
send(takes: weekly);
println(i"sent: {$ title $}");
Listing 19-5: Reading what you need before a takes call
sending 'Weekly status' (0 lines)
sent: Weekly status
The compiler also catches a hand-off inside a loop, where the second iteration would reuse a value the first one gave away:
@mode(host.scripted)
class Report {
pub let title: string;
new(title: string) { this.title = title; }
}
func send(takes report: Report): void {
println(i"sending '{$ report.title $}'");
}
let draft = Report("Draft");
for (recipient in ["ada", "grace"]) {
send(takes: draft);
}
main.lpscr:14:17: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'draft' was consumed by a `takes` parameter in a previous iteration of the loop and cannot be used again
Give each iteration its own value:
@mode(host.scripted)
for (recipient in ["ada", "grace"]) {
let copy = Report(i"Draft for {$ recipient $}");
send(takes: copy);
}
sending 'Draft for ada'
sending 'Draft for grace'
A method can consume its own receiver: write the verb before func. A takes func close() makes the object unusable once it is closed:
@mode(host.scripted)
class Connection {
pub let host: string;
var sent: int = 0;
new(host: string) {
this.host = host;
}
pub modifier func send(message: string): void {
this.sent = this.sent + 1;
println(i"-> {$ this.host $}: {$ message $}");
}
## Closing consumes the connection.
pub takes func close(): int {
println(i"closing {$ this.host $}");
return this.sent;
}
}
let conn = Connection("db.local");
safe {
conn.send("BEGIN");
conn.send("COMMIT");
}
let count = conn.close();
safe {
conn.send("ROLLBACK");
}
main.lpscr:30:5: error[E_USE_AFTER_TAKE]: E_USE_AFTER_TAKE: binding 'conn' was consumed by a `takes` parameter and cannot be used again
Sending on a closed connection is a run-time error in most languages; here it does not compile. Remove the late send, and the program runs:
@mode(host.scripted)
let conn = Connection("db.local");
safe {
conn.send("BEGIN");
conn.send("COMMIT");
}
let count = conn.close();
println(i"{$ count $} messages sent");
Listing 19-6: A takes receiver makes "use after close" a compile error
-> db.local: BEGIN
-> db.local: COMMIT
closing db.local
2 messages sent
shares: keeping a reference
A shares parameter tells the caller that the callee will keep the object after the call returns — a registry, a cache, an observer list. The call site acknowledges it with shares:; leaving it out is E_VERB_REQUIRED, as with edits:
@mode(host.scripted)
class Counter {
pub var hits: int = 0;
pub modifier func bump(): void {
this.hits = this.hits + 1;
}
}
class Registry {
var counters: list[Counter] = [];
pub modifier func add(shares c: Counter): void {
this.counters.push(c);
}
pub func total(): int {
var t = 0;
for (c in this.counters) {
t = t + c.hits;
}
return t;
}
}
let registry = Registry();
let pageViews = Counter();
safe {
registry.add(shares: pageViews);
pageViews.bump();
pageViews.bump();
}
println(i"registry total: {$ registry.total() $}");
Listing 19-7: shares — the registry keeps the counter
registry total: 2
The registry and pageViews refer to the same counter, so the bumps are visible through both.
Mutating Objects: modifier and safe
Because class instances are shared, Loup asks for two marks wherever one is changed (§3-2.4.11, §3-2.4.12):
- A method that writes one of the object's
varfields is declaredmodifier. - A call to a
modifiermethod, or a direct write to apub varfield, happens inside a safe context: asafe { … }block or asafe func.
Here is a rate limiter written the way you would write it in Python or TypeScript:
@mode(host.scripted)
class RateLimiter {
pub let limit: int;
var used: int = 0;
new(limit: int) {
this.limit = limit;
}
pub func tryAcquire(): bool {
if (this.used >= this.limit) {
return false;
}
this.used = this.used + 1;
return true;
}
}
main.lpscr:15:9: error[E_MODIFIER_REQUIRED]: E_MODIFIER_REQUIRED: writing to `var` field 'used' of `self` in method 'tryAcquire' of class 'RateLimiter' requires the method to be declared as `modifier`
tryAcquire changes the limiter, so its signature must say so. Add modifier and call it:
@mode(host.scripted)
class RateLimiter {
pub let limit: int;
var used: int = 0;
new(limit: int) {
this.limit = limit;
}
pub modifier func tryAcquire(): bool {
if (this.used >= this.limit) {
return false;
}
this.used = this.used + 1;
return true;
}
}
let limiter = RateLimiter(2);
for (request in ["a", "b", "c"]) {
println(i"{$ request $}: {$ limiter.tryAcquire() $}");
}
main.lpscr:22:33: error[E_SAFE_CONTEXT_REQUIRED]: E_SAFE_CONTEXT_REQUIRED: `modifier` method 'tryAcquire' called on ARC receiver of type 'RateLimiter' outside `safe {}` or `safe func` context — wrap the call in `safe { ... }` or mark the enclosing function as `safe func`
Mark the call site with safe { … }, or give a whole function a safe context with safe func:
@mode(host.scripted)
let limiter = RateLimiter(2);
for (request in ["a", "b", "c"]) {
safe {
println(i"{$ request $}: {$ limiter.tryAcquire() $}");
}
}
## A `safe func` provides the safe context for its whole body.
safe func drain(edits l: RateLimiter): int {
var granted = 0;
while (l.tryAcquire()) {
granted = granted + 1;
}
return granted;
}
let fresh = RateLimiter(5);
safe {
let granted = drain(edits: fresh);
println(i"granted {$ granted $} more");
}
Listing 19-8: A modifier method called from safe {} and from a safe func
a: true
b: true
c: false
granted 5 more
A safe func is itself called from a safe context, the way an async func is awaited: the requirement travels up to the caller until a safe { … } block anchors it.
Structs do not use modifier: a struct is a value, so changing your copy cannot affect anyone else.
⚠ Planned. Inside a safe context the Language Reference also requires the compiler to reject a mutation while another reference to the same object is live (
E_SAFE_ALIAS_MUTABLE), and to reject passing one value as twoeditsarguments (E_EDITS_ALIAS). Loup 1.25 does not check either yet, and it does not yet reject asafe funccalled outside a safe context. The spec also says a function with aneditsparameter is implicitly asafe func; the 1.25 compiler still asks you to writesafe func, as Listing 19-8 does.
unique: One Owner at a Time
unique let declares a binding that is the only reference to its object. Assigning it to another binding moves the object, and the old name can no longer be used (§3-2.4.7):
@mode(host.scripted)
class Upload {
pub let path: string;
new(path: string) {
this.path = path;
}
}
unique let pending = Upload("photos.zip");
let active = pending;
println(i"uploading {$ pending.path $}");
main.lpscr:12:24: error[E_USE_AFTER_MOVE]: E_USE_AFTER_MOVE: binding 'pending' was moved and cannot be used again — `unique class` instances and move-only structs have single ownership
After the move, use the new name:
@mode(host.scripted)
unique let pending = Upload("photos.zip");
let active = pending;
println(i"uploading {$ active.path $}");
uploading photos.zip
A unique class makes every instance of a type uniquely owned. That is a native feature: in a script the declaration itself is rejected.
@mode(host.scripted)
unique class FileLock {
pub let path: string;
new(path: string) {
this.path = path;
}
}
let lock = FileLock("build.lock");
println(lock.path);
main.lpscr:3:1: error[E_UNIQUE_CLASS_ARC_BINDING]: E_UNIQUE_CLASS_ARC_BINDING: `unique class 'FileLock'` is only valid in `@mode(native)` or `@mode(freestanding)`
(The message still uses the retired name native; the mode is host.native.) In a script, declare an ordinary class and put unique on the binding instead — unique let lock = FileLock("build.lock");. If the type really must never be shared, the file belongs in host.native; The Loup Native Book covers unique class, consume, borrow and lifetimes.
Reference Cycles and weak
Under ARC, two objects that refer to each other keep each other alive. The usual case is a tree whose children point back to their parent. Make the back-pointer weak: a weak field does not keep its object alive, and it is always an Option (§1-8.6).
@mode(host.scripted)
class Folder {
pub let name: string;
pub var children: list[Folder] = [];
pub weak var parent: Folder?;
new(name: string) {
this.name = name;
}
pub func path(): string {
return match this.parent {
Some(p) => p.path() + "/" + this.name,
None => this.name,
};
}
}
let home = Folder("home");
let docs = Folder("docs");
safe {
docs.parent = home;
home.children.push(docs);
}
println(docs.path());
main.lpscr:23:5: error[E0001]: cannot assign 'Folder' to 'Option[Folder]'
Folder? is Option[Folder], so the compiler wants an Option. (The Language Reference says assigning a plain object to a weak field wraps it in Some automatically; the 1.25 compiler does not.) Wrap it:
@mode(host.scripted)
class Folder {
pub let name: string;
pub var children: list[Folder] = [];
pub weak var parent: Folder?;
new(name: string) {
this.name = name;
}
pub func path(): string {
return match this.parent {
Some(p) => p.path() + "/" + this.name,
None => this.name,
};
}
}
let home = Folder("home");
let docs = Folder("docs");
safe {
docs.parent = Some(home);
home.children.push(docs);
}
println(docs.path());
Listing 19-9: A tree with a weak parent link
home/docs
⚠ Not yet effective. In Loup 1.25 a weak reference is never cleared:
loup runkeeps readingSome(…)from a weak field even after the last strong reference to the object is gone, and native builds currently store weak fields as strong references. Writeweakwhere the design calls for it — it documents ownership and will behave as specified once release is implemented — but do not write code that waits for a weak field to becomeNone.
Closures capture outer variables by reference: a closure sees later changes to a var it uses, and assigning one needs a nonlocal or global declaration (Chapter 3). Capture lists such as [weak self] are for native builds; loup run rejects them in every mode ("capture lists are only allowed in @compiled files targeting --target native").
Releasing Resources
Memory takes care of itself; files, lock files, sockets and child processes do not. The rule is the same as in Python or C#: release a resource in code that runs on every path out of the block, including when an exception is thrown.
try/finally
The most basic tool is finally, which runs whether the try block finishes, returns or throws:
@mode(host.scripted)
from io.fs import (remove);
## Runs `step` while holding a lock file, and always removes the lock.
func withLockFile(path: string, step: fn(): void): void {
if (exists(path)) {
throw RuntimeError(i"{$ path $} exists: another run is in progress");
}
writeText(path, "locked\n");
try {
step();
} finally {
remove(path);
}
}
withLockFile("deploy.lock", func(): void {
println("deploying...");
});
println(i"lock left behind: {$ exists("deploy.lock") $}");
try {
withLockFile("deploy.lock", func(): void {
throw RuntimeError("upload failed");
});
} catch (RuntimeError as e) {
println(i"failed: {$ e.message $}");
}
println(i"lock left behind: {$ exists("deploy.lock") $}");
Listing 19-10: A lock file removed on success and on failure
deploying...
lock left behind: false
failed: upload failed
lock left behind: false
The Language Reference (§1-6.5) also has defer expr; and defer { … }, which run when the enclosing block exits, in reverse order. In Loup 1.25, defer does not run. The compiler accepts and type-checks it, and loup run silently skips the deferred action:
@mode(host.scripted)
func publish(): void {
println("acquire lock");
defer println("release lock");
println("publishing");
}
publish();
println("done");
acquire lock
publishing
done
Under the specification, release lock would print before done. Until defer is implemented, write the cleanup in a finally block, as Listing 19-10 does, or use with.
with and Closeable
When a resource is an object, let the object clean up after itself. Chapter 4 introduced with; this section looks at how a class takes part in it. A with statement (§1-6.6) binds a resource for the duration of a block and calls its exit hook on every way out of the block. The simplest way to get the hook is to implement the prelude interface Closeable, whose one required method is close().
Having a close() method is not enough on its own:
@mode(host.scripted)
## Collects lines in memory and writes them to a file when closed.
class LineWriter {
let path: string;
var lines: list[string] = [];
new(path: string) {
this.path = path;
}
pub modifier func write(line: string): void {
this.lines.push(line);
}
pub func close(): void {
writeText(this.path, this.lines.join("\n") + "\n");
}
}
with (out = LineWriter("report.txt")) {
safe {
out.write("total: 3");
}
}
main.lpscr:21:1: error[E0001]: E_WITH_NOT_SCOPED: type 'LineWriter' does not implement Scoped[T] — a `with` resource must provide scope_enter() and scope_exit(error) (Closeable and Releasable do)
main.lpscr:21:1: error[E0001]: E_WITH_NOT_SCOPED: type 'LineWriter' does not implement Scoped[T] — a `with` resource must provide scope_enter() and scope_exit(error) (Closeable and Releasable do)
main.lpscr:21:1: error[E0001]: E_WITH_NOT_SCOPED: type 'LineWriter' does not implement Scoped[T] — a `with` resource must provide scope_enter() and scope_exit(error) (Closeable and Releasable do)
(The 1.25 compiler prints this diagnostic three times.) with looks for the Scoped protocol, and a class gets it by declaring implements Closeable:
@mode(host.scripted)
## Collects lines in memory and writes them to a file when closed.
class LineWriter implements Closeable {
let path: string;
var lines: list[string] = [];
new(path: string) {
this.path = path;
}
pub modifier func write(line: string): void {
this.lines.push(line);
}
pub func close(): void {
writeText(this.path, this.lines.join("\n") + "\n");
println(i"wrote {$ this.lines.length() $} line(s) to {$ this.path $}");
}
}
with (out = LineWriter("report.txt")) {
safe {
out.write("total: 3");
out.write("errors: 0");
}
}
print(readText("report.txt"));
try {
with (out = LineWriter("partial.txt")) {
safe {
out.write("step 1 done");
}
throw RuntimeError("step 2 failed");
}
} catch (RuntimeError as e) {
println(i"caught: {$ e.message $}");
}
print(readText("partial.txt"));
Listing 19-11: A Closeable resource is closed when the block ends, even when it throws
wrote 2 line(s) to report.txt
total: 3
errors: 0
wrote 1 line(s) to partial.txt
caught: step 2 failed
step 1 done
The binding is written with (name = expression), without let. Several resources can share one with: with (a = …, b = …) { … } enters them left to right and closes them right to left.
Custom Scoped[T] resources
Closeable is a convenience over the general protocol, Scoped[T] (§1-11.2). A Scoped[T] class has two methods: scope_enter() runs when the block starts and returns the value bound by with — which need not be the object itself — and scope_exit(error) runs when it ends, receiving the exception if there was one. Returning false from scope_exit lets that exception continue; true suppresses it.
@mode(host.scripted)
from io.fs import (remove);
## A scratch file that exists only for the duration of a `with` block.
class ScratchFile implements Scoped[string] {
let path: string;
new(path: string) {
this.path = path;
}
## Creates the file; the `with` binding receives its path.
pub func scope_enter(): string {
writeText(this.path, "");
return this.path;
}
## Removes the file. Returning false lets an exception continue.
pub func scope_exit(error: Option[Any]): bool {
remove(this.path);
return false;
}
}
with (path = ScratchFile("sort-input.tmp")) {
writeText(path, "pear\napple\nfig\n");
let lines = readText(path).trim().split("\n");
lines.sort(func(a: string, b: string) => a.compareTo(b));
println(lines);
}
println(i"scratch file still there: {$ exists("sort-input.tmp") $}");
Listing 19-12: A Scoped[string] that hands the block a path and deletes the file afterwards
["apple", "fig", "pear"]
scratch file still there: false
del and finalize()
Some resources do not fit a block: a subscription that lives until some later event, a cache entry you drop by hand. A class can declare func finalize(): void, and del name; calls it immediately and ends the binding (§1-6.11, §1-8.9):
@mode(host.scripted)
var listeners: list[string] = [];
## Registers interest in a topic until it is finalized.
class Subscription {
pub let topic: string;
new(topic: string) {
this.topic = topic;
listeners.push(topic);
}
func finalize(): void {
listeners.remove(listeners.indexOf(this.topic));
println(i"unsubscribed from {$ this.topic $}");
}
}
let logins = Subscription("user.login");
let errors = Subscription("app.error");
println(listeners);
del logins;
println(listeners);
Listing 19-13: del runs finalize() at a point you choose
["user.login", "app.error"]
unsubscribed from user.login
["app.error"]
After del, the name is uninitialized, and the compiler rejects any later use:
@mode(host.scripted)
var listeners: list[string] = [];
## Registers interest in a topic until it is finalized.
class Subscription {
pub let topic: string;
new(topic: string) {
this.topic = topic;
listeners.push(topic);
}
func finalize(): void {
listeners.remove(listeners.indexOf(this.topic));
println(i"unsubscribed from {$ this.topic $}");
}
}
let logins = Subscription("user.login");
del logins;
println(logins.topic);
main.lpscr:22:9: error[E_USE_AFTER_DEL]: E_USE_AFTER_DEL: use of binding 'logins' after `del` — the binding is uninitialized once it has been passed to `del` (§1-6.11)
Read what you need before the del, as with takes.
⚠
finalize()runs only ondeltoday. Under the ARC rules of §1-8.1.1,finalize()also runs when the last reference to an object goes away: at the end of a scope, on reassignment, at program exit. In Loup 1.25,loup runcallsfinalize()only fordel— in Listing 19-13, theapp.errorsubscription is never finalized. Do not rely onfinalize()for cleanup that must happen; usewith,try/finallyor an explicitdel. The compiler also does not yet warn when a finalizable object goes out of scope withoutdel, or rejectdelon a value that has nofinalize()(E_DEL_NOT_FINALIZABLE).
Escaping Untrusted Text in HTML
A script that writes HTML from data it did not create — comments, file names, the output of another tool — must escape that data. The Language Reference makes html template fences escape every insertion automatically (§1-16). Loup 1.25 does not: an html fence inserts the text as it is.
@mode(host.scripted)
let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment $}</p>
```;
println(page.render({}));
<p class="comment"><script>alert('hi')</script></p>
Anyone who opens that page runs the commenter's script. Until fences escape by default, apply the escape filter to every insertion of untrusted text:
@mode(host.scripted)
let comment = "<script>alert('hi')</script>";
let page = ```(html):(none)
<p class="comment">{$ comment | escape $}</p>
```;
println(page.render({}));
Listing 19-14: The escape filter makes untrusted text safe to embed
<p class="comment"><script>alert('hi')</script></p>
Chapter 12 covers template fences and filters.
atomic {}: No Suspension in a Check-Then-Act
In async code, every await is a point where other tasks may run. A check followed by an action with an await between them can act on stale information. An atomic { … } block (runtime volume §2-1.7) is a region with no suspension points, and the compiler enforces that:
@mode(host.scripted)
var cache: dict[string, string] = {};
async func fetchProfile(user: string): Task[string] {
return i"profile of {$ user $}";
}
async func profileFor(user: string): Task[string] {
atomic {
if (not cache.contains(user)) {
cache[user] = await fetchProfile(user);
}
return cache[user];
}
}
println(await profileFor("ada"));
main.lpscr:12:27: error[E_ATOMIC_AWAIT]: E_ATOMIC_AWAIT: await expression is not allowed inside atomic {} block
Do the waiting outside the block, then check again and act inside it:
@mode(host.scripted)
var cache: dict[string, string] = {};
async func fetchProfile(user: string): Task[string] {
return i"profile of {$ user $}";
}
async func profileFor(user: string): Task[string] {
let known = cache.get(user);
if (known.is_some()) {
return known.unwrap();
}
let fetched = await fetchProfile(user);
atomic {
# Check again: another task may have filled the entry while we waited.
if (not cache.contains(user)) {
cache[user] = fetched;
}
}
return cache[user];
}
println(await profileFor("ada"));
println(await profileFor("ada"));
Listing 19-15: Awaiting outside an atomic block, checking and updating inside it
profile of ada
profile of ada
atomic {} is not a lock; it works because a script's tasks take turns on one thread. In Loup 1.25, async functions run eagerly and in order (Chapter 13), so no other task can interleave yet; writing the code this way keeps it correct when they can.
Features That Belong to Other Modes
Some memory features exist only where there is a native ARC runtime or real hardware. In a script they are compile errors:
| Feature | In host.scripted |
|---|---|
unique class |
E_UNIQUE_CLASS_ARC_BINDING (shown above) |
consume x (turn a unique binding into an ordinary reference) |
E_CONSUME_NOT_AVAILABLE: only host.native and host.dual |
Capture lists [weak self], [unowned self] |
rejected by loup run in every mode |
asm { … } |
E_ASM_NOT_COMPILED |
For example:
@mode(host.scripted)
func pause(): void {
asm { "nop" }
}
main.lpscr:4:5: error[E_ASM_NOT_COMPILED]: E_ASM_NOT_COMPILED: `asm { }` is only valid in a compiled mode (`web.compiled`, `host.native`, `freestanding`); the module mode is `host.scripted` (§3-3.7)
⚠ Planned.
arc letandarc var, which declare a binding as an explicit ARC reference (§1-6.2), are not implemented in any mode: the parser rejectsarc let b = Box();witherror[E0003]: expected ';'.
shared class (a class whose instances may cross threads) is available in scripts, and the compiler checks its rule that every field is Shareable: a string field, for example, is E_SHARED_FIELD_NOT_SHAREABLE. Constructing one with shared Stats() is not supported by loup run yet (internal interpreter limitation: unsupported instruction: NewSharedObject). Chapter 13 and the Native Book cover sharing between threads.
A Checklist for Scripts
Before you hand a script to someone else:
- Functions that can find nothing return
Option[T]; eachunwrap()is a deliberate "this cannot beNone". - Dictionary lookups that may miss use
get(key) ?? default, notd[key]. - Arithmetic on large
intvalues is guarded: in Loup 1.25,intoverflow wraps silently. - Functions that change their arguments declare
edits; functions that keep them declareshares; functions that consume them declaretakes. - Methods that change their object are
modifier, and their callers usesafe. - Every file, lock or temp file is released in
with,try/finallyor an explicitdel— never indeferor an unpromptedfinalize()in 1.25. - Every insertion of untrusted text into HTML uses
| escape.
Where the Native Book Goes Further
A script never frees memory itself, so it needs only the verbs, unique let, modifier/safe, weak and the resource tools in this chapter. In host.native, ownership also controls cost: borrow and excl borrow remove reference-count traffic, lifetimes (lifetime ~name, borrow[~name], unowned[~name]) prove that a reference does not outlive its target, and unique class and consume give single ownership to a whole type. See The Loup Native Book.
Summary
- Every mode has ARC semantics: class instances are shared references, while collections, structs, enums and tuples are values copied on assignment.
Option[T]replaces null; indexing throwsIndexErrorand a missing keyKeyError;getreturns anOption.- Sized integers throw
NumericOverflowErroron overflow, but in Loup 1.25intandint64wrap — guard large arithmetic yourself. - Parameters are read-only by default (
E_READS_MUTATION).edits,takesandsharesmust be acknowledged at the call site (E_VERB_REQUIRED,E_VERB_MISMATCH), and a value passed withtakes:cannot be used again (E_USE_AFTER_TAKE), including on a method receiver. - A method that writes a
varfield ismodifier(E_MODIFIER_REQUIRED), and calls to it needsafe {}or asafe func(E_SAFE_CONTEXT_REQUIRED). unique letmoves on assignment (E_USE_AFTER_MOVE);unique class,consume, capture lists andasmare not available in scripts, andarc letis not implemented anywhere.weakfields are typedT?and assigned withSome(…); in 1.25 they are never cleared.- Release resources with
try/finally,withandCloseableorScoped[T], ordelwithfinalize(). In Loup 1.25,deferdoes not run andfinalize()runs only ondel. htmlfences do not escape automatically yet: use| escapeon untrusted text.atomic {}rejectsawaitinside a check-then-act region (E_ATOMIC_AWAIT).