Conventions
Idiomatic Axle. Patterns the compiler, stdlib, and built-in tooling
all expect. Following them keeps your code consistent with the
stdlib examples and the axle tooling output.
Naming
| Kind | Convention | Example |
|---|---|---|
| Types (classes, traits) | PascalCase | ArrayList, IOException, Runnable |
| Free functions, methods | camelCase | parseInt, readLine, getName |
| Variables, parameters, fields | camelCase | userName, lineCount |
| Boolean queries | is… / has… / can… | isEmpty, hasNext, canRead |
| Constants (module-level) | SCREAMING_SNAKE | MAX_BUFFER_SIZE |
| Type parameters | single uppercase letter | T, K, V, E |
| Modules / files | lower_snake | text_json, core_util |
Stdlib follows mainstream OO conventions deliberately — coming from C#,
Kotlin or Swift, every name maps to its expected shape (size() not len, isEmpty() not is_empty, IOException not IoError).
File layout
A typical source file groups one logical concern :
// 1. Imports
use std::collections::ArrayList;
use std::io::File;
// 2. Top-level constants
const MAX_LINES : i32 = 1000;
// 3. Types (traits before classes that implement them)
trait Encoder {
fn encode(self, s : string) : string;
}
class Base64 : Encoder {
pub fn encode(self, s : string) : string {
return s;
}
}
// 4. Free functions
fn loadConfig(path : string) : ArrayList<string> ! IOException {
let lines : ArrayList<string> = new ArrayList<string>();
lines.add(path);
return lines;
}
// 5. Entry point (last)
fn main() : i32 {
let cfg : ArrayList<string> = loadConfig("app.conf") catch _ {
new ArrayList<string>();
};
return cfg.size() as i32;
} Ownership idioms
The default parameter mode is borrowed (no keyword). Reach for a prefix only when the contract really differs :
class Rectangle {
w : f64;
h : f64;
constructor(w : f64, h : f64) { self.w = w; self.h = h; }
}
// Borrowed — the default. The caller keeps ownership and the
// borrow cannot escape the call.
fn area(rect : Rectangle) : f64 { return rect.w * rect.h; }
// You will mutate fields of the param ; caller still owns it.
fn translate(mut rect : Rectangle, dx : f64, dy : f64) : void {
rect.w = rect.w + dx;
rect.h = rect.h + dy;
}
// You take ownership ; caller must not use it afterwards. Rare —
// reserve for explicit hand-off (e.g. into a queue / collection).
fn enqueue(own r : Rectangle) : void { }
// You take a refcounted handle. Cheap to copy (bumps the refcount),
// safe to keep across threads.
fn schedule(rect : Shared<Rectangle>) : void { } When in doubt, borrow. Promoting later (borrow → mut, borrow → own) is mechanical ; demoting (own → borrow) breaks
callers.
When to use which storage tier
Axle has four allocation tiers picked automatically by the compiler’s escape analysis :
| Tier | When the compiler picks it | When you write it explicitly |
|---|---|---|
| Stack | local that never escapes the fn | never — automatic |
| Arena | local that escapes its declaring block but not the function frame | rare — automatic in most code |
Heap (new T(…)) | escape into a return value / long-lived store | let x = new Foo(…) |
Shared (new shared T(…)) | needs to outlive a single owner or cross thread boundaries | let x = new shared Foo(…) |
The escape analysis is conservative — when it can’t prove an
allocation stays local, it leaves it on the heap rather than risk
an unsound promotion. The two you choose
explicitly are heap (new T(…)) and shared (new shared T(…)).
class Point {
x : f64;
y : f64;
constructor(x : f64, y : f64) { self.x = x; self.y = y; }
}
// The compiler picks stack — `pt` never leaves `area`.
fn area() : f64 {
let pt : Point = new Point(1.0, 2.0);
return pt.x * pt.y;
}
// The compiler picks heap — escaped through the return.
fn makePoint() : Point {
return new Point(1.0, 2.0);
}
// You pick shared — pt will be handed to multiple threads.
fn shared_point() : Shared<Point> {
return new shared Point(1.0, 2.0);
} See Memory model and Shared<T> reference counting for the lifecycle
details.
Error handling
A function’s failure set lives in its return type — : R ! E1, E2. A
function that can fail must list it there ; callers must either
propagate (declare it onward, or mark the site with ?) or catch. This
holds for every family — there is no unchecked exemption ; for a bug you
never want to surface, hard-abort with std::ffi::runtime::panicMsg.
class Config {
name : string;
constructor(name : string) { self.name = name; }
pub static fn defaults() : Config { return new Config("default"); }
}
fn readFile(path : string) : string ! IOException {
if (path.isEmpty()) { throw FileNotFoundException("no path given"); }
return "name=" + path;
}
// Declares what it can fail with — checked at compile time.
fn parseConfig(text : string) : Config ! ParseException {
if (text.isEmpty()) {
throw ParseException("config is empty");
}
return new Config(text);
}
// Caller propagates (the `!` set lists both; `?` marks each site).
fn load() : Config ! ParseException, IOException {
let text : string = readFile("/etc/app.conf")?;
return parseConfig(text)?;
}
// Caller catches — must restore a meaningful state.
fn loadOrDefault() : Config {
try {
return load();
} catch e : ParseException {
return Config::defaults();
} catch e : IOException {
return Config::defaults();
}
} Rules :
- Exceptions are exceptional — don’t use them for control flow on
the happy path. Narrow a nullable with
if (input == null)rather than reaching for exceptions: a null value is aT | nullyou must check, not aNullPointerExceptionthe language throws for you. - Always catch the most specific subtype first.
- Use
deferfor cleanup — see below. - Don’t swallow exceptions silently. If you really mean “ignore”, say so with a one-line comment.
Stdlib operations are held to the same rule — a fallible write like ArrayList.set / removeAt raises a checked IndexOutOfBoundsException you must discharge. For a single call, the inline catch e { … } form is
lighter than a full try block. Mind the trailing ; — the whole expr catch { … } is one statement, and the block must recover or exit
(an empty {} is rejected):
use std::collections::ArrayList;
fn demo() : void {
let xs : ArrayList<i32> = new ArrayList<i32>();
xs.add(10);
xs.set(0, 99) catch e { println("out of range"); }; // recover in place
xs.removeAt(3) catch e { return; }; // or exit the fn
} A fallible read is the mirror image — xs.get(i) returns T | null,
not a throw, so you unwrap it with ?? default instead of catching.
See Reading compiler errors for diagnostic format and errors/error-handling.md for patterns.
Cleanup with defer
Pair every acquisition with a defer for the release, immediately
after acquisition :
use std::io::FileInputStream;
use std::io::FileOutputStream;
use std::io::FileOpenMode;
fn copyFile(src : string, dst : string)
: void ! IOException, FileNotFoundException
{
let input : FileInputStream = new FileInputStream(src);
defer input.close(); // releases on every exit path
let output : FileOutputStream = new FileOutputStream(dst, FileOpenMode::WRITE);
defer output.close();
let b : i32 = input.read();
while (b >= 0) { output.write(b); b = input.read(); }
} defer runs when the block that registered it exits — whether the
exit is a return, an uncaught throw, a break / continue out of it,
or plain fall-through. Written at the top level of a function body, that
is function exit. Defers fire in LIFO order, so defer input.close() followed by defer output.close() closes output first, then input.
The same LIFO order interleaves your defers with the compiler’s own
scope cleanup, and that is what makes the idiom above safe: a defer written under a declaration runs before that binding is torn down, so close() sees a live object rather than one whose buffers have already
gone back to the allocator.
A defer written inside a nested if / for / { … } block fires at
the end of that block, where the resource it names is still in scope.
Cleanup that has to outlive the block belongs beside the acquisition,
one level up.
Concurrency idioms
Shared<T> for lifetime, synchronized for access. The combination
is the safe baseline ; reach for atomics only when the access is a
single primitive operation :
class Counter {
pub value : i32;
constructor() {
self.value = 0;
}
}
fn worker(c : Shared<Counter>) : i32 {
synchronized (c) { c.value = c.value + 1; }
return 0;
}
fn main() : i32 ! InterruptedException {
let c : Shared<Counter> = new shared Counter();
let f1 : Task<i32> = spawn worker(c);
let f2 : Task<i32> = spawn worker(c);
f1.join();
f2.join();
return c.value; // 2
} A Shared<T> is used through its handle exactly as the object is used
directly: its fields read and write (c.value), and its methods are
called on the handle (c.bump()) — a self or mut self method borrows the
object for the call while the handle keeps it alive. Only an own self method
is refused (E0504), since it would take the object away from every other
handle. The examples here keep the state in fields and the operations as free
functions, so the reader sees a plain field read rather than a receiver
conversion.
The lock is just an object — its identity keys the per-pointer
reentrant mutex inside the runtime. A common pattern is to lock on
the same Shared<T> you’re mutating, so the lock travels with the
data. spawn fn(args) packs the args struct into a thunk that the
worker thread runs ; no lambda machinery needed for this shape.
For single-primitive shared state, an atomic class is one instruction instead of a lock dance :
use std::concurrent::AtomicI32;
fn main() : i32 {
let counter : AtomicI32 = new AtomicI32(0);
counter.incrementAndGet(); // thread-safe, lock-free; returns the new value
return counter.get();
} See concurrency/multithreading.md for the full set of patterns.
Strings
Strings are immutable UTF-8. string is a value type — passing
one to a fn copies a handle, not the bytes.
== / != on strings compare content — two distinct heap
strings with equal bytes are equal. .equals(...) / .equalsIgnoreCase(...) remain available (the latter for
case-insensitive comparison).
use std::text::StringBuilder;
fn main() : i32 {
let name : string = "Ada";
// Concatenation — left-to-right
let greeting : string = "Hello, " + name + "!";
println(greeting);
// Building large strings — use StringBuilder, not iterative concat.
let sb : StringBuilder = new StringBuilder();
for (i of 0..100) {
sb.append("line ");
sb.appendI32(i);
sb.append("\n");
}
let result : string = sb.toString();
println(result);
return 0;
} Iterative += on strings allocates a new string each time
(O(n²) over n iterations). StringBuilder is O(n).
Classes — order of declarations
Inside a class :
enum Role {
ADMIN,
USER,
}
class User {
// 1. Fields (immutable refs / data first, mutable state after)
id : i64;
name : string;
role : Role;
// 2. Constructor(s)
constructor(id : i64, name : string, own role : Role) {
self.id = id;
self.name = name;
self.role = role;
}
// 3. Query methods (no side effects)
pub fn isAdmin(self) : bool {
return self.role == Role::ADMIN;
}
// 4. Mutators
pub fn rename(mut self, newName : string) : void {
self.name = newName;
}
} Prefer an enum over a string for a closed set of states — the
compiler then rejects typos at compile time instead of letting "amdin" slip through to runtime.
Keep classes focused — one responsibility. If a class has more than 5–7 fields or 10–15 methods, split it.
Modules and use
// Group `use` declarations at the top, one per line.
use std::collections::ArrayList; // a TYPE — import by name
use std::collections::HashMap; // a TYPE — import by name
use std::io::File; // a TYPE — import by name
use std::text; // a MODULE — for its free functions Import a type by its bare name; import a module for its free functions. The two forms are not interchangeable — pick by what you’re reaching for:
| Reaching for… | Write | Call site |
|---|---|---|
| a type (class / trait / exception) | use std::collections::ArrayList; | new ArrayList<i32>() |
| a module’s free functions | use std::text; | text::f64ToString(x) |
Prefer importing the module for its free functions (use std::text;) and
calling text::f64ToString(...) — the module:: prefix documents the origin
at every call site, and one use line then covers the whole module’s surface.
Importing a single free function (use std::text::f64ToString;) is accepted by
the compiler, and is the natural form when a file reaches for exactly one
symbol. Wildcard imports (use std::collections::*;) are not supported —
spell every type name out. Greps for callers are faster and the diff says
exactly what each file pulls in.
Test programs
Axle has no separate unit-test harness ; the working convention is one small program per behaviour, returning its verdict through the process exit code :
// Checks compound assignment on locals.
fn main() : i32 {
let x : i32 = 40;
x += 2;
if (x == 42) { return 0; } // 0 = pass
return 1; // non-zero = which check failed
} Conventions :
- One behaviour per file, named after it (
compound_assign.axle). return 0means “passed” ; distinct non-zero codes identify which assertion failed.- A one-line
//description at the top says what’s being exercised. - Each file is a standalone program — build and run it directly :
axle build tests/compound_assign.axle -o /tmp/t && /tmp/t.
See also
- Language tour — the syntax reference.
- Memory model — escape analysis + tiers.
Shared<T>reference counting — refcount lifecycle.- Reading compiler errors — diagnostic format.
- Recipes — task-oriented examples.
- Concept index — every concept on one page.