Hello World & Building
Hello, World
fmt.println is a procedure, not a macro — Odin has no macro system, so it takes variadic ..any arguments and formats at runtime.fn main() {
println!("Hello, World!");
} package main
import "core:fmt"
main :: proc() {
fmt.println("Hello, World!")
} Rust needs
println! to be a macro so it can typecheck the format string at compile time. Odin gives that up, and has nothing to get it back with — the first of many deliberate omissions on this page.No Cargo
Odin has no package manager, no lockfile, no version resolution, and no central registry. Dependencies are vendored into your source tree and imported by relative path;
core: and vendor: ship with the compiler.// Cargo does dependency resolution, building, testing,
// benchmarking, docs, and publishing:
// cargo new project
// cargo add serde
// cargo build --release
// cargo test
// cargo doc --open
// cargo publish
fn main() {
println!("crates.io has well over 100,000 crates");
} // There is no Cargo, and no crates.io equivalent.
// odin build . compile a directory
// odin test . run the test procedures
// odin run . -o:speed optimized
// Dependencies are VENDORED — you copy the source into your
// tree, or add it as a git submodule, and import by path:
// import mylib "shared/mylib"
package main
import "core:fmt"
main :: proc() {
fmt.println("core: and vendor: ship with the compiler")
} This is the biggest genuine loss, and it should inform whether you use Odin at all. For the game and graphics work Odin targets — short dependency lists, mostly C libraries, already vendored — it hurts less than it sounds, and
vendor: covers SDL, raylib, and OpenGL. If your Rust workflow leans on crates.io, no amount of language design compensates.Compilation model
Odin compiles a whole directory in one pass, with no borrow checker, no trait resolution, no macro expansion, and no crate graph.
-vet opts into extra static checks such as unused variables and shadowing.// Rust compiles per crate, monomorphizes generics, and runs
// a borrow-check pass plus heavy LLVM optimization. A clean
// release build of a mid-sized project is minutes.
// cargo build debug
// cargo build --release optimized
// cargo check typecheck only, much faster
fn main() {
println!("incremental compilation helps, but it is still slow");
} // Odin compiles a whole directory in one pass with no borrow
// checker and a much smaller frontend. Whole-project builds of
// tens of thousands of lines are typically well under a second.
// odin build .
// odin build . -o:speed
// odin build . -vet extra static checks
package main
import "core:fmt"
main :: proc() {
fmt.println("fast enough that you stop thinking about it")
} This is an ergonomic difference rather than a benchmark point: a sub-second edit-compile-run loop changes how you work, especially for the graphics and gameplay iteration Odin is aimed at.
Variables & Types
Mutability
Odin has no
mut keyword — every local is mutable. The only true immutability is ::, a compile-time constant with no storage.fn main() {
let immutable = 1;
let mut counter = 0;
counter += 1;
// immutable = 2; // rejected: not declared mut
println!("{immutable} {counter}");
} package main
import "core:fmt"
main :: proc() {
// Locals are ALWAYS mutable — there is no `mut` keyword
counter := 0
counter += 1
// Immutability at the value level means a compile-time constant
IMMUTABLE :: 1
fmt.println(IMMUTABLE, counter)
} Odin does not track mutability in the type system at all, so
&mut T versus &T has no counterpart. Procedure parameters are the exception and are immutable, which is the reverse of Rust, where a parameter can be declared mut.Shadowing
Re-declaring a name in the same scope is a compile error in Odin — "Redeclaration of 'x' in this scope". Shadowing inside a nested scope is still allowed, as the braces below show.
fn main() {
let value = "42";
// Rust allows re-binding the SAME name, even at a new type
let value: i32 = value.parse().unwrap();
let value = value * 2;
println!("{value}");
} package main
import "core:fmt"
import "core:strconv"
main :: proc() {
text := "42"
// value := ...; value := ... is "Redeclaration in this scope"
parsed, _ := strconv.parse_int(text)
doubled := parsed * 2
// Inner SCOPES may shadow, however
{
doubled := 999
fmt.println("inner:", doubled)
}
fmt.println("outer:", doubled)
} The Rust idiom of successively re-binding one name through a parse-and-transform pipeline therefore has to become distinct names. If you would rather nested shadowing did not happen either,
-vet warns about it.Newtypes
distinct f64 creates a separate type that keeps the underlying type's operators and printing — so there is no wrapper struct, no .0 to unwrap, and no derive list to restore what the inner type already had.#[derive(Debug, Clone, Copy, PartialEq)]
struct Celsius(f64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Fahrenheit(f64);
fn to_fahrenheit(degrees: Celsius) -> Fahrenheit {
Fahrenheit(degrees.0 * 9.0 / 5.0 + 32.0)
}
fn main() {
let indoors = Celsius(21.5);
// let outdoors: Fahrenheit = indoors; // rejected
println!("{:?}", to_fahrenheit(indoors));
} package main
import "core:fmt"
// distinct gives the newtype behavior without a wrapper struct,
// so the arithmetic operators still work directly on it.
Celsius :: distinct f64
Fahrenheit :: distinct f64
to_fahrenheit :: proc(degrees: Celsius) -> Fahrenheit {
return Fahrenheit(f64(degrees) * 9 / 5 + 32)
}
main :: proc() {
indoors: Celsius = 21.5
// outdoors: Fahrenheit = indoors // rejected, same as Rust
fmt.println(to_fahrenheit(indoors))
} It is the same safety property as Rust's newtype with none of the ceremony. Note there is no
#[derive] in Odin at all, because there is no trait system to derive into — the row on derive below explains why that costs less than it sounds.Ownership & Lifetimes
No borrow checker
Odin has no borrow checker, no lifetimes, no ownership tracking, and no
unsafe keyword — in Rust's terms the whole language is unsafe. The right column compiles, and is a dangling pointer if the append reallocates.fn main() {
let mut numbers = vec![1, 2, 3];
// The borrow checker rejects this: numbers is mutably
// borrowed by push while first still holds a shared borrow.
// let first = &numbers[0];
// numbers.push(4);
// println!("{first}");
// So you copy the value out first, or restructure.
let first = numbers[0];
numbers.push(4);
println!("{first} {:?}", numbers);
} package main
import "core:fmt"
main :: proc() {
numbers: [dynamic]int
defer delete(numbers)
append(&numbers, 1, 2, 3)
// This COMPILES. It is also a dangling pointer if the
// append reallocates — nothing warns you.
first := &numbers[0]
append(&numbers, 4)
// Correct by construction: copy the value out.
safe_first := numbers[0]
fmt.println(safe_first, numbers)
_ = first
} This is the headline difference and deserves an honest verdict rather than a sales pitch. What you get is no lifetime annotations and no fighting the checker to express a graph or a back-pointer. Whether that is a good trade depends entirely on the program: for a game with an arena per frame it usually is, for a networked service handling untrusted input it usually is not.
Drop vs defer
There is no
Drop trait and no destructor — nothing runs when a value goes out of scope unless you wrote a defer. Deferred statements run in reverse order, matching Drop's ordering.struct Resource {
name: String,
}
impl Drop for Resource {
fn drop(&mut self) {
println!("dropping {}", self.name);
}
}
fn main() {
let _first = Resource { name: "first".to_string() };
let _second = Resource { name: "second".to_string() };
println!("end of scope");
// Drop runs automatically, in reverse declaration order
} package main
import "core:fmt"
Resource :: struct {
name: string,
}
// No Drop trait — cleanup is an ordinary procedure you call
resource_destroy :: proc(resource: Resource) {
fmt.println("destroying", resource.name)
}
main :: proc() {
first := Resource{name = "first"}
defer resource_destroy(first)
second := Resource{name = "second"}
defer resource_destroy(second)
fmt.println("end of scope")
// defers run in reverse order, same as Drop
} Writing the cleanup one line below the acquisition is arguably clearer than an
impl Drop several hundred lines away. But the cost is real: a forgotten defer is a leak, where RAII cannot be forgotten. This is the single largest source of leaks when moving from Rust to Odin.Moves and clones
Odin has no move semantics. Assignment and argument passing copy — and for a slice or dynamic array that copies only the small header (pointer plus length), not the elements.
fn consume(values: Vec<i32>) -> usize {
values.len()
}
fn main() {
let numbers = vec![1, 2, 3];
let count = consume(numbers.clone()); // clone, or lose it
// consume(numbers) would MOVE numbers; using it after
// that point is a compile error.
println!("{count} {:?}", numbers);
} package main
import "core:fmt"
// Nothing is moved. This receives a copy of the slice HEADER
// (pointer + length), pointing at the same elements.
count_of :: proc(values: []int) -> int {
return len(values)
}
main :: proc() {
numbers: [dynamic]int
defer delete(numbers)
append(&numbers, 1, 2, 3)
count := count_of(numbers[:])
// numbers is still perfectly usable — there was no move
fmt.println(count, numbers)
} Nothing is invalidated by being passed somewhere, so
.clone() largely disappears and a value stays usable after a call. The flip side is that two copies of a [dynamic]int header both believe they own the buffer, and calling delete on both is a double free that nothing catches.Box, Rc & RefCell
The whole smart-pointer family is absent: no
Box, Rc, Arc, RefCell, Weak, or Cow. A heap value is a pointer you free yourself, and sharing is a plain pointer.use std::cell::RefCell;
use std::rc::Rc;
fn main() {
// Box: unique heap ownership
let boxed: Box<i32> = Box::new(42);
// Rc + RefCell: shared ownership with runtime borrow checks
let shared = Rc::new(RefCell::new(vec![1, 2]));
let second = Rc::clone(&shared);
second.borrow_mut().push(3);
println!("{boxed} {:?} count={}", shared.borrow(), Rc::strong_count(&shared));
} package main
import "core:fmt"
main :: proc() {
// Box is just a pointer you free yourself
boxed := new(int)
defer free(boxed)
boxed^ = 42
// There is no Rc, no Arc, no RefCell, no Weak.
// Sharing is a plain pointer; lifetime is your problem.
shared: [dynamic]int
defer delete(shared)
append(&shared, 1, 2)
second := &shared
append(second, 3)
fmt.println(boxed^, shared)
} Since there is no ownership system, there is nothing for those types to encode. In practice Odin programs sidestep most of what
Rc exists for by allocating related objects in one arena and freeing the arena as a unit — a different answer to the same problem rather than a missing feature.Allocators
The implicit context
Every Odin procedure receives an implicit
context carrying an allocator, a temp allocator, and a logger. You never declare or pass it. Assigning context.allocator redirects allocation for this scope and everything it calls — which is why collect uses the arena without a signature change.// Rust has ONE global allocator, swappable process-wide with
// #[global_allocator]. Per-collection allocators (allocator_api)
// are still unstable, so on stable Rust a library cannot be told
// "allocate from this arena" without threading a type parameter
// through every signature.
fn collect() -> Vec<i32> {
vec![1, 2, 3] // always the global allocator
}
fn main() {
println!("{:?}", collect());
} package main
import "core:fmt"
import "core:mem"
// Takes no allocator parameter and knows nothing about arenas
collect :: proc() -> []int {
values := make([]int, 3)
values[0] = 1
values[1] = 2
values[2] = 3
return values
}
main :: proc() {
backing: [1024]byte
arena: mem.Arena
mem.arena_init(&arena, backing[:])
// Redirects this scope AND everything it calls
context.allocator = mem.arena_allocator(&arena)
fmt.println("from the arena:", collect())
fmt.println("no individual frees needed")
} If the borrow checker is what Odin removed, this is what it added. Rust's
allocator_api aims at the same capability but is unstable and viral through type signatures; Odin's is dynamic, ambient, and costs one pointer in the context struct.The temporary allocator
The
context carries a second allocator for short-lived values. Anything taken from it is released by a single free_all(context.temp_allocator) rather than one deallocation per value.fn describe(value: i32) -> String {
// Allocates; freed when the String is dropped
format!("value is {value}")
}
fn main() {
for value in 1..=3 {
println!("{}", describe(value));
}
} package main
import "core:fmt"
describe :: proc(value: int) -> string {
// Scratch memory — no individual free
return fmt.aprintf("value is %d", value, allocator = context.temp_allocator)
}
main :: proc() {
defer free_all(context.temp_allocator)
for value in 1 ..= 3 {
fmt.println(describe(value))
}
// One free_all reclaims all three at once
} Rust's RAII frees each
String at its own drop point, which is correct but pays a deallocation per value; the arena resets a pointer once. This is also why returning an unowned string is normal in Odin, where returning a dangling &str is impossible in Rust.Finding leaks
Because an allocator is an ordinary value, one can wrap another.
Tracking_Allocator records every allocation with the source location that made it, and reports whatever was never freed.fn main() {
// Safe Rust largely prevents leaks by construction, though
// Rc cycles and mem::forget can still leak. Detection means
// an external tool: valgrind, heaptrack, or dhat-rs.
let leaked: &'static mut i32 = Box::leak(Box::new(42));
println!("{leaked}");
} package main
import "core:fmt"
import "core:mem"
main :: proc() {
tracker: mem.Tracking_Allocator
mem.tracking_allocator_init(&tracker, context.allocator)
defer mem.tracking_allocator_destroy(&tracker)
context.allocator = mem.tracking_allocator(&tracker)
leaked := new(int)
leaked^ = 42
fmt.println(leaked^)
// free(leaked) deliberately omitted
for _, entry in tracker.allocation_map {
fmt.printfln("leaked %d bytes at %v", entry.size, entry.location)
}
} Odin makes leaks possible, so it ships the tool to find them in the standard library — no external profiler, no special build, and scopeable to one subsystem. Most projects install it behind
when ODIN_DEBUG and print the report at exit. It is the pragmatic answer to a problem Rust solves at compile time.Strings
String vs &str
Odin has one string type — a pointer and a length. Whether you own the bytes is a convention, not part of the type: slicing returns a view that must not be deleted, while
strings.concatenate returns owned memory that must be.fn main() {
let borrowed: &str = "Hello"; // view, 'static
let owned: String = borrowed.to_string(); // heap, owned
// The distinction is enforced by lifetimes throughout
let combined: String = format!("{owned}, Rust!");
println!("{combined} ({} bytes)", combined.len());
} package main
import "core:fmt"
import "core:strings"
main :: proc() {
// ONE type. Whether it is owned is a convention, not a type.
borrowed: string = "Hello"
combined := strings.concatenate({borrowed, ", Odin!"})
defer delete(combined)
fmt.println(combined, "(", len(combined), "bytes )")
} Rust's
String/&str split encodes ownership in the type system, which is precisely what Odin does not do. Reading the return documentation therefore matters here in a way it does not in Rust.UTF-8 handling
Both languages store strings as UTF-8 and decode runes when ranging. Odin does not enforce validity, so byte indexing is allowed. Note the loop-variable order: value first, offset second.
fn main() {
let greeting = "héllo";
// Rust GUARANTEES a str is valid UTF-8, and refuses to
// index by byte position at all.
println!("bytes: {}", greeting.len());
println!("chars: {}", greeting.chars().count());
for (offset, character) in greeting.char_indices() {
print!("{offset}:{character} ");
}
println!();
} package main
import "core:fmt"
import "core:unicode/utf8"
main :: proc() {
greeting := "héllo"
// Byte indexing IS allowed — validity is not enforced
fmt.println("bytes:", len(greeting))
fmt.println("runes:", utf8.rune_count_in_string(greeting))
// NOTE the order: value FIRST, offset second
for character, offset in greeting {
fmt.printf("%d:%c ", offset, character)
}
fmt.println()
} Rust makes invalid UTF-8 in a
str unrepresentable and therefore forbids byte indexing outright. Odin trusts you instead — and the reversed loop variables relative to char_indices are an easy thing to get backwards.Building strings
strings.Builder works as Rust's does, but needs an explicit builder_destroy, and strings.to_string returns a view into the builder's buffer rather than a copy.use std::fmt::Write;
fn main() {
let mut builder = String::new();
for index in 1..=5 {
write!(builder, "{index} ").unwrap();
}
println!("{builder}");
} package main
import "core:fmt"
import "core:strings"
main :: proc() {
builder := strings.builder_make()
defer strings.builder_destroy(&builder)
for index in 1 ..= 5 {
fmt.sbprintf(&builder, "%d ", index)
}
fmt.println(strings.to_string(builder))
} That view is valid only until the builder is destroyed. Rust's type system would stop you holding it too long; here it is on you, and
strings.clone is how you keep it.Collections
Vec vs dynamic arrays
Vec<T> is [dynamic]T and &[T] is []T, with matching slice syntax. append takes a pointer to the array rather than being a &mut self method, and delete is explicit because nothing drops for you.fn main() {
let mut numbers: Vec<i32> = Vec::new();
numbers.push(10);
numbers.extend([20, 30]);
println!("{:?} len={} cap={}", numbers, numbers.len(), numbers.capacity());
let slice: &[i32] = &numbers[1..];
println!("{:?}", slice);
// Dropped automatically at end of scope
} package main
import "core:fmt"
main :: proc() {
numbers: [dynamic]int
defer delete(numbers) // no automatic Drop
append(&numbers, 10)
append(&numbers, 20, 30)
fmt.println(numbers, "len =", len(numbers), "cap =", cap(numbers))
view := numbers[1:]
fmt.println(view)
} Odin's slice is pointer plus length with no lifetime attached, so it can outlive the array it points into — which the borrow checker exists to prevent.
HashMap
map[K]V is built into the language rather than living in a collections module, and allocates from context.allocator. A read of a missing key yields the zero value, so scores[key] += 10 already does what or_insert(0) arranges.use std::collections::HashMap;
fn main() {
let mut scores: HashMap<&str, i32> = HashMap::new();
scores.insert("alice", 1);
scores.insert("bob", 2);
// The Entry API has no Odin equivalent
*scores.entry("alice").or_insert(0) += 10;
match scores.get("bob") {
Some(value) => println!("bob = {value}"),
None => println!("bob absent"),
}
println!("entries: {}", scores.len());
} package main
import "core:fmt"
main :: proc() {
scores := make(map[string]int)
defer delete(scores)
scores["alice"] = 1
scores["bob"] = 2
// No Entry API — read, modify, write back
scores["alice"] += 10
if value, found := scores["bob"]; found {
fmt.println("bob =", value)
} else {
fmt.println("bob absent")
}
fmt.println("entries:", len(scores))
} What is absent is the
Entry API — no or_insert, and_modify, or or_insert_with. Removing one entry is delete_key(&scores, key), while delete(scores) frees the whole map.No iterator adaptors
Odin has no
Iterator trait, no adaptors, and no collect — it cannot, because adaptor chains are built from closures and Odin procedure literals cannot capture. So the loop comes back.fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
// The heart of idiomatic Rust: lazy, composable, zero-cost
let total: i32 = numbers
.iter()
.filter(|value| *value % 2 == 0)
.map(|value| value * 10)
.sum();
let names: Vec<String> = numbers
.iter()
.take(2)
.map(|value| format!("n{value}"))
.collect();
println!("{total} {names:?}");
} package main
import "core:fmt"
main :: proc() {
numbers := []int{1, 2, 3, 4, 5, 6}
// Write the loop. There is no .iter().filter().map().sum().
total := 0
for value in numbers {
if value % 2 == 0 {
total += value * 10
}
}
names: [dynamic]string
defer {
for name in names { delete(name) }
delete(names)
}
for value, index in numbers {
if index >= 2 { break }
append(&names, fmt.aprintf("n%d", value))
}
fmt.println(total, names)
} For most Rust programmers this is the biggest day-to-day adjustment — bigger than the borrow checker, because you touch it in every function. Some of the pain is real: the three-line chain becomes eight, and the intermediate now needs its own cleanup. Some is illusory: the loop makes the allocation and the iteration order visible, which is the point of the language.
core:slice covers common reductions by taking a non-capturing procedure.Control Flow
Expressions vs statements
Odin is statement-oriented:
if, switch, and blocks do not produce values, so every branch needs its own return. The one expression form is the ternary a if condition else b.fn classify(score: i32) -> &'static str {
// if is an EXPRESSION, and the tail is the return value
if score >= 90 {
"excellent"
} else if score >= 70 {
"good"
} else {
"needs work"
}
}
fn main() {
let doubled = { let base = 21; base * 2 }; // block expression
println!("{} {doubled}", classify(75));
} package main
import "core:fmt"
classify :: proc(score: int) -> string {
// if is a STATEMENT — every branch needs an explicit return
if score >= 90 {
return "excellent"
} else if score >= 70 {
return "good"
}
return "needs work"
}
main :: proc() {
// Ternary-style expression for the simple case
base := 21
doubled := base * 2
label := "big" if doubled > 40 else "small"
fmt.println(classify(75), doubled, label)
} Nothing here is a safety difference — it is a style adjustment you will feel for about a day. The ternary covers the common case that Rust's tail-expression
if is usually used for.Loops
Ranging over a collection needs no
.iter().enumerate() — the index is the optional second variable, the reverse of Rust's enumerate. Ranges spell their bound explicitly: ..< excludes, ..= includes.fn main() {
for index in 0..3 {
print!("{index} ");
}
println!();
let numbers = [10, 20, 30];
for (index, value) in numbers.iter().enumerate() {
print!("{index}:{value} ");
}
println!();
// loop is an expression that can break with a value
let mut counter = 0;
let found = loop {
counter += 1;
if counter == 3 { break counter * 10; }
};
println!("{found}");
} package main
import "core:fmt"
main :: proc() {
for index in 0 ..< 3 {
fmt.print(index, "")
}
fmt.println()
numbers := [3]int{10, 20, 30}
// value FIRST, index second — and no .enumerate() needed
for value, index in numbers {
fmt.printf("%d:%d ", index, value)
}
fmt.println()
// No break-with-value; assign before breaking
counter := 0
found := 0
for {
counter += 1
if counter == 3 {
found = counter * 10
break
}
}
fmt.println(found)
} Odin has no
break value, because loops are not expressions; assign to a variable declared outside the loop instead. Labeled break and continue work as they do in Rust, with the label attached to the loop.Functions & Closures
No closures
An Odin procedure literal cannot capture surrounding locals. There is no
Fn/FnMut/FnOnce hierarchy and no move — captured state has to become an explicit struct passed by pointer.fn make_counter() -> impl FnMut() -> i32 {
let mut count = 0;
// Captures count by move; the closure owns it
move || {
count += 1;
count
}
}
fn main() {
let mut next = make_counter();
println!("{} {} {}", next(), next(), next());
} package main
import "core:fmt"
// No capture is possible, so the state becomes explicit
Counter :: struct {
count: int,
}
next :: proc(counter: ^Counter) -> int {
counter.count += 1
return counter.count
}
main :: proc() {
counter: Counter
fmt.println(next(&counter), next(&counter), next(&counter))
} A procedure value is therefore a bare code pointer with no environment and no allocation, which is exactly why the language omits closures: a capturing closure must store its environment somewhere, and Odin refuses to do that invisibly. The struct in the right column is what the Rust closure compiles to anyway.
Callbacks
A non-capturing Rust closure and an Odin procedure literal are the same thing — a function pointer. For the capturing case, Odin makes you pass the captured state as an extra parameter, or bundle it into a struct alongside the procedure pointer.
fn apply_to(operation: impl Fn(i32) -> i32, value: i32) -> i32 {
operation(value)
}
fn main() {
// A non-capturing closure works as a plain fn pointer
println!("{}", apply_to(|value| value * 2, 21));
// A capturing one needs the generic or a Box<dyn Fn>
let factor = 3;
println!("{}", apply_to(move |value| value * factor, 21));
} package main
import "core:fmt"
Transform :: proc(value: int) -> int
apply_to :: proc(operation: Transform, value: int) -> int {
return operation(value)
}
// The capturing case: pass the state alongside
Scaled :: proc(value: int, factor: int) -> int
apply_scaled :: proc(operation: Scaled, value, factor: int) -> int {
return operation(value, factor)
}
main :: proc() {
fmt.println(apply_to(proc(value: int) -> int { return value * 2 }, 21))
factor := 3
fmt.println(apply_scaled(proc(value, factor: int) -> int {
return value * factor
}, 21, factor))
} That struct-plus-pointer pair is precisely what
Box<dyn Fn> is under the hood, and what impl Fn monomorphizes into. Odin just makes you write it.Default & named arguments
Odin has both default parameter values and call-by-name, so an argument can be set by name while earlier ones keep their defaults.
// Rust has neither. The idiom is a builder, or Default +
// struct update syntax, or several named constructors.
#[derive(Default)]
struct GreetOptions {
greeting: Option<String>,
punctuation: Option<String>,
}
fn greet(name: &str, options: GreetOptions) {
let greeting = options.greeting.unwrap_or_else(|| "Hello".into());
let punctuation = options.punctuation.unwrap_or_else(|| "!".into());
println!("{greeting}, {name}{punctuation}");
}
fn main() {
greet("Ada", GreetOptions::default());
greet("Bob", GreetOptions { punctuation: Some("?".into()), ..Default::default() });
} package main
import "core:fmt"
greet :: proc(name: string, greeting := "Hello", punctuation := "!") {
fmt.printfln("%s, %s%s", greeting, name, punctuation)
}
main :: proc() {
greet("Ada")
// Name an argument to skip the ones before it
greet("Bob", punctuation = "?")
greet("Carol", greeting = "Good morning", punctuation = "?")
} One of the few places where Odin is unambiguously less ceremonious. Rust has neither, so the ecosystem reaches for the builder pattern,
Default plus struct update syntax, or a family of constructors.Structs & Methods
No impl blocks
There are no
impl blocks, no self, no Self, and no method-call syntax. A procedure operating on a type takes it as an ordinary parameter, conventionally prefixed with the type name.struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
fn area(&self) -> f64 {
self.width * self.height
}
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
}
fn main() {
let mut box_shape = Rectangle::new(3.0, 4.0);
println!("{}", box_shape.area());
box_shape.scale(2.0);
println!("{}", box_shape.area());
} package main
import "core:fmt"
Rectangle :: struct {
width: f64,
height: f64,
}
// Free procedures, conventionally prefixed with the type name
rectangle_make :: proc(width, height: f64) -> Rectangle {
return Rectangle{width = width, height = height}
}
rectangle_area :: proc(rectangle: Rectangle) -> f64 {
return rectangle.width * rectangle.height
}
rectangle_scale :: proc(rectangle: ^Rectangle, factor: f64) {
rectangle.width *= factor
rectangle.height *= factor
}
main :: proc() {
box := rectangle_make(3, 4)
fmt.println(rectangle_area(box))
rectangle_scale(&box, 2)
fmt.println(rectangle_area(box))
} The
&self versus &mut self distinction becomes value versus pointer, which reads almost the same at the definition. At the call site you write rectangle_area(box) rather than box.area(), and namespacing is by naming convention rather than by scope.Composition
using on a struct field promotes that field's members into the outer struct, so worker.name resolves through to worker.identity.name while the full path keeps working.struct Named {
name: String,
}
struct Employee {
identity: Named, // no field promotion in Rust
salary: i32,
}
impl Employee {
// Delegation is written out by hand, or via Deref (discouraged)
fn name(&self) -> &str {
&self.identity.name
}
}
fn main() {
let worker = Employee {
identity: Named { name: "Ada".to_string() },
salary: 100,
};
println!("{} {}", worker.name(), worker.salary);
} package main
import "core:fmt"
Named :: struct {
name: string,
}
Employee :: struct {
using identity: Named, // fields promoted into Employee
salary: int,
}
main :: proc() {
worker := Employee{identity = Named{name = "Ada"}, salary = 100}
// Reachable directly — no delegating accessor to write
fmt.println(worker.name, worker.salary)
fmt.println(worker.identity.name)
} Rust has no field promotion — you write a delegating accessor, or abuse
Deref, which the community rightly discourages for non-smart-pointer types. Since Odin has no methods, only fields are promoted, never behavior.Traits & Polymorphism
There are no traits
Odin has no traits: no
impl Trait, no dyn, no trait objects, no associated types, no blanket impls, no derive, and no operator overloading. For a closed set of implementations, a tagged union takes their place.trait Shape {
fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rectangle { width: f64, height: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }
}
impl Shape for Rectangle {
fn area(&self) -> f64 { self.width * self.height }
}
fn total_area(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|shape| shape.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 2.0 }),
Box::new(Rectangle { width: 3.0, height: 4.0 }),
];
println!("{:.2}", total_area(&shapes));
} package main
import "core:fmt"
Circle :: struct { radius: f64 }
Rectangle :: struct { width, height: f64 }
// A tagged union replaces the trait for a CLOSED set
Shape :: union {
Circle,
Rectangle,
}
area :: proc(shape: Shape) -> f64 {
switch specific in shape {
case Circle: return 3.14159 * specific.radius * specific.radius
case Rectangle: return specific.width * specific.height
}
return 0
}
total_area :: proc(shapes: []Shape) -> f64 {
total := 0.0
for shape in shapes {
total += area(shape)
}
return total
}
main :: proc() {
shapes := []Shape{Circle{2}, Rectangle{3, 4}}
fmt.printfln("%.2f", total_area(shapes))
} This is the second-largest removal after the borrow checker. For a closed set the union is arguably better: no boxing, no vtable pointer, values inline, dispatch as a jump table, and the compiler enforcing exhaustiveness — adding a variant surfaces every site that must handle it, which
dyn Shape cannot do. What you lose is open extension, and any equivalent of implementing a foreign trait for your own type.Open extension by hand
When you need open extension, you build the trait object yourself: a struct holding a
rawptr to the data plus one procedure pointer per operation.trait Writer {
fn write(&mut self, text: &str) -> usize;
}
struct CountingWriter { total: usize }
impl Writer for CountingWriter {
fn write(&mut self, text: &str) -> usize {
self.total += text.len();
text.len()
}
}
fn write_twice(writer: &mut dyn Writer, text: &str) {
writer.write(text);
writer.write(text);
}
fn main() {
let mut counter = CountingWriter { total: 0 };
write_twice(&mut counter, "hello");
println!("{}", counter.total);
} package main
import "core:fmt"
// The trait object, written out: data pointer + procedure table
Writer :: struct {
data: rawptr,
write: proc(data: rawptr, text: string) -> int,
}
Counting_Writer :: struct {
total: int,
}
counting_writer_write :: proc(data: rawptr, text: string) -> int {
writer := cast(^Counting_Writer)data
writer.total += len(text)
return len(text)
}
write_twice :: proc(writer: Writer, text: string) {
writer.write(writer.data, text)
writer.write(writer.data, text)
}
main :: proc() {
counter: Counting_Writer
writer := Writer{data = &counter, write = counting_writer_write}
write_twice(writer, "hello")
fmt.println(counter.total)
} That is exactly what
&mut dyn Writer is — a fat pointer of data plus vtable — except here it is visible, so the indirect call and the type erasure are in the source rather than in the compiler. Odin's own Allocator and Logger are built this way, which is what makes the allocator swappable.No derive
Every Odin struct already prints with
%v, compares with == when its fields do, copies on assignment, hashes as a map key, and has a zero value — with no attribute to opt in.#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let origin = Point { x: 3, y: 4 };
let copied = origin; // Copy
let zero = Point::default(); // Default
println!("{origin:?} {}", origin == copied); // Debug, PartialEq
println!("{zero:?}");
} package main
import "core:fmt"
Point :: struct {
x: int,
y: int,
}
main :: proc() {
origin := Point{x = 3, y = 4}
copied := origin // everything is Copy; there is no move
zero: Point // everything has a zero value
// Printing and equality are built in for every struct
fmt.printfln("%v %v", origin, origin == copied)
fmt.printfln("%v", zero)
} That is why there is no
derive: there is nothing to derive, because those are properties of the language rather than traits. What is genuinely lost is custom derives — serde in particular has no Odin counterpart, and serialization is written by hand or generated externally.No operator overloading
Odin has no operator overloading, by design, so that
a + b is always a machine operation and never a hidden call. Fixed-size arrays cover the main use case natively: element-wise arithmetic plus GLSL-style .xyzw swizzles.use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Vector2 { x: f64, y: f64 }
impl Add for Vector2 {
type Output = Vector2;
fn add(self, other: Vector2) -> Vector2 {
Vector2 { x: self.x + other.x, y: self.y + other.y }
}
}
fn main() {
let sum = Vector2 { x: 1.0, y: 2.0 } + Vector2 { x: 10.0, y: 20.0 };
println!("{sum:?}");
} package main
import "core:fmt"
// No impl Add — but ARRAYS already do element-wise arithmetic,
// which is what most vector overloads exist for.
Vector2 :: [2]f64
main :: proc() {
left := Vector2{1, 2}
right := Vector2{10, 20}
sum := left + right // built in, and SIMD where available
fmt.println(sum, sum.yx) // swizzles too
// For a named struct, write the procedure
Point :: struct { x, y: f64 }
point_add :: proc(a, b: Point) -> Point {
return Point{a.x + b.x, a.y + b.y}
}
fmt.println(point_add(Point{1, 2}, Point{10, 20}))
} So
[2]f64 and [4]f32 behave as vectors with no impl Add, and there is a first-class matrix[R, C]T type with real multiplication. For a named struct you write the procedure, as the second half of the column shows.Enums & Pattern Matching
Enums with and without payloads
Odin splits Rust's
enum in two. enum is only a set of named values; payload-carrying variants go in a union of named struct types.#[derive(Debug)]
enum Direction { North, South, East, West }
#[derive(Debug)]
enum Event {
Click { x: i32, y: i32 },
KeyPress(u32),
Quit,
}
fn main() {
let heading = Direction::East;
let event = Event::Click { x: 3, y: 4 };
println!("{heading:?} {event:?}");
} package main
import "core:fmt"
// A plain enum: no payloads, just named values
Direction :: enum {North, South, East, West}
// Payloads live in a UNION of struct types, not in the enum
Click :: struct { x, y: int }
Key_Press :: struct { code: u32 }
Quit :: struct {}
Event :: union {
Click,
Key_Press,
Quit,
}
main :: proc() {
heading := Direction.East
event: Event = Click{x = 3, y = 4}
fmt.println(heading, event)
} That means each variant is a real type usable on its own — you can pass a
Click to a procedure expecting one — where a Rust enum variant is not a type. Both forms give exhaustive matching.match vs switch
Exhaustiveness carries over — a
switch over a union or enum must cover every case, with #partial to opt out. Pattern matching does not: there is no destructuring, no bindings inside a pattern, no @ bindings, no nested patterns, and no guards.enum Shape {
Circle(f64),
Rectangle(f64, f64),
}
fn area(shape: &Shape) -> f64 {
// Exhaustive, with destructuring bindings
match shape {
Shape::Circle(radius) => 3.14159 * radius * radius,
Shape::Rectangle(width, height) => width * height,
}
}
fn main() {
// Guards, bindings, and nested patterns all in one construct
let value = 7;
let label = match value {
n if n < 0 => "negative",
0 => "zero",
1..=9 => "single digit",
_ => "large",
};
println!("{:.2} {label}", area(&Shape::Circle(2.0)));
} package main
import "core:fmt"
Circle :: struct { radius: f64 }
Rectangle :: struct { width, height: f64 }
Shape :: union {Circle, Rectangle}
area :: proc(shape: Shape) -> f64 {
// Exhaustive over the union, but no destructuring
switch specific in shape {
case Circle: return 3.14159 * specific.radius * specific.radius
case Rectangle: return specific.width * specific.height
}
return 0
}
main :: proc() {
value := 7
label: string
switch {
case value < 0: label = "negative"
case value == 0: label = "zero"
case value >= 1 && value <= 9: label = "single digit"
case: label = "large"
}
fmt.printfln("%.2f %s", area(Circle{2}), label)
} The union switch binds the whole variant value and you reach into its fields. Ranges (
1 ..= 9) work on values, and a conditionless switch replaces guard arms — which is what the second half of the right column does.Option vs Maybe
Maybe(T) is Option<T> — a union of T and nothing — and the .? suffix unwraps it into a value plus a flag, reading much like if let Some(x).fn find_index(values: &[i32], wanted: i32) -> Option<usize> {
values.iter().position(|value| *value == wanted)
}
fn main() {
let numbers = [10, 20, 30];
if let Some(index) = find_index(&numbers, 20) {
println!("found at {index}");
}
// The combinator vocabulary is the real value of Option
let doubled = find_index(&numbers, 20).map(|index| index * 2).unwrap_or(0);
println!("{doubled} {:?}", find_index(&numbers, 99));
} package main
import "core:fmt"
find_index :: proc(values: []int, wanted: int) -> Maybe(int) {
for value, index in values {
if value == wanted {
return index
}
}
return nil
}
main :: proc() {
numbers := []int{10, 20, 30}
if index, found := find_index(numbers, 20).?; found {
fmt.println("found at", index)
}
// No .map()/.and_then() — unwrap, then compute
doubled := 0
if index, found := find_index(numbers, 20).?; found {
doubled = index * 2
}
fmt.println(doubled, find_index(numbers, 99) == nil)
} What is missing is the combinator vocabulary: no
map, and_then, unwrap_or_else, ok_or, or filter, because all of those take closures. or_else covers unwrap_or; the rest becomes an if.Error Handling
Result vs multiple returns
Odin returns the value and the error side by side rather than wrapping them in a sum type. The error is conventionally an
enum whose zero member is None.#[derive(Debug)]
enum ParseError {
NotANumber,
OutOfRange,
}
fn parse_positive(text: &str) -> Result<i32, ParseError> {
let value: i32 = text.parse().map_err(|_| ParseError::NotANumber)?;
if value <= 0 {
return Err(ParseError::OutOfRange);
}
Ok(value)
}
fn main() {
println!("{:?}", parse_positive("42"));
println!("{:?}", parse_positive("abc"));
} package main
import "core:fmt"
import "core:strconv"
Parse_Error :: enum {
None,
Not_A_Number,
Out_Of_Range,
}
parse_positive :: proc(text: string) -> (value: int, error: Parse_Error) {
parsed, ok := strconv.parse_int(text)
if !ok {
return 0, .Not_A_Number
}
if parsed <= 0 {
return 0, .Out_Of_Range
}
return parsed, .None
}
main :: proc() {
fmt.println(parse_positive("42"))
fmt.println(parse_positive("abc"))
} That costs one integer, needs no allocation, and stays a closed set the compiler can check exhaustively. The trade-off is that nothing forces you to inspect it — Rust's
#[must_use] on Result has no equivalent, though you must at least bind or _ every return value.The ? operator
or_return is ?: it takes the last returned value as the error and, if it is not the zero value, returns from the enclosing procedure immediately with it. Named results tell the compiler what to return.#[derive(Debug)]
enum AppError { Parse }
fn parse_value(text: &str) -> Result<i32, AppError> {
text.parse().map_err(|_| AppError::Parse)
}
fn double_value(text: &str) -> Result<i32, AppError> {
let value = parse_value(text)?; // early return on Err
Ok(value * 2)
}
fn quadruple(text: &str) -> Result<i32, AppError> {
Ok(double_value(text)? * 2)
}
fn main() {
println!("{:?}", quadruple("21"));
println!("{:?}", quadruple("nope"));
} package main
import "core:fmt"
import "core:strconv"
App_Error :: enum {None, Parse}
parse_value :: proc(text: string) -> (value: int, error: App_Error) {
parsed, ok := strconv.parse_int(text)
if !ok {
return 0, .Parse
}
return parsed, .None
}
double_value :: proc(text: string) -> (result: int, error: App_Error) {
value := parse_value(text) or_return // early return on error
return value * 2, .None
}
quadruple :: proc(text: string) -> (result: int, error: App_Error) {
doubled := double_value(text) or_return
return doubled * 2, .None
}
main :: proc() {
fmt.println(quadruple("21"))
fmt.println(quadruple("nope"))
} The one thing
? does that or_return cannot is perform a From conversion between error types, which needs traits. So multi-layer error hierarchies are usually flattened into one enum, or a union of enums, rather than converted as they propagate.panic & no unwinding
Odin has
panic and assert but no unwinding and no catch_unwind — a panic terminates the process. Anything a caller might handle therefore has to be a return value.fn main() {
// Rust panics unwind by default, running Drop along the way,
// and can be caught at a boundary with catch_unwind.
let result = std::panic::catch_unwind(|| {
panic!("something broke");
});
println!("caught: {}", result.is_err());
println!("execution continues");
} package main
import "core:fmt"
risky :: proc(should_fail: bool) -> (result: string, ok: bool) {
// There is no catch_unwind and no unwinding at all, so a
// recoverable failure MUST be a return value.
if should_fail {
return "", false
}
return "succeeded", true
}
main :: proc() {
assert(1 + 1 == 2, "arithmetic still works")
// panic("something broke") // would abort the process outright
fmt.println(risky(false))
fmt.println(risky(true))
fmt.println("execution continues")
} Since there is no
Drop either, there would be nothing to run during an unwind anyway. The Rust pattern of panicking on an invariant violation and catching it at a thread or request boundary simply does not transfer. assert is compiled out in release builds; panic is not.Generics
Generic functions
The
$ prefix marks a parameter the compiler infers and specializes on. There are no trait bounds to name — the body is instantiated per type and checked then.fn largest<T: PartialOrd + Copy>(values: &[T]) -> T {
let mut best = values[0];
for &value in &values[1..] {
if value > best {
best = value;
}
}
best
}
fn main() {
println!("{}", largest(&[3, 17, 8]));
println!("{}", largest(&[1.5, 0.5]));
} package main
import "core:fmt"
// $T is inferred; no trait bounds to name
largest :: proc(values: []$T) -> T {
best := values[0]
for value in values[1:] {
if value > best {
best = value
}
}
return best
}
main :: proc() {
fmt.println(largest([]int{3, 17, 8}))
fmt.println(largest([]f64{1.5, 0.5}))
fmt.println(largest([]string{"pear", "apple"}))
} Both languages monomorphize, but Rust verifies the bounds at the definition, so the body is guaranteed valid for any conforming type. Odin reports an error only if an operation turns out to be unsupported, closer to C++ templates: more flexible, less safe, and the error surfaces inside the procedure rather than at your call site.
Constraints
Odin's
where takes an arbitrary compile-time boolean — a type predicate from base:intrinsics, a size comparison, a relation between two parameters — not a list of trait bounds. Note the base:intrinsics import.use std::ops::Add;
fn sum_all<T>(values: &[T]) -> T
where
T: Add<Output = T> + Copy + Default,
{
let mut total = T::default();
for &value in values {
total = total + value;
}
total
}
fn main() {
println!("{}", sum_all(&[1, 2, 3]));
println!("{}", sum_all(&[1.5, 2.5]));
} package main
import "core:fmt"
import "base:intrinsics"
// A `where` clause is any compile-time boolean expression
sum_all :: proc(values: []$T) -> T
where intrinsics.type_is_numeric(T) {
total: T
for value in values {
total += value
}
return total
}
main :: proc() {
fmt.println(sum_all([]int{1, 2, 3}))
fmt.println(sum_all([]f64{1.5, 2.5}))
} That is strictly more expressive than a trait bound, which can only enumerate a type set, but it proves nothing about the body. Both languages spell it
where and mean quite different things by it.Const generics
[$N]int binds the array length as a compile-time value usable in the body and the return type — the same job as Rust's const N: usize.// Rust has const generics, so this much DOES transfer
fn sum_fixed<const N: usize>(values: [i32; N]) -> i32 {
values.iter().sum()
}
fn doubled<const N: usize>(values: [i32; N]) -> [i32; N] {
let mut result = [0; N];
for index in 0..N {
result[index] = values[index] * 2;
}
result
}
fn main() {
println!("{}", sum_fixed([1, 2, 3]));
println!("{}", sum_fixed([1, 2, 3, 4, 5]));
println!("{:?}", doubled([1, 2, 3]));
} package main
import "core:fmt"
// $N binds the array length, same idea as const generics
sum_fixed :: proc(values: [$N]int) -> int {
total := 0
for value in values {
total += value
}
return total
}
doubled :: proc(values: [$N]int) -> [N]int {
result: [N]int
for value, index in values {
result[index] = value * 2
}
return result
}
main :: proc() {
fmt.println(sum_fixed([3]int{1, 2, 3}))
fmt.println(sum_fixed([5]int{1, 2, 3, 4, 5}))
fmt.println(doubled([3]int{1, 2, 3}))
} This is the one area where the two languages genuinely converge. Odin's is slightly more general in that any parameter can be marked
$ to become compile-time, without the restrictions Rust still has around const-generic expressions.Macros & Compile Time
No macro system
Odin has no macro system whatsoever — no
macro_rules!, no derives, no attribute macros, no proc macros. Their jobs are split across parametric polymorphism, when, #load, and #force_inline.// macro_rules!, derive macros, proc macros, attribute macros
macro_rules! square {
($value:expr) => {
$value * $value
};
}
fn main() {
println!("{}", square!(7));
// Plus derive macros (#[derive(Serialize)]), attribute
// macros (#[tokio::main]), and function-like proc macros
// (sqlx::query!) — an entire metaprogramming ecosystem.
println!("{:?}", vec![1, 2, 3]);
} package main
import "core:fmt"
// No macros of any kind. A "macro" is a procedure.
square :: proc(value: int) -> int {
return value * value
}
main :: proc() {
fmt.println(square(7))
// The jobs macros do are split across other features:
// metaprogramming -> parametric polymorphism ($T, $N)
// conditional code -> when
// embedding data -> #load("file.bin")
// inlining -> #force_inline
values := []int{1, 2, 3}
fmt.println(values)
} The design position is that macros make code unreadable and compilers slow. What is genuinely lost is the derive ecosystem: serde has no counterpart, and serialization is hand-written or generated by an external tool.
Conditional compilation
when is a language construct over typed constants (ODIN_OS, ODIN_ARCH, ODIN_DEBUG) rather than an attribute plus a macro plus a Cargo.toml feature table. Only the taken branch is compiled.fn main() {
// cfg! is a macro; #[cfg] is an attribute
if cfg!(target_os = "macos") {
println!("compiled for macOS");
} else if cfg!(target_os = "linux") {
println!("compiled for Linux");
} else {
println!("compiled for something else");
}
println!("debug assertions: {}", cfg!(debug_assertions));
} package main
import "core:fmt"
main :: proc() {
// when is ordinary Odin, not an attribute or a macro
when ODIN_OS == .Darwin {
fmt.println("compiled for macOS")
} else when ODIN_OS == .Linux {
fmt.println("compiled for Linux")
} else {
fmt.println("compiled for something else")
}
fmt.println("debug build:", ODIN_DEBUG)
fmt.println("architecture:", ODIN_ARCH)
} The capability matches
#[cfg]/cfg! and the spelling is simpler. #config(NAME, default) covers values passed on the command line, which is what Cargo features are usually used for.Concurrency & Data Layout
Threads without Send and Sync
Odin has no
Send, no Sync, and no compile-time data-race prevention. Shared state travels as a struct of pointers because the thread procedure cannot capture, and sync.guard takes the lock and releases it at scope exit.use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Send + Sync are checked at COMPILE time; sharing a
// non-Sync value across threads simply does not compile.
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("counter: {}", *counter.lock().unwrap());
} package main
import "core:fmt"
import "core:sync"
import "core:thread"
Shared :: struct {
counter: ^int,
mutex: ^sync.Mutex,
}
main :: proc() {
counter := 0
mutex: sync.Mutex
shared := Shared{&counter, &mutex}
workers: [4]^thread.Thread
for index in 0 ..< 4 {
workers[index] = thread.create_and_start_with_poly_data(&shared, proc(data: ^Shared) {
for _ in 0 ..< 1000 {
sync.guard(data.mutex)
data.counter^ += 1
}
})
}
for worker in workers {
thread.join(worker)
thread.destroy(worker)
}
fmt.println("counter:", counter)
} "Fearless concurrency" is exactly what the borrow checker buys, so removing the checker removes the guarantee — forgetting the mutex here compiles and races. In exchange there is no
Arc to clone per thread and no lock().unwrap() ceremony.Struct of arrays
Prefixing an array type with
#soa stores each field as its own contiguous column while entities[index].field indexing stays identical.struct Entity {
x: f32,
y: f32,
health: i32,
}
fn main() {
// Array of structs. Converting to struct-of-arrays means a
// new type and rewriting every access — or the soa_derive
// crate, which generates a parallel API with different names.
let mut entities = vec![
Entity { x: 0.0, y: 0.0, health: 50 },
Entity { x: 1.0, y: 1.0, health: 75 },
];
entities[0].health = 60;
println!("{} {}", entities[0].health, entities[1].health);
} package main
import "core:fmt"
Entity :: struct {
x, y: f32,
health: int,
}
main :: proc() {
// Array of structs — interleaved
interleaved: [2]Entity
interleaved[0].health = 60
// Struct of arrays — columns. IDENTICAL indexing syntax.
columnar: #soa[2]Entity
columnar[0].health = 60
columnar[1].health = 75
fmt.println(interleaved[0].health)
fmt.println(columnar[0].health, columnar[1].health)
fmt.println("health column:", columnar.health)
} In Rust this needs a proc macro such as
soa_derive, which generates a parallel set of types and accessor names, so switching layouts is a refactor. For the cache-bound loops Odin targets, being able to try the change and revert it in seconds is the point.