Hello World & Building
Hello, World
Every Odin file opens with a
package declaration, and execution begins at main. The :: operator binds a compile-time constant, so main :: proc() reads literally as "main is a procedure".puts "Hello, World!"package main
import "core:fmt"
main :: proc() {
fmt.println("Hello, World!")
}Ruby runs a file containing a single expression. Odin will not: there is no top-level code, no interpreter, and nothing happens until you have named a package, imported
core:fmt, and declared a main. That ceremony is the smallest possible preview of the trade this whole page describes.Running vs compiling
A directory is Odin's compilation unit —
odin run . compiles every .odin file in the current directory as one package. The -file flag is what makes a single-file example like this one legal, and it is exactly what this page's run buttons use.# Ruby is interpreted. There is no build step:
# ruby script.rb run a file
# irb an interactive session
# ruby -e 'puts 1 + 1' run an expression
# You can also change a running program from inside itself,
# which is why irb and the Rails console are so central.
value = 1 + 1
puts "Ruby evaluated this the moment it read it: #{value}"// Odin is compiled ahead of time. There is no interpreter
// and no REPL:
// odin run . build a directory and run it
// odin build . -o:speed optimized build
// odin test . run the test procedures
// odin run file.odin -file build ONE file (what this site does)
package main
import "core:fmt"
main :: proc() {
value := 1 + 1
fmt.println("A compiler read this before you ever ran it:", value)
}Losing
irb is a real loss, and no amount of compiler speed fully replaces poking at a live object. What you get back is that Odin builds a whole project in a fraction of a second, so the edit-run loop stays close to Ruby's even without an interpreter.No gems, no Bundler
Odin's standard library is imported by collection path:
core: is the standard library that ships with the compiler and vendor: is the bundled third-party set (SDL, raylib, OpenGL). Anything else you copy into your own tree and import by relative path.# RubyGems and Bundler are so central they barely feel optional:
# gem install nokogiri
# bundle add rails
# bundle install
# require "json"
# rubygems.org carries well over 180,000 gems.
require "json"
puts JSON.generate({ language: "Ruby", gems: "many" })// There is no package manager, no lockfile, no version
// resolution, and no central registry.
//
// Dependencies are VENDORED — copied into your source tree
// (or added as a git submodule) and imported by path:
// import mylibrary "shared/mylibrary"
//
// core: and vendor: ship with the compiler itself.
package main
import "core:fmt"
import "core:encoding/json"
main :: proc() {
Description :: struct {
language: string,
packages: string,
}
described := Description{"Odin", "vendored"}
encoded, error := json.marshal(described)
defer delete(encoded)
if error != nil {
fmt.println("could not encode:", error)
return
}
fmt.println(string(encoded))
}This is the largest genuine loss on the page, and it should inform whether Odin suits your problem at all. For the game and graphics work Odin targets — short dependency lists, mostly C libraries already vendored — it costs less than it sounds. If your instinct on any new problem is to search rubygems.org first, no language feature compensates for that instinct being unavailable.
Variables & Types
Declaring a variable
Odin declarations read strictly left to right:
name : Type = value. Drop the type and the compiler infers it, which is what the := shorthand means — it is one operator, not an assignment with a colon glued on.name = "Ada"
age = 36
height = 1.7
puts "#{name} is #{age} and #{height}m tall"package main
import "core:fmt"
main :: proc() {
// Full form: name : Type = value
name: string = "Ada"
age: int = 36
// Short form: the type is inferred from the value
height := 1.7
fmt.printf("%s is %d and %.1fm tall\n", name, age, height)
}The declaration is the one place Odin asks for more typing than Ruby, and the reason is that a declaration is a distinct act here. Assigning to a name that was never declared is a compile error, so the typo that silently creates a new local in Ruby cannot happen.
Constants
The same
:: that declared main declares every constant — procedures, types, and values are all just compile-time constants in Odin, which is why they share one operator.MAXIMUM_RETRIES = 3
# A Ruby constant is a convention, not a rule. Reassigning
# only produces a warning, and the value is an ordinary
# object created at runtime like any other.
puts MAXIMUM_RETRIES
puts MAXIMUM_RETRIES.frozen?
TABLE = [1, 2, 3]
TABLE << 4
puts TABLE.inspectpackage main
import "core:fmt"
// :: declares a compile-time constant. It has no address,
// no storage, and cannot be reassigned — the compiler
// substitutes the value at every use.
MAXIMUM_RETRIES :: 3
// A constant array is baked into the binary.
TABLE :: [3]int{1, 2, 3}
main :: proc() {
fmt.println(MAXIMUM_RETRIES)
fmt.println(TABLE)
// MAXIMUM_RETRIES = 4 // Error: cannot assign to a constant
}Ruby's
CONSTANT is a naming convention the interpreter grumbles about but permits, and the object it names stays mutable unless you freeze it. Odin's constant is enforced by the compiler and has no runtime existence at all.One variable, one type
A variable's type is fixed at its declaration and checked at compile time. Odin also performs no implicit numeric conversions: mixing an
int and an f64 in one expression is an error until you write the conversion yourself.value = 42
puts value.class
value = "now a string"
puts value.class
value = [1, 2, 3]
puts value.class
# The type travels with the object, not the variable.
# Any variable can hold anything at any time.package main
import "core:fmt"
main :: proc() {
value := 42
fmt.println(value)
// value = "now a string"
// Error: cannot assign a string to a variable of type int
// A different type needs a different variable.
label := "now a string"
fmt.println(label)
// Odin will not convert for you either:
count: int = 42
ratio := f64(count) / 5.0
fmt.println(ratio)
}The Ruby habit this breaks is not really rebinding a variable to a new type — it is the pervasive assumption that a method will accept anything that responds to the right messages. Every argument you pass in Odin has been checked before the program starts.
Numbers have a width
Odin's integers are fixed-width machine integers, and signed overflow is defined to wrap rather than being undefined behavior the way it is in C. That makes the wrap predictable, not harmless.
# Ruby's Integer is arbitrary precision. It grows.
huge = 2 ** 100
puts huge
puts (huge * huge).to_s.length
# Float is IEEE 754 double, as everywhere.
puts 0.1 + 0.2package main
import "core:fmt"
main :: proc() {
// int is a machine word (64-bit here). It does not grow.
biggest := max(i64)
fmt.println(biggest)
one: i64 = 1
fmt.println(biggest + one) // wraps to the minimum
// Sized types are spelled out: i8 i16 i32 i64 i128,
// u8 u16 u32 u64 u128, f16 f32 f64.
small: u8 = 200
fmt.println(small + small) // 400 wraps to 144
fmt.println(0.1 + 0.2)
}Ruby's promotion from
Integer to bignum is so quiet that most Rubyists never think about the width of a number. In Odin the width is part of the type you chose, and choosing u8 for a counter that reaches 300 is a bug the compiler cannot see.No truthiness
The condition of an
if, for, or when must be an actual bool. There is no conversion from integers, strings, or pointers, so the C-style if (pointer) idiom does not exist either.count = 0
name = ""
items = []
# Only nil and false are falsey. Everything else is truthy.
puts "zero is truthy" if count
puts "empty string is truthy" if name
puts "empty array is truthy" if items
value = nil
puts "nil is falsey" unless valuepackage main
import "core:fmt"
main :: proc() {
count := 0
name := ""
// if count { ... }
// Error: 'count' is not a boolean expression
if count == 0 {
fmt.println("you have to say what you mean")
}
if len(name) == 0 {
fmt.println("and say it about the length")
}
found := false
if !found {
fmt.println("only a bool can steer an if")
}
}Ruby's rule that only
nil and false are falsey is already stricter than most dynamic languages, and Rubyists lean on it constantly — if user, value || default, array.first&.name. All of that has to become an explicit comparison.No nil: zero values
Odin zero-initializes every declaration, and
= --- is the explicit opt-out for a buffer you are about to fill anyway. A string's zero value is the empty string, not a null reference.class Account
attr_reader :balance, :owner
def initialize
@balance = 0
end
end
account = Account.new
p account.balance
p account.owner # never assigned, so nil
# Every reference can be nil, which is why so much Ruby
# code is defensive about it.
p account.owner&.upcasepackage main
import "core:fmt"
Account :: struct {
balance: int,
owner: string,
active: bool,
}
main :: proc() {
// Every variable is zeroed unless you opt out.
account: Account
fmt.println(account.balance) // 0
fmt.println(account.owner == "") // true — an empty string
fmt.println(account.active) // false
scores: [4]int
fmt.println(scores) // [0, 0, 0, 0]
// Opt out explicitly when you are about to overwrite it:
buffer: [4]int = ---
buffer = {1, 2, 3, 4}
fmt.println(buffer)
}The defensive
&. and || return scattered through mature Ruby code exists because any reference might be nil. In Odin a struct field is never absent — it holds its type's zero value. Pointers can still be nil, and the section on optionals covers the case where "no value" is genuinely meaningful.Strings
A string is not an object
An Odin
string is a two-word value: a pointer to bytes and a length. Because it is not an object, len is a builtin rather than a method, and everything else lives in core:strings as a free procedure whose first argument is the string.greeting = "Hello, World"
puts greeting.upcase
puts greeting.reverse
puts greeting.length
puts greeting.include?("World")
puts greeting.split(", ").inspect
# String has around 180 instance methods, and you can
# add more to it whenever you like.
puts String.instance_methods(false).length > 100package main
import "core:fmt"
import "core:strings"
main :: proc() {
greeting := "Hello, World"
// A string is a pointer and a length. It has no methods.
// Every operation is a procedure in the strings package.
upper := strings.to_upper(greeting)
defer delete(upper)
fmt.println(upper)
fmt.println(len(greeting)) // bytes, and it is a builtin
fmt.println(strings.contains(greeting, "World"))
pieces := strings.split(greeting, ", ")
defer delete(pieces)
fmt.println(pieces)
}Notice the
defer delete on the results. strings.to_upper has to build a new string somewhere, so it allocates — and in a language with no garbage collector, whoever asked for the allocation owns it. Procedures that only read, like strings.contains, allocate nothing.Interpolation
The
fmt family follows a naming rule worth learning immediately: print* writes to stdout, tprint* returns a string allocated in the temporary allocator, and aprint* returns one you must delete yourself.name = "Ada"
age = 36
# Interpolation calls to_s on whatever you put in it.
puts "#{name} is #{age} years old"
puts "Next year: #{age + 1}"
# format is there when you need control:
puts format("%-6s|%5.2f|", name, 1.7)package main
import "core:fmt"
main :: proc() {
name := "Ada"
age := 36
// There is no interpolation syntax. Formatting is a
// procedure call, and %v prints any value sensibly.
fmt.printf("%v is %v years old\n", name, age)
fmt.printf("Next year: %v\n", age + 1)
// Build a string instead of printing one:
line := fmt.tprintf("%-6s|%5.2f|", name, 1.7)
fmt.println(line)
}The
%v verb is the closest thing Odin has to Ruby's to_s, and it works on every type — including your own structs, enums, and unions — with no code from you. Where Ruby calls a method the object defines, Odin walks the type information the compiler already emitted.Strings are immutable
strings.Builder owns a growable byte buffer; strings.to_string hands back a string that points into that buffer rather than copying it, which is why the builder must outlive every use of the result.buffer = String.new("Never")
buffer << " gonna"
buffer << " give"
buffer.concat(" you up")
puts buffer
# In-place methods are everywhere, marked with a bang.
shout = buffer.dup
shout.upcase!
puts shoutpackage main
import "core:fmt"
import "core:strings"
main :: proc() {
// A string's bytes cannot be written through. To build one
// up, use a Builder — the amortized-growth buffer that a
// Ruby String already is internally.
builder := strings.builder_make()
defer strings.builder_destroy(&builder)
strings.write_string(&builder, "Never")
strings.write_string(&builder, " gonna")
strings.write_string(&builder, " give")
strings.write_string(&builder, " you up")
assembled := strings.to_string(builder)
fmt.println(assembled)
shouted := strings.to_upper(assembled)
defer delete(shouted)
fmt.println(shouted)
}Repeated
+ concatenation allocates a fresh string every time in both languages — Ruby just hides the cost behind a garbage collector that cleans up after you. The builder makes the cost visible, and the defer on the next line makes the cleanup yours.Bytes, runes, and characters
Odin has a distinct
rune type (a 32-bit Unicode code point) alongside string. Iterating a string decodes UTF-8 as it goes and yields (rune, byte offset) — the value first, which is the reverse of Ruby's each_with_index.text = "héllo"
puts text.length # 5 characters
puts text.bytesize # 6 bytes
puts text.chars.inspect
text.each_char.with_index do |character, index|
puts "#{index}: #{character}"
endpackage main
import "core:fmt"
import "core:unicode/utf8"
main :: proc() {
text := "héllo"
fmt.println(len(text)) // 6 — BYTES
fmt.println(utf8.rune_count(text)) // 5 — characters
// Iterating a string decodes UTF-8 and yields
// (rune, byte offset) — the value comes FIRST.
for character, offset in text {
fmt.printf("byte %v: %v\n", offset, character)
}
// Indexing gives you a raw byte, not a character:
fmt.println(text[1])
}Ruby's
String#length counts characters and bytesize counts bytes, so the character count is the one you get by default. Odin's len is bytes, because that is the number the machine actually stores — and the offsets the loop reports jump from 1 to 3 across the two-byte é.Symbols become enums
Inside a context where the enum type is already known — a
case arm, an assignment to a typed variable — Odin lets you write just .Pending. That leading dot is the implicit selector, and it is the closest Odin comes to the lightness of a Ruby symbol.status = :pending
case status
when :pending then puts "waiting"
when :shipped then puts "on its way"
when :delivered then puts "done"
end
# A symbol is an interned string. Nothing stops you
# from writing one that means nothing:
typo = :shippped
puts typo.inspect
puts typo == :shippedpackage main
import "core:fmt"
Status :: enum {
Pending,
Shipped,
Delivered,
}
main :: proc() {
status := Status.Pending
switch status {
case .Pending: fmt.println("waiting")
case .Shipped: fmt.println("on its way")
case .Delivered: fmt.println("done")
}
// A misspelling is a compile error, not a silent miss.
// typo := Status.Shippped // Error: undeclared name
// %v prints the member's name, and the whole set is
// enumerable without you writing it out.
for member in Status {
fmt.println(member, int(member))
}
}This is one of the clearest wins on the page. A symbol typo in Ruby produces a
case that silently matches nothing; the same typo in Odin does not compile. The switch is also checked for exhaustiveness, so adding a fourth status makes the compiler point at every place that needs updating.Parsing and converting
Odin procedures that can fail return an extra
bool or error value, and this is the first appearance of the multiple-return pattern that the error-handling section builds on. Ignoring the second value is possible but conspicuous.puts "42".to_i
puts "3.14".to_f
puts 42.to_s
puts "not a number".to_i # 0, silently
# Integer() raises instead of guessing:
begin
Integer("not a number")
rescue ArgumentError => error
puts "raised: #{error.message}"
endpackage main
import "core:fmt"
import "core:strconv"
main :: proc() {
// Every parse reports whether it succeeded. There is no
// version that guesses.
count, count_ok := strconv.parse_int("42")
fmt.println(count, count_ok)
ratio, ratio_ok := strconv.parse_f64("3.14")
fmt.println(ratio, ratio_ok)
bad, bad_ok := strconv.parse_int("not a number")
fmt.println(bad, bad_ok) // 0 false
if !bad_ok {
fmt.println("the caller has to notice")
}
rendered := fmt.tprintf("%v", 42)
fmt.println(rendered, len(rendered))
}Ruby offers both shapes —
"x".to_i guesses and returns 0, Integer("x") raises. Odin only offers the reporting shape, so the 0 that a bad parse produces always arrives alongside the false that explains it.Collections
Array becomes three types
Ruby's single
Array splits into three distinct types. The one to internalize is the slice: a pointer plus a length that borrows storage it does not own, so passing one copies two words rather than the elements.# One Array class covers every case.
numbers = [1, 2, 3]
numbers << 4
numbers.push(5)
puts numbers.inspect
puts numbers.length
puts numbers.first
puts numbers[1..2].inspect
mixed = [1, "two", :three, nil]
puts mixed.inspectpackage main
import "core:fmt"
main :: proc() {
// 1. Fixed array — length is part of the type, lives on
// the stack, copies whole when assigned.
fixed := [3]int{1, 2, 3}
fmt.println(fixed, len(fixed))
// 2. Slice — a pointer and a length into someone else's
// memory. This is the type you pass around.
window := fixed[1:]
fmt.println(window, len(window))
// 3. Dynamic array — grows, and owns its buffer.
numbers: [dynamic]int
defer delete(numbers)
append(&numbers, 1, 2, 3)
append(&numbers, 4)
fmt.println(numbers, len(numbers), cap(numbers))
fmt.println(numbers[0])
fmt.println(numbers[1:3])
}The other half of the split is that every element must be the same type. Ruby's
[1, "two", :three, nil] has no direct equivalent — you would reach for a slice of a tagged union, which the unions section covers, and the compiler would then make you handle every variant.Growing and shrinking
append, pop, inject_at, and clear are builtins that take a pointer to the dynamic array, so they can reallocate its buffer. Read-only helpers such as slice.contains take a plain slice, which is why you see stack[:] at those call sites.stack = []
stack.push("a")
stack.push("b")
stack << "c"
puts stack.pop
puts stack.inspect
stack.unshift("z")
puts stack.shift
puts stack.length
puts stack.include?("a")
puts stack.index("b").inspectpackage main
import "core:fmt"
import "core:slice"
main :: proc() {
stack: [dynamic]string
defer delete(stack)
append(&stack, "a")
append(&stack, "b", "c")
last := pop(&stack)
fmt.println(last)
fmt.println(stack)
inject_at(&stack, 0, "z")
first := pop_front(&stack)
fmt.println(first, len(stack))
fmt.println(slice.contains(stack[:], "a"))
position, found := slice.linear_search(stack[:], "b")
fmt.println(position, found)
}Ruby's
Array#index returns the position or nil, and the caller distinguishes them. Odin's slice.linear_search returns the position and a bool, which is the same information without the sentinel — a shape you will see everywhere once you start reading Odin code.Hash becomes map
Two things to keep straight:
delete_key removes one entry, while delete frees the whole map — which is why the defer delete(ages) on line two is the map's cleanup, not a removal. Map iteration yields (key, value), matching Ruby's order.ages = { "ada" => 36, "grace" => 45 }
ages["alan"] = 41
puts ages["ada"]
puts ages["nobody"].inspect # nil
puts ages.key?("grace")
puts ages.size
ages.each do |name, age|
puts "#{name}: #{age}"
end
ages.delete("alan")
puts ages.keys.inspectpackage main
import "core:fmt"
main :: proc() {
ages := make(map[string]int)
defer delete(ages)
ages["ada"] = 36
ages["grace"] = 45
ages["alan"] = 41
fmt.println(ages["ada"])
// A missing key gives the zero value, so ask for the
// second return when absence actually matters.
age, found := ages["nobody"]
fmt.println(age, found) // 0 false
fmt.println("grace" in ages)
fmt.println(len(ages))
for name, value in ages {
fmt.printf("%v: %v\n", name, value)
}
delete_key(&ages, "alan")
fmt.println(len(ages))
}Ruby returns
nil for a missing key and lets you configure a different default with Hash.new(0). Odin always returns the value type's zero, so a map of counters already behaves like Hash.new(0) — and when zero is a legitimate stored value, the second return value is the only honest way to tell the two apart.Sets
bit_set[T] packs a set of enum members into a single integer, so membership is a bit test and union and intersection are | and & on that integer. card is the population count.require "set"
seen = Set.new
seen << "ada"
seen << "grace"
seen << "ada"
puts seen.size
puts seen.include?("ada")
other = Set["grace", "alan"]
puts (seen & other).to_a.inspect
puts (seen | other).to_a.sort.inspectpackage main
import "core:fmt"
Permission :: enum { Read, Write, Execute }
Permissions :: bit_set[Permission]
main :: proc() {
// For a small enum, a bit_set IS the set — one machine
// word, with real set operators.
granted: Permissions = {.Read, .Write}
required: Permissions = {.Write, .Execute}
fmt.println(.Read in granted)
fmt.println(card(granted))
fmt.println(granted & required)
fmt.println(granted | required)
// For arbitrary keys, a map to an empty struct is the
// idiom — the value costs zero bytes.
seen := make(map[string]struct{})
defer delete(seen)
seen["ada"] = {}
seen["grace"] = {}
seen["ada"] = {}
fmt.println(len(seen), "grace" in seen)
}Ruby's
Set is a Hash in a trench coat, and it works for anything hashable. Odin splits the job by what you actually have: a closed set of named states becomes a bit_set that costs one word and no allocation, and everything else becomes a map.Ranges
Odin spells its ranges
..< (exclusive) and ..= (inclusive) — deliberately unambiguous, because Ruby's .. versus ... is a one-character difference with an off-by-one consequence.(0...5).each { |index| print index, " " }
puts
(0..5).each { |index| print index, " " }
puts
puts (1..10).to_a.inspect
puts (1..10).include?(7)
puts ("a".."e").to_a.inspect
# A Range is an object you can store and pass around.
window = (2..4)
puts [10, 20, 30, 40, 50][window].inspectpackage main
import "core:fmt"
main :: proc() {
// ..< is exclusive, ..= is inclusive. These are loop
// and switch syntax, not values you can store.
for index in 0 ..< 5 {
fmt.print(index, "")
}
fmt.println()
for index in 0 ..= 5 {
fmt.print(index, "")
}
fmt.println()
// Ranges also appear in switch arms:
score := 7
switch score {
case 0 ..< 5: fmt.println("low")
case 5 ..= 10: fmt.println("high")
}
// Slicing uses the exclusive half-open form:
values := [5]int{10, 20, 30, 40, 50}
fmt.println(values[2:5])
}The real difference is that an Odin range is syntax, valid in a
for header and a switch arm and nowhere else. Ruby's Range is an object you can store in a variable, pass to a method, or use as a hash key — none of which has an Odin equivalent.Blocks & Iteration
each becomes for
Odin has exactly one loop keyword,
for, and it covers ranges, slices, maps, strings, and the C-style three-clause form. Iterating a slice yields (value, index) — the value first, which is the opposite of each_with_index.fruits = ["apple", "banana", "cherry"]
fruits.each do |fruit|
puts fruit
end
fruits.each_with_index do |fruit, index|
puts "#{index}: #{fruit}"
end
3.times { |index| puts "round #{index}" }package main
import "core:fmt"
main :: proc() {
fruits := []string{"apple", "banana", "cherry"}
for fruit in fruits {
fmt.println(fruit)
}
// The index is the SECOND value, not the first.
for fruit, index in fruits {
fmt.printf("%v: %v\n", index, fruit)
}
for round in 0 ..< 3 {
fmt.println("round", round)
}
}Ruby's
each is a method that takes a block, which is why 3.times, Integer#upto, and a custom each on your own class all look alike. Odin's for is a keyword the compiler understands, so it works on the types the language knows about and cannot be extended to yours.map, select, reduce
There is no
map, select, or reduce in Odin, and the reason is structural rather than an oversight: those methods need a closure, and Odin has none. What is left is the loop.numbers = [1, 2, 3, 4, 5, 6]
doubled = numbers.map { |number| number * 2 }
evens = numbers.select { |number| number.even? }
total = numbers.reduce(0) { |sum, number| sum + number }
puts doubled.inspect
puts evens.inspect
puts total
# Chaining is the everyday shape of Ruby:
result = numbers.select(&:even?).map { |n| n * n }.sum
puts resultpackage main
import "core:fmt"
main :: proc() {
numbers := []int{1, 2, 3, 4, 5, 6}
doubled: [dynamic]int
defer delete(doubled)
for number in numbers {
append(&doubled, number * 2)
}
fmt.println(doubled)
evens: [dynamic]int
defer delete(evens)
for number in numbers {
if number % 2 == 0 {
append(&evens, number)
}
}
fmt.println(evens)
total := 0
for number in numbers {
total += number
}
fmt.println(total)
// The chained version collapses into one loop.
sum_of_even_squares := 0
for number in numbers {
if number % 2 == 0 {
sum_of_even_squares += number * number
}
}
fmt.println(sum_of_even_squares)
}This is the biggest day-to-day adjustment on the page — larger than losing the garbage collector, because you touch it in every procedure you write rather than once per subsystem. The honest consolation is the last example: a three-stage Ruby chain that allocates two intermediate arrays becomes one loop over the data with no allocation at all.
No closures
A procedure literal in Odin is a plain function pointer. It has no environment attached, so referring to a surrounding local inside one is a compile error rather than a capture.
def make_counter
count = 0
-> { count += 1 } # captures count
end
counter = make_counter
puts counter.call
puts counter.call
puts counter.call
# Blocks capture too, which is what makes
# each_with_object and instance_eval possible.
running = 0
[1, 2, 3].each { |number| running += number }
puts runningpackage main
import "core:fmt"
// A procedure literal CANNOT capture its surrounding scope.
// State that a Ruby closure would capture becomes a struct
// you pass in explicitly.
Counter :: struct {
count: int,
}
counter_next :: proc(counter: ^Counter) -> int {
counter.count += 1
return counter.count
}
main :: proc() {
counter := Counter{}
fmt.println(counter_next(&counter))
fmt.println(counter_next(&counter))
fmt.println(counter_next(&counter))
running := 0
numbers := []int{1, 2, 3}
for number in numbers {
running += number
}
fmt.println(running)
}Making the captured state an explicit struct is more typing, and it is also exactly what Ruby builds behind the scenes when it heap-allocates a binding for the closure. The Odin version has no hidden allocation and no lifetime question about how long that binding survives.
Passing behavior around
Odin does have first-class procedures —
proc(value: int) -> int is a type like any other. The restriction is only that the procedure carries no captured environment, so it is a bare code pointer rather than Ruby's Proc.def apply_twice(value, &operation)
operation.call(operation.call(value))
end
puts apply_twice(3) { |number| number * 10 }
# Method objects and symbols-to-proc are the other
# two ways Ruby passes behavior:
doubler = method(:puts)
doubler.call("called through a Method object")
puts [1, 2, 3].map(&:to_s).inspectpackage main
import "core:fmt"
// A procedure VALUE has a type: proc(int) -> int
apply_twice :: proc(value: int, operation: proc(value: int) -> int) -> int {
return operation(operation(value))
}
times_ten :: proc(value: int) -> int {
return value * 10
}
main :: proc() {
fmt.println(apply_twice(3, times_ten))
// A literal works too, as long as it captures nothing:
fmt.println(apply_twice(3, proc(value: int) -> int {
return value + 1
}))
// Procedures are ordinary values you can store:
operation := times_ten
fmt.println(operation(5))
}This covers a good share of what blocks are used for: a comparator, a callback, a strategy chosen at runtime. What it cannot cover is the case where the behavior needs to remember something, and that is precisely the case Ruby uses blocks for most often.
Sorting with a comparator
slice.sort_by sorts in place, which is why the example clones the source first — and the clone then needs its own delete. Ruby's sort returns a new array and sort! mutates; Odin only has the mutating form.words = ["cherry", "apple", "banana"]
puts words.sort.inspect
puts words.sort_by(&:length).inspect
puts words.sort { |a, b| b <=> a }.inspect
puts words.max_by(&:length)
puts words.any? { |word| word.start_with?("b") }package main
import "core:fmt"
import "core:slice"
import "core:strings"
main :: proc() {
source := []string{"cherry", "apple", "banana"}
words := slice.clone(source)
defer delete(words)
slice.sort(words)
fmt.println(words)
// The comparator is a non-capturing procedure literal:
// "is a strictly before b?"
slice.sort_by(words, proc(a, b: string) -> bool {
return len(a) < len(b)
})
fmt.println(words)
slice.reverse_sort(words)
fmt.println(words)
fmt.println(slice.any_of_proc(words, proc(word: string) -> bool {
return strings.has_prefix(word, "b")
}))
}A comparator is the ideal case for procedure values, because it genuinely needs nothing from the surrounding scope. The moment you want
sort_by { |item| item.distance_to(origin) }, where origin is a local, the pattern breaks and you write the loop yourself.Control Flow
if, unless, and modifiers
Braces are mandatory and parentheses around the condition are not used. Odin's ternary reads in English word order —
value if condition else other — rather than C's ? :.temperature = 22
if temperature > 30
puts "hot"
elsif temperature > 15
puts "mild"
else
puts "cold"
end
puts "not freezing" unless temperature < 0
puts "comfortable" if (18..25).cover?(temperature)
label = temperature > 20 ? "warm" : "cool"
puts labelpackage main
import "core:fmt"
main :: proc() {
temperature := 22
if temperature > 30 {
fmt.println("hot")
} else if temperature > 15 {
fmt.println("mild")
} else {
fmt.println("cold")
}
// No unless, no trailing modifiers.
if !(temperature < 0) {
fmt.println("not freezing")
}
// An if may open with a statement, scoped to the if:
if adjusted := temperature + 2; adjusted > 20 {
fmt.println("adjusted is", adjusted)
}
label := "warm" if temperature > 20 else "cool"
fmt.println(label)
}Losing the trailing
if modifier costs a line each time, and losing unless costs a !. The if statement; condition form pays some of it back: the variable exists only inside the branch, which is the scoping Ruby cannot give you.case becomes switch
An empty
case: is the default arm. Odin borrows Ruby's no-fall-through behavior rather than C's, so an arm ends by itself and fallthrough is the explicit opt-in.value = 7
case value
when 0 then puts "zero"
when 1..5 then puts "small"
when 6..10 then puts "medium"
when Integer then puts "some other integer"
else puts "not a number at all"
end
# case/when uses ===, so it matches classes, ranges,
# regexes, and anything defining ===.
case "hello world"
when /world/ then puts "matched a regex"
endpackage main
import "core:fmt"
main :: proc() {
value := 7
switch value {
case 0:
fmt.println("zero")
case 1 ..= 5:
fmt.println("small")
case 6 ..= 10:
fmt.println("medium")
case:
fmt.println("something else")
}
// Cases do NOT fall through — no break needed. Ask for
// fallthrough explicitly when you want it.
switch value {
case 7:
fmt.println("seven")
fallthrough
case 8:
fmt.println("...and the arm below it")
}
}Ruby's
case/when is powered by ===, which any class can define — that is how it matches regexes, classes, ranges, and lambdas through one syntax. Odin's switch compares values and ranges of a single known type; the open-ended matching moves to the switch in over a tagged union in the unions section.while, until, loop
One keyword covers every loop:
for condition is a while loop, bare for is infinite, and for initializer; condition; step is the C form. Ruby's next is spelled continue.countdown = 3
while countdown > 0
puts countdown
countdown -= 1
end
remaining = 2
until remaining.zero?
puts "remaining #{remaining}"
remaining -= 1
end
index = 0
loop do
index += 1
next if index == 2
break if index > 3
puts "index #{index}"
endpackage main
import "core:fmt"
main :: proc() {
// for with one condition is a while loop.
countdown := 3
for countdown > 0 {
fmt.println(countdown)
countdown -= 1
}
// There is no until — negate the condition.
remaining := 2
for remaining != 0 {
fmt.println("remaining", remaining)
remaining -= 1
}
// A bare for is an infinite loop.
index := 0
for {
index += 1
if index == 2 { continue }
if index > 3 { break }
fmt.println("index", index)
}
}Ruby also has
redo, retry in a rescue, and loop-like enumerator methods such as each_slice and each_cons. Odin has break, continue, and labeled versions of both for breaking out of a nested loop — which is genuinely useful and has no clean Ruby equivalent.defer
defer schedules a statement to run when the enclosing scope exits, by any path. Deferred statements run in reverse order, so cleanup naturally unwinds the setup that preceded it.def process
puts "opening"
begin
puts "working"
return "done"
ensure
puts "closing"
end
end
puts process
# ensure runs on the way out, however you leave —
# return, raise, or falling off the end.package main
import "core:fmt"
process :: proc() -> string {
fmt.println("opening")
defer fmt.println("closing")
fmt.println("working")
return "done"
}
main :: proc() {
fmt.println(process())
// Deferred statements run in reverse order at scope exit,
// so cleanup unwinds in the opposite order of setup.
{
defer fmt.println("third")
defer fmt.println("second")
fmt.println("first")
}
}The important difference from
ensure is placement. ensure sits at the bottom of the block, far from the File.open it balances; defer sits on the very next line, so a reviewer sees the acquisition and the release together. This keyword is what makes manual memory management bearable, and it appears constantly from here on.Methods & Procedures
Defining a procedure
A procedure is declared like every other constant:
name :: proc(parameters) -> results { }. Parameters sharing a type can share one annotation, as a, b: int does, and return is required — there is no implicit last-expression result.def greet(name)
"Hello, #{name}!"
end
def add(a, b)
a + b # the last expression is the return value
end
def shout(text) = text.upcase # one-liner form
puts greet("Ada")
puts add(2, 3)
puts shout("quiet")package main
import "core:fmt"
greet :: proc(name: string) -> string {
return fmt.tprintf("Hello, %v!", name)
}
add :: proc(a, b: int) -> int {
return a + b
}
main :: proc() {
fmt.println(greet("Ada"))
fmt.println(add(2, 3))
}Odin has no method-versus-function distinction, because there are no objects for a method to belong to. Everything is a procedure at package scope, and the thing it operates on is its first parameter.
Default and named arguments
Any parameter with a default can be passed by name with
name = value, in any order. Odin does not need Ruby's separation between positional defaults and keyword arguments — one mechanism covers both.def connect(host, port: 80, secure: false)
scheme = secure ? "https" : "http"
"#{scheme}://#{host}:#{port}"
end
puts connect("example.com")
puts connect("example.com", port: 8080)
puts connect("example.com", secure: true, port: 443)package main
import "core:fmt"
connect :: proc(host: string, port := 80, secure := false) -> string {
scheme := "https" if secure else "http"
return fmt.tprintf("%v://%v:%v", scheme, host, port)
}
main :: proc() {
fmt.println(connect("example.com"))
fmt.println(connect("example.com", port = 8080))
fmt.println(connect("example.com", secure = true, port = 443))
}This lands very close to modern Ruby, and the differences are small:
= rather than : at the call site, and a default value that must be a compile-time constant. Ruby's def find(id, at: Time.now), where the default is evaluated per call, has no direct equivalent.Returning more than one value
Multiple return values are a language feature here rather than a returned array, so nothing is allocated and each result keeps its own type. Naming them —
(quotient: int, ok: bool) — is optional for readability but required before you can use or_return.def divide(numerator, denominator)
return nil, "division by zero" if denominator.zero?
[numerator / denominator, nil]
end
quotient, error = divide(10, 2)
puts quotient.inspect
quotient, error = divide(10, 0)
puts error
# Ruby fakes this by returning an array and destructuring it.
first, *rest = [1, 2, 3, 4]
puts first, rest.inspectpackage main
import "core:fmt"
// Multiple results are part of the type, not an array in
// disguise. Naming them documents the call site.
divide :: proc(numerator, denominator: int) -> (quotient: int, ok: bool) {
if denominator == 0 {
return 0, false
}
return numerator / denominator, true
}
main :: proc() {
quotient, ok := divide(10, 2)
fmt.println(quotient, ok)
_, failed := divide(10, 0)
fmt.println(failed)
// Ignoring a result requires the explicit blank _,
// so it is always visible in review.
}Ruby's version costs an array allocation and gives both values the same static type, which is to say none. The
_ for a discarded result is Odin insisting you say so out loud; there is no equivalent of quietly using only the first element of a returned pair.Splat arguments
A variadic parameter is written
..T and arrives as a slice; the same .. at a call site spreads an existing slice, which is Ruby's *array. fmt.println itself is variadic over ..any, which is why it accepts anything.def total(*numbers)
numbers.sum
end
puts total(1, 2, 3)
puts total(*[4, 5, 6])
def describe(**options)
options.map { |key, value| "#{key}=#{value}" }.join(" ")
end
puts describe(host: "localhost", port: 80)package main
import "core:fmt"
// ..T is a typed variadic. Inside the procedure it is an
// ordinary slice.
total :: proc(numbers: ..int) -> int {
sum := 0
for number in numbers {
sum += number
}
return sum
}
main :: proc() {
fmt.println(total(1, 2, 3))
// Spread an existing slice with ..
existing := []int{4, 5, 6}
fmt.println(total(..existing))
// There is no **options. A struct with defaults is
// the equivalent, and it is type-checked.
Options :: struct {
host: string,
port: int,
}
options := Options{host = "localhost", port = 80}
fmt.printf("host=%v port=%v\n", options.host, options.port)
}Ruby's
**options hash has no equivalent, and that is mostly for the better: a struct with named fields gives you the same call-site readability, plus a compiler that catches prot: when you meant port:.Parameters are immutable
Parameters in Odin are immutable bindings — the compiler rejects assigning to one. Mutating a caller's value requires taking
^T and being passed &value, so both sides of the call show it.def normalize(name)
name = name.strip.downcase # rebinding the parameter
name
end
puts normalize(" Ada ")
def append_to(list)
list << "added" # mutating the caller's array
end
items = ["original"]
append_to(items)
puts items.inspectpackage main
import "core:fmt"
import "core:strings"
normalize :: proc(name: string) -> string {
// name = ... // Error: cannot assign to a parameter
trimmed := strings.trim_space(name)
return strings.to_lower(trimmed)
}
// To mutate the caller's value, take a pointer and say so
// in the signature.
append_to :: proc(list: ^[dynamic]string) {
append(list, "added")
}
main :: proc() {
lowered := normalize(" Ada ")
defer delete(lowered)
fmt.println(lowered)
items: [dynamic]string
defer delete(items)
append(&items, "original")
append_to(&items)
fmt.println(items)
}Ruby draws no such line:
name = name.strip quietly rebinds a local while list << "added" quietly mutates the caller's object, and the two look identical at the call site. Odin makes the second one visible as &items, which is worth the extra character.Objects & Structs
Class becomes struct
A struct is a plain layout of fields with no object header, no vtable, and no identity —
size_of(Point) here is exactly eight bytes. Assigning one copies every field, because a struct is a value rather than a reference.class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
end
def to_s = "(#{@x}, #{@y})"
end
point = Point.new(3, 4)
point.x = 10
puts point
puts point.xpackage main
import "core:fmt"
// A struct is data. It has no methods, no identity, and no
// hidden header — just its fields, laid out in order.
Point :: struct {
x: f32,
y: f32,
}
main :: proc() {
point := Point{3, 4}
point.x = 10
fmt.println(point) // Point{x = 10, y = 4}
fmt.println(point.x)
// Fields can be named at the literal too:
origin := Point{x = 0, y = 0}
fmt.println(origin)
// Assignment COPIES the whole struct.
moved := point
moved.y = 99
fmt.println(point.y, moved.y)
}That last block is the difference a Rubyist feels first.
other = point in Ruby gives two names for one object, and mutating through either is visible through both. In Odin it gives two independent points, and sharing requires an explicit ^Point.Methods become procedures
There is no receiver and no
self: the thing a procedure works on is simply its first parameter. Odin auto-dereferences through a pointer, so rectangle.width works whether rectangle is a Rectangle or a ^Rectangle.class Rectangle
def initialize(width, height)
@width = width
@height = height
end
def area = @width * @height
def scale!(factor)
@width *= factor
@height *= factor
self
end
end
rectangle = Rectangle.new(3, 4)
puts rectangle.area
rectangle.scale!(2)
puts rectangle.areapackage main
import "core:fmt"
Rectangle :: struct {
width: int,
height: int,
}
// Read-only: take the struct by value.
rectangle_area :: proc(rectangle: Rectangle) -> int {
return rectangle.width * rectangle.height
}
// Mutating: take a pointer, and the caller writes &.
rectangle_scale :: proc(rectangle: ^Rectangle, factor: int) {
rectangle.width *= factor
rectangle.height *= factor
}
main :: proc() {
rectangle := Rectangle{3, 4}
fmt.println(rectangle_area(rectangle))
rectangle_scale(&rectangle, 2)
fmt.println(rectangle_area(rectangle))
}The naming convention
type_verb — rectangle_area, strings.to_upper, thread.create — does the work that a receiver does in Ruby. Ruby's bang convention for mutation becomes something the signature states outright: ^Rectangle mutates, Rectangle cannot.initialize becomes a make procedure
Nothing runs automatically when a struct comes into existence and nothing runs when it goes out of scope, so a type that owns memory needs a paired
make and destroy, with a defer joining them at the call site.class Buffer
attr_reader :contents
def initialize(capacity)
raise ArgumentError, "capacity must be positive" if capacity <= 0
@contents = Array.new(capacity, 0)
end
end
buffer = Buffer.new(4)
puts buffer.contents.inspect
begin
Buffer.new(0)
rescue ArgumentError => error
puts "raised: #{error.message}"
endpackage main
import "core:fmt"
Buffer :: struct {
contents: []int,
}
// A constructor is a normal procedure returning the value.
// By convention it is named make_* or new_*.
buffer_make :: proc(capacity: int) -> (buffer: Buffer, ok: bool) {
if capacity <= 0 {
return Buffer{}, false
}
return Buffer{contents = make([]int, capacity)}, true
}
buffer_destroy :: proc(buffer: ^Buffer) {
delete(buffer.contents)
}
main :: proc() {
buffer, ok := buffer_make(4)
if !ok {
fmt.println("bad capacity")
return
}
defer buffer_destroy(&buffer)
fmt.println(buffer.contents)
_, second_ok := buffer_make(0)
fmt.println("zero capacity accepted:", second_ok)
}Ruby's
initialize can refuse to produce an object by raising. An Odin constructor has no such escape, so it returns the failure as a value — and a caller who ignores the ok gets a zeroed struct, which is at least a defined state rather than a half-built object.to_s and inspect
The compiler emits full type information for every type, and
fmt walks it at runtime. That is how %v prints a struct, a slice of structs, a map, an enum member's name, or a union's active variant without any method on your part.class Person
def initialize(name, age)
@name = name
@age = age
end
def to_s = "#{@name} (#{@age})"
end
person = Person.new("Ada", 36)
puts person
p person # inspect: shows ivars
puts [person].inspectpackage main
import "core:fmt"
Person :: struct {
name: string,
age: int,
}
main :: proc() {
person := Person{"Ada", 36}
// %v works on every type, with no code from you.
fmt.printf("%v\n", person)
// #v is the expanded form, one field per line.
fmt.printf("%#v\n", person)
people := []Person{person, {"Grace", 45}}
fmt.println(people)
}Ruby gives you a default
inspect too, and you override to_s when it is not good enough. Odin has no override — the formatting is derived from the layout, and a custom representation means writing your own procedure and calling it deliberately.No inheritance
using on a struct field embeds that struct and promotes its fields into the outer type, so dog.name reaches dog.base.name. It is a layout arrangement, not a type relationship — a Dog is never usable where an Animal is expected.class Animal
def initialize(name)
@name = name
end
def speak = "..."
def describe = "#{@name} says #{speak}"
end
class Dog < Animal
def speak = "Woof"
end
puts Dog.new("Rex").describe
puts Dog.ancestors.first(3).inspectpackage main
import "core:fmt"
Animal :: struct {
name: string,
}
// 'using' on a field embeds Animal and promotes its fields,
// so dog.name works directly. This is composition, and it
// is as close to a superclass as Odin gets.
Dog :: struct {
using base: Animal,
breed: string,
}
dog_speak :: proc(dog: Dog) -> string {
return "Woof"
}
main :: proc() {
dog := Dog{base = Animal{name = "Rex"}, breed = "corgi"}
fmt.println(dog.name) // promoted from Animal
fmt.println(dog.base.name) // and still reachable by path
fmt.printf("%v says %v\n", dog.name, dog_speak(dog))
}Nothing here dispatches. Ruby's
describe calling an overridden speak is the whole point of inheritance, and Odin has no mechanism for it: a procedure taking a Dog takes a Dog. Runtime polymorphism has to be built explicitly, which the next section does.Duck Typing & Polymorphism
Duck typing becomes a tagged union
switch specific in shape both tests the active variant and binds it, so inside each arm specific has that concrete type. The union stores its tag alongside the value, so the check is real and the compiler warns when an arm is missing.class Circle
def initialize(radius) = @radius = radius
def area = 3.14159 * @radius ** 2
end
class Square
def initialize(side) = @side = side
def area = @side ** 2
end
# Nothing connects these two classes. The method call
# is resolved by name at the moment it happens.
[Circle.new(2), Square.new(3)].each do |shape|
puts shape.area.round(2)
endpackage main
import "core:fmt"
import "core:math"
Circle :: struct { radius: f32 }
Square :: struct { side: f32 }
// A union lists every member up front. The compiler
// knows which one is active and checks the switch.
Shape :: union {
Circle,
Square,
}
shape_area :: proc(shape: Shape) -> f32 {
switch specific in shape {
case Circle: return math.PI * specific.radius * specific.radius
case Square: return specific.side * specific.side
}
return 0
}
main :: proc() {
circle := Circle{2}
square := Square{3}
shapes := []Shape{circle, square}
for shape in shapes {
fmt.printf("%.2f\n", shape_area(shape))
}
}This is the closed case, and it inverts Ruby's trade deliberately. Adding a shape in Ruby costs nothing and touches nothing; adding one in Odin makes the compiler point at every
switch that now has a hole. Which you prefer depends on whether new variants or new operations arrive more often.Open polymorphism by hand
A
rawptr is an untyped pointer, and cast(^Robot)data converts it back to the concrete type the procedure expects. Nothing checks that the pairing is right — you wrote both halves of the struct literal, so the correctness is yours.module Greeter
def greet = "Hello from #{self.class}"
end
class Robot
include Greeter
end
class Human
include Greeter
def greet = "Hi, I am a person"
end
# Any class, anywhere, can include the module later —
# including classes in gems you do not control.
[Robot.new, Human.new].each { |thing| puts thing.greet }package main
import "core:fmt"
// When the set of implementations must stay open, you
// build the vtable yourself: a data pointer plus the
// procedures that operate on it.
Greeter :: struct {
data: rawptr,
greet: proc(data: rawptr) -> string,
}
Robot :: struct { serial: int }
Human :: struct { name: string }
robot_greet :: proc(data: rawptr) -> string {
robot := cast(^Robot)data
return fmt.tprintf("Hello from robot %v", robot.serial)
}
human_greet :: proc(data: rawptr) -> string {
human := cast(^Human)data
return fmt.tprintf("Hi, I am %v", human.name)
}
main :: proc() {
robot := Robot{7}
human := Human{"Ada"}
greeters := []Greeter{
{&robot, robot_greet},
{&human, human_greet},
}
for greeter in greeters {
fmt.println(greeter.greet(greeter.data))
}
}This is what a Ruby method call compiles down to anyway, minus the cache: find the code for this object, call it with the object. Writing it out is more work and drops all the safety, and the reason to prefer the tagged union above is precisely that it keeps the checking.
any and runtime type info
any is Odin's escape hatch: a rawptr plus a typeid. Because it points at the original value rather than copying it, an any must never outlive what it refers to — which is why it belongs in printing and debugging code, not in data structures.values = [42, "text", 3.14, :symbol]
values.each do |value|
puts "#{value.inspect} is a #{value.class}"
end
puts 42.is_a?(Integer)
puts 42.respond_to?(:upcase)package main
import "core:fmt"
main :: proc() {
// 'any' is a pointer plus a type id. It POINTS AT the
// value — it does not copy or box it.
number := 42
text := "hello"
ratio := 3.14
values := []any{number, text, ratio}
for value in values {
fmt.printf("%v is a %v\n", value, value.id)
}
boxed: any = number
fmt.println(boxed.id == int)
// Recover the concrete type with a switch:
switch specific in boxed {
case int: fmt.println("an int:", specific)
case string: fmt.println("a string:", specific)
}
}Odin does keep runtime type information, so this is not as far from Ruby as the rest of the page suggests —
core:reflect can walk a struct's fields by name. What it cannot do is act on that information: there is no way to call something you discovered at runtime, because there is no method table to look in.Exceptions & Error Values
raise and rescue
An error enum's zero value is the success case, so
error != nil reads as "something went wrong" for enums, unions, and pointers alike. There is no stack unwinding: an error travels back up one return at a time.class InsufficientFunds < StandardError; end
def withdraw(balance, amount)
raise InsufficientFunds, "need #{amount}, have #{balance}" if amount > balance
balance - amount
end
begin
puts withdraw(100, 50)
puts withdraw(100, 500)
rescue InsufficientFunds => error
puts "rescued: #{error.message}"
endpackage main
import "core:fmt"
// There are no exceptions. An error is a value, and the
// idiomatic shape is an enum whose zero value means "fine".
Account_Error :: enum {
None,
Insufficient_Funds,
Account_Frozen,
}
withdraw :: proc(balance, amount: int) -> (remaining: int, error: Account_Error) {
if amount > balance {
return balance, .Insufficient_Funds
}
return balance - amount, .None
}
main :: proc() {
remaining, error := withdraw(100, 50)
fmt.println(remaining, error)
remaining, error = withdraw(100, 500)
if error != nil {
fmt.println("failed:", error)
return
}
fmt.println(remaining)
}The cost is that every caller in the chain must handle or forward the error, where Ruby lets an exception fly past twenty frames to a
rescue at the top. The benefit is that a procedure's signature tells you exactly what can go wrong, which a Ruby method's signature never does.or_return and or_else
or_return is a suffix that collapses if error != nil { return ..., error } into nothing — it takes the last return value, and if it is non-zero, returns it from the enclosing procedure. It only compiles when that procedure's results are named.def parse_port(text)
Integer(text)
end
def build_url(host, port_text)
port = parse_port(port_text) # exception propagates on its own
"http://#{host}:#{port}"
end
begin
puts build_url("example.com", "8080")
puts build_url("example.com", "eighty")
rescue ArgumentError => error
puts "rescued: #{error.class}"
end
# The || fallback is Ruby's or_else:
port = (Integer("nope") rescue nil) || 80
puts portpackage main
import "core:fmt"
import "core:strconv"
Parse_Error :: enum { None, Not_A_Number }
parse_port :: proc(text: string) -> (port: int, error: Parse_Error) {
value, ok := strconv.parse_int(text)
if !ok {
return 0, .Not_A_Number
}
return value, .None
}
// or_return returns early on any non-zero error. It needs
// NAMED return values on the enclosing procedure.
build_url :: proc(host, port_text: string) -> (url: string, error: Parse_Error) {
port := parse_port(port_text) or_return
return fmt.tprintf("http://%v:%v", host, port), .None
}
main :: proc() {
good, _ := build_url("example.com", "8080")
fmt.println(good)
_, error := build_url("example.com", "eighty")
fmt.println("failed:", error)
// or_else supplies a fallback inline.
port := strconv.parse_int("nope") or_else 80
fmt.println(port)
}This gets error-value code close to the density of exceptions for the common case of "pass it up". What it deliberately will not do is skip frames: every procedure between the failure and the handler still declares the error in its signature, so the path is written down instead of inferred.
ensure becomes defer
A
defer runs on every exit from its scope, including an early return in an error branch — which is what makes it a complete replacement for ensure even though nothing here unwinds a stack.def with_resource
puts "acquire"
yield
ensure
puts "release"
end
with_resource { puts "using it" }
begin
with_resource { raise "boom" }
rescue => error
puts "rescued: #{error.message}"
endpackage main
import "core:fmt"
Work_Error :: enum { None, Failed }
do_work :: proc(should_fail: bool) -> (error: Work_Error) {
fmt.println("acquire")
defer fmt.println("release")
if should_fail {
return .Failed // the defer still runs
}
fmt.println("using it")
return .None
}
main :: proc() {
fmt.println(do_work(false))
fmt.println(do_work(true))
}Ruby's block-taking
with_resource guarantees the release, because the caller cannot forget to pass a block. Odin's defer is one line the author must remember to write, and forgetting it is the single most common source of leaks when moving to this language.Assertions and panics
assert and panic abort the process. Odin has no recover, so a panic is genuinely the end — which is why it is reserved for conditions that mean the program is wrong, not for failures a caller could handle.def average(numbers)
raise ArgumentError, "empty" if numbers.empty?
numbers.sum.fdiv(numbers.length)
end
puts average([1, 2, 3])
# A bug (as opposed to an expected failure) is still just
# an exception, and can still be rescued:
begin
average([])
rescue ArgumentError
puts "even a programmer error is rescuable"
endpackage main
import "core:fmt"
average :: proc(numbers: []f64) -> f64 {
// assert is for programmer errors: conditions that
// should be impossible if the code is correct.
assert(len(numbers) > 0, "average of an empty slice")
sum := 0.0
for number in numbers {
sum += number
}
return sum / f64(len(numbers))
}
main :: proc() {
values := []f64{1, 2, 3}
fmt.println(average(values))
// A failing assert calls panic, prints the message and
// a stack trace, and aborts. There is no recover.
fmt.println("assertions are compiled out with -o:speed")
}Ruby draws no line here:
raise covers "the file was missing" and "this should be unreachable" alike, and rescue => error catches both, which is how a bug ends up silently swallowed in production. Odin's split is enforced — expected failures are return values, impossible states are panics.Memory & Allocators
There is no garbage collector
Odin has no garbage collector, no reference counting, and no destructors. Memory is released exactly when a
delete, free, or arena teardown runs, and the defer beside the allocation is the convention that keeps the pair together.# Every object here is heap-allocated, tracked, and freed
# for you whenever the collector decides to look.
records = 1000.times.map { |index| { id: index, name: "row #{index}" } }
puts records.length
records = nil
GC.start
puts "the collector handled it"
puts GC.count > 0package main
import "core:fmt"
Record :: struct {
id: int,
name: string,
}
main :: proc() {
// You asked for this memory, so you release it.
records := make([]Record, 1000)
defer delete(records)
for index in 0 ..< len(records) {
records[index] = Record{id = index}
}
fmt.println(len(records))
// Nothing runs in the background. Nothing pauses. The
// only deallocation that happens is the one you wrote.
fmt.println("released at the end of this scope")
}It is worth being clear about what this costs and buys. You lose the freedom to allocate without thinking, and you gain the absence of collection pauses and the ability to say precisely when memory goes away. For a game frame or an audio callback that predictability is the entire point; for a script that runs once and exits, Ruby's bargain is obviously better.
new and free
new(T) allocates one zeroed T on the heap and returns a ^T; free gives it back. The ^ is both the pointer type (^Node) and, as a suffix, the dereference — though field access auto-dereferences, so you rarely write it.class Node
attr_accessor :value, :next_node
def initialize(value)
@value = value
end
end
first = Node.new(1)
first.next_node = Node.new(2)
puts first.value
puts first.next_node.value
# Nothing to release. When nothing references them,
# they go away eventually.package main
import "core:fmt"
Node :: struct {
value: int,
next_node: ^Node,
}
main :: proc() {
first := new(Node)
defer free(first)
first.value = 1
second := new(Node)
defer free(second)
second.value = 2
first.next_node = second
fmt.println(first.value)
fmt.println(first.next_node.value)
// new(T) allocates one zeroed T and returns ^T.
// free takes the pointer back.
fmt.println(first.next_node.next_node == nil)
}A linked structure is where the absence of a collector is felt most sharply, because ownership stops being obvious: two nodes pointing at each other have no natural owner, and neither
defer free knows about the other. The answer in practice is usually the arena two rows down — allocate the whole graph in one region and drop the region.Containers must be released
The rule that makes this manageable: whoever asked for the allocation owns it.
make, append, and any procedure that returns a freshly built string or slice all allocate, and each one wants a matching delete.words = []
words << "alpha"
words << "beta"
lookup = {}
lookup["alpha"] = 1
joined = words.join(", ")
puts joined
puts lookup.inspect
# Three allocations, zero cleanup code.package main
import "core:fmt"
import "core:strings"
main :: proc() {
// Each of these owns a heap buffer.
words: [dynamic]string
defer delete(words)
append(&words, "alpha", "beta")
lookup := make(map[string]int)
defer delete(lookup)
lookup["alpha"] = 1
// So does the result of join.
joined := strings.join(words[:], ", ")
defer delete(joined)
fmt.println(joined)
fmt.println(len(lookup))
}Reading Odin's standard library is largely a matter of asking "does this return something new, or a view into what I passed in?".
strings.join builds a new string and must be deleted; strings.has_prefix only looks. The documentation says which, and getting it wrong leaks rather than crashes — which is why the tracking allocator below exists.Arena allocators
The implicit
context carries the current allocator, and assigning context.allocator redirects every allocation below that point — including inside procedures you call and inside the standard library. Nothing in the signatures changes.# Ruby has one heap and one strategy. You can influence
# the collector, but you cannot say "put these hundred
# objects together and drop them all at once".
def build_report
rows = 100.times.map { |index| "row #{index}" }
rows.first(2)
end
puts build_report.inspect
GC.startpackage main
import "core:fmt"
import "core:mem/virtual"
main :: proc() {
// An arena hands out memory by bumping a pointer and
// frees EVERYTHING in one call. No per-object bookkeeping.
arena: virtual.Arena
_ = virtual.arena_init_growing(&arena)
defer virtual.arena_destroy(&arena)
{
// Redirect every allocation in this scope, including
// ones inside procedures we call.
context.allocator = virtual.arena_allocator(&arena)
rows: [dynamic]string
for index in 0 ..< 100 {
append(&rows, fmt.aprintf("row %v", index))
}
fmt.println(rows[0], rows[1])
// No delete anywhere. The arena owns all of it.
}
fmt.println("one teardown released a hundred allocations")
}This is the feature that makes life without a collector practical, and it has no Ruby counterpart at all. Lifetimes that are genuinely bulk — everything a request touched, everything a frame drew — stop needing per-object ownership: allocate into the arena, reset it when the phase ends.
The temporary allocator
Every allocating procedure in
core: comes in three flavors distinguished by prefix: t for the temporary arena, a for the caller-owned heap, and none for writing into a buffer you supply. Choosing the prefix is how you choose the lifetime.def label_for(index)
"item #{index}" # a new String, collected later
end
3.times { |index| puts label_for(index) }
# Short-lived strings are the single most common
# allocation in Ruby, and also the least thought about.package main
import "core:fmt"
label_for :: proc(index: int) -> string {
// tprintf allocates in the TEMPORARY allocator: an arena
// meant for values that die before the next frame or
// request boundary. No delete, no ownership question.
return fmt.tprintf("item %v", index)
}
main :: proc() {
for index in 0 ..< 3 {
fmt.println(label_for(index))
}
// Release the whole temporary arena at a point you choose.
free_all(context.temp_allocator)
fmt.println("temporary storage reset")
}The temporary allocator is the closest Odin comes to Ruby's "just make a string and forget it", and the difference is where the sweep happens: you call
free_all at a boundary you picked, instead of a collector choosing a moment you did not.Finding leaks
A tracking allocator wraps another allocator and records every outstanding allocation together with the source location that requested it —
#caller_location is threaded through the allocator interface, so the file and line are the ones in your code.require "objspace"
before = ObjectSpace.count_objects[:TOTAL]
leaked = 1000.times.map { |index| "row #{index}" }
after = ObjectSpace.count_objects[:TOTAL]
puts after > before
puts leaked.length
# A leak in Ruby means "still referenced", and finding one
# means asking who still points at it.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)
released := make([]int, 10)
delete(released)
forgotten := make([]int, 10)
_ = forgotten // deliberately never deleted
fmt.println("outstanding allocations:", len(tracker.allocation_map))
for _, entry in tracker.allocation_map {
fmt.printf(" %v bytes from %v:%v\n",
entry.size, entry.location.file_path, entry.location.line)
}
}
}A leak means opposite things in the two languages. In Ruby it means something still holds a reference and the collector is right to keep the object; finding it means tracing who points at what. In Odin it means a
delete was never written, and this tool names the exact line that allocated it — a considerably shorter investigation.Modules & Packages
require becomes import
Odin's compilation unit is the directory, so splitting a package across files needs no declaration in either file — they simply share a namespace. An import is always qualified at the point of use; there is no way to pull names into the current scope.
# One file, requiring another by path:
# require_relative "geometry"
# require "json" a gem or the stdlib
#
# require executes the file, and whatever constants it
# defined are now in the global namespace.
require "json"
puts JSON.generate([1, 2, 3])
puts defined?(JSON)// A DIRECTORY is a package. Every .odin file in it shares
// one namespace, declaration order does not matter, and
// there are no headers, prototypes, or include guards.
//
// geometry/vector.odin package geometry
// geometry/matrix.odin package geometry
// main.odin import "geometry"
package main
import "core:fmt"
import "core:encoding/json"
// The import name is the last path segment; alias it when
// two packages would collide:
import string_helpers "core:strings"
main :: proc() {
encoded, _ := json.marshal([]int{1, 2, 3})
defer delete(encoded)
fmt.println(string(encoded))
fmt.println(string_helpers.to_upper("aliased"))
}Ruby's
require runs a file for its side effects and everything it defined lands in one global namespace, which is why gem authors nest everything under a module by convention. Odin's qualification is mandatory rather than conventional, so json.marshal and string_helpers.to_upper can never shadow each other.No mixins
The embedding from the inheritance row is also the answer to mixins:
using timestamps: Timestamps puts the fields in the outer type, and the procedures that operate on ^Timestamps are called with &document.timestamps.module Timestamped
def touch
@updated_at = "2026-07-24"
self
end
def updated_at = @updated_at
end
class Document
include Timestamped
end
document = Document.new.touch
puts document.updated_at
puts Document.include?(Timestamped)
puts Comparable.instance_methods.sort.inspectpackage main
import "core:fmt"
// There are no mixins, no include, and no Comparable.
// Shared behavior is a shared field plus shared procedures.
Timestamps :: struct {
updated_at: string,
}
Document :: struct {
using timestamps: Timestamps,
title: string,
}
timestamps_touch :: proc(timestamps: ^Timestamps) {
timestamps.updated_at = "2026-07-24"
}
main :: proc() {
document := Document{title = "notes"}
// The embedded field is reachable directly...
timestamps_touch(&document.timestamps)
fmt.println(document.updated_at)
fmt.println(document.title)
}What is missing is the dispatch, again.
Comparable gives a Ruby class five methods in exchange for one <=>, and that trade depends on the module calling back into the class. Odin has no callback to make, so the shared code has to work on the shared data and nothing else.private
Visibility is an attribute written above the declaration, and it is enforced at compile time by the package system rather than by a runtime check on a method table.
class Cache
def fetch(key)
normalize(key)
end
private
def normalize(key) = key.to_s.downcase
end
cache = Cache.new
puts cache.fetch(:KEY)
begin
cache.normalize(:KEY)
rescue NoMethodError => error
puts "private: #{error.message[0, 30]}"
end
# ...and send bypasses it entirely.
puts cache.send(:normalize, :KEY)package main
import "core:fmt"
import "core:strings"
// @(private) hides a declaration from other packages.
// @(private = "file") hides it from other files in this
// same package — the tighter of the two.
@(private = "file")
normalize :: proc(key: string) -> string {
return strings.to_lower(key)
}
fetch :: proc(key: string) -> string {
return normalize(key)
}
main :: proc() {
lowered := fetch("KEY")
defer delete(lowered)
fmt.println(lowered)
// From another package, normalize does not exist at all.
// There is no send, no __send__, and no reflection that
// reaches it.
}Ruby's
private is a runtime rule about receivers that send steps around, which is why so many gems reach into other gems' internals. Odin's is a compile-time fact with no escape hatch — private really is unreachable, for you and for everybody else.Metaprogramming & Compile Time
No open classes
Because a procedure is not attached to a type, adding an operation never requires reopening anything —
shout is a free procedure taking a string, and that is all "extending" ever means in Odin.class String
def shout = upcase + "!"
end
puts "hello".shout
# Every class in the process, including the ones the
# standard library and every gem defined, is open to this.
class Integer
def double = self * 2
end
puts 21.doublepackage main
import "core:fmt"
import "core:strings"
// A type's members are fixed at its declaration. You cannot
// add to string, to int, or to a type another package owns.
// A new operation is just a new procedure.
shout :: proc(text: string) -> string {
upper := strings.to_upper(text)
defer delete(upper)
return fmt.tprintf("%v!", upper)
}
double :: proc(value: int) -> int {
return value * 2
}
main :: proc() {
fmt.println(shout("hello"))
fmt.println(double(21))
}The loss is smaller than it first appears. What you cannot do is make other people's code call your addition, which is precisely the power that makes monkey patching both wonderful and dangerous. Reading Odin, a call to
shout is findable by grepping for shout ::, and there is exactly one.No method_missing, no define_method
A named field and a runtime key are different things in Odin, and the language will not blur them. Something looked up by a string that varies at runtime lives in a map; something known when you wrote the code is a struct field.
class Settings
def initialize(values) = @values = values
def method_missing(name, *args)
return @values[name] if @values.key?(name)
super
end
def respond_to_missing?(name, include_private = false)
@values.key?(name) || super
end
end
settings = Settings.new({ host: "localhost", port: 80 })
puts settings.host
puts settings.port
puts settings.respond_to?(:host)
# define_method builds methods from data at load time:
class Model
[:name, :email].each do |field|
define_method(field) { "value of #{field}" }
end
end
puts Model.new.emailpackage main
import "core:fmt"
// None of that exists. There is no message dispatch to
// intercept, because there are no messages — a call is
// resolved to an address at compile time.
//
// Data that varies at runtime stays DATA:
Settings :: struct {
values: map[string]string,
}
settings_get :: proc(settings: Settings, key: string) -> (value: string, found: bool) {
value, found = settings.values[key]
return
}
main :: proc() {
settings := Settings{values = make(map[string]string)}
defer delete(settings.values)
settings.values["host"] = "localhost"
settings.values["port"] = "80"
host, found := settings_get(settings, "host")
fmt.println(host, found)
missing, present := settings_get(settings, "nope")
fmt.println(missing == "", present)
}This is the deepest difference on the page. Ruby's object model is a runtime data structure you can rewrite from inside the program, which is what makes ActiveRecord attributes and RSpec's DSL possible. Odin resolves every call at compile time, so a whole category of library design simply does not exist — and neither does the ten-frame stack trace through
method_missing when it goes wrong.Compile-time when
when is the compile-time sibling of if: its condition must be a constant expression, and the untaken branch is discarded before type checking. That is what makes it safe to reference platform-specific procedures inside one.# Ruby decides everything at runtime, including what to
# require and which definition wins.
if RUBY_PLATFORM.include?("darwin")
PLATFORM_NAME = "macOS"
else
PLATFORM_NAME = "something else"
end
puts PLATFORM_NAME
puts RUBY_VERSION
# Conditional definition is just an if around a def:
require "json"
if defined?(JSON)
def encode(value) = JSON.generate(value)
end
puts encode([42])package main
import "core:fmt"
main :: proc() {
// 'when' is evaluated by the COMPILER. The branch not
// taken is never type-checked and never emitted.
when ODIN_OS == .Darwin {
platform_name := "macOS"
fmt.println(platform_name)
} else when ODIN_OS == .Linux {
platform_name := "Linux"
fmt.println(platform_name)
} else {
fmt.println("something else")
}
when ODIN_DEBUG {
fmt.println("this line only exists in a debug build")
}
fmt.println(ODIN_ARCH)
}Odin has no preprocessor, and
when is why it does not need one — conditional compilation happens in the real language, with real scoping and real type checking on the branch that survives. Ruby's equivalent is an ordinary runtime if, so both branches must at least parse, and both ship.Generics
A
$ marks a parameter the compiler must resolve when the call is compiled. $T in a value position infers the type from the argument; $T: typeid in a type declaration makes the type itself parametric.# Ruby needs no generics, because nothing is typed.
def largest(items)
items.max
end
puts largest([3, 9, 1])
puts largest(%w[pear apple quince])
class Stack
def initialize = @items = []
def push(item) = @items.push(item)
def pop = @items.pop
end
stack = Stack.new
stack.push(1)
stack.push("mixed types are fine")
puts stack.poppackage main
import "core:fmt"
// $T is a type parameter, resolved at compile time. One
// specialized copy is generated per type actually used.
largest :: proc(items: []$T) -> T {
best := items[0]
for item in items[1:] {
if item > best {
best = item
}
}
return best
}
// A type can be parametric too.
Stack :: struct($T: typeid) {
items: [dynamic]T,
}
stack_push :: proc(stack: ^Stack($T), item: T) {
append(&stack.items, item)
}
stack_pop :: proc(stack: ^Stack($T)) -> T {
return pop(&stack.items)
}
main :: proc() {
numbers := []int{3, 9, 1}
words := []string{"pear", "apple", "quince"}
fmt.println(largest(numbers))
fmt.println(largest(words))
stack: Stack(int)
defer delete(stack.items)
stack_push(&stack, 1)
stack_push(&stack, 2)
fmt.println(stack_pop(&stack))
}Generics are the answer to a problem Ruby does not have — with no static types there is nothing to parameterize. What Odin gets in return is that
largest(words) and largest(numbers) are two separately compiled procedures, each with no dispatch and no boxing, and a Stack(int) that cannot accidentally hold a string.Reflection
Odin keeps full runtime type information —
core:reflect reads field names, types, offsets, and tags — because that is what fmt's %v and any are built on.class Person
def initialize(name, age)
@name = name
@age = age
end
end
person = Person.new("Ada", 36)
puts person.class.name
puts person.instance_variables.inspect
puts person.instance_variable_get(:@name)
puts Person.instance_methods(false).inspect
# And reflection can act, not just look:
person.instance_variable_set(:@name, "Grace")
puts person.instance_variable_get(:@name)package main
import "core:fmt"
import "core:reflect"
Person :: struct {
name: string,
age: int,
}
main :: proc() {
person := Person{"Ada", 36}
// The compiler emits type information, so you can LOOK
// at a type's shape at runtime.
fmt.println(typeid_of(Person))
fmt.println(reflect.struct_field_names(Person))
fmt.println(reflect.struct_field_types(Person))
name_field := reflect.struct_field_value_by_name(person, "name")
fmt.println(name_field)
fmt.println(size_of(Person), align_of(Person))
}The line Odin draws is between inspecting and changing. You can ask what fields a struct has; you cannot add one, define a procedure, or call something discovered by name, because there is no method table to modify and no interpreter to compile new code into. Reflection here answers questions — it does not rewrite the program.
Enums, Unions & Optionals
Enums
An enum is a distinct type, not a set of integer constants — an
int will not silently pass where a Level is expected. [Level]string is an array indexed by the enum, so the compiler requires an entry for every member and no index can be out of range.# Ruby has no enum. The usual stand-ins are symbols,
# frozen constants, or a small class:
module Level
DEBUG = 0
INFO = 1
ERROR = 2
ALL = constants.map { |name| [name, const_get(name)] }
end
puts Level::INFO
puts Level::ALL.inspect
# Nothing stops a stray integer being used as a Level.
level = 99
puts levelpackage main
import "core:fmt"
Level :: enum {
Debug,
Info,
Error,
}
// Values can be assigned, and the backing type chosen:
Status_Code :: enum u16 {
Ok = 200,
Not_Found = 404,
}
main :: proc() {
level := Level.Info
fmt.println(level, int(level))
// The whole set is iterable, and %v gives you the name.
for member in Level {
fmt.printf("%v=%v ", member, int(member))
}
fmt.println()
fmt.println(Status_Code.Not_Found, u16(Status_Code.Not_Found))
// An enum-indexed array cannot be indexed out of range:
labels := [Level]string{
.Debug = "verbose",
.Info = "normal",
.Error = "loud",
}
fmt.println(labels[level])
}Ruby's nearest equivalents all leak: a module of integer constants accepts any integer, and symbols accept any symbol. The enum-indexed array is the piece with no Ruby analog at all — a lookup table the compiler proves is total.
Tagged unions
A union's zero value is
nil, meaning no variant is currently held, which is why the switch falls through to the final return for an untouched Value. Reading the wrong variant is impossible: the tag is checked.# A Ruby value that could be one of several things is
# just... a value. You ask it what it is.
def describe(value)
case value
when Integer then "an integer: #{value}"
when String then "a string: #{value}"
when nil then "nothing"
else "something else: #{value.class}"
end
end
puts describe(42)
puts describe("text")
puts describe(nil)
puts describe(3.14)package main
import "core:fmt"
// A union lists its variants. The value carries a tag, and
// the compiler will not let you read the wrong one.
Value :: union {
int,
string,
f64,
}
describe :: proc(value: Value) -> string {
switch specific in value {
case int: return fmt.tprintf("an integer: %v", specific)
case string: return fmt.tprintf("a string: %v", specific)
case f64: return fmt.tprintf("a float: %v", specific)
}
return "nothing"
}
main :: proc() {
fmt.println(describe(42))
fmt.println(describe("text"))
fmt.println(describe(3.14))
// A union's zero value is nil — no variant set.
empty: Value
fmt.println(describe(empty))
fmt.println(empty == nil)
}Ruby's
case on value.class does the same job with none of the guarantees — the list of possible types is in your head, and a new one arriving takes the else branch quietly. The union writes that list into the type, so a fourth variant makes every switch incomplete until you say what it does.nil becomes Maybe
Maybe(T) is a union of T and nil, and .? is the unwrap that yields the value plus a bool. Because absence lives in the type, a plain User is guaranteed to be a user.def find_user(id)
id == 1 ? { name: "Ada" } : nil
end
user = find_user(1)
puts user[:name] if user
missing = find_user(99)
puts missing.inspect
puts missing&.fetch(:name, nil).inspect
# Every reference in Ruby is implicitly "or nil",
# which is why &. exists at all.package main
import "core:fmt"
User :: struct {
name: string,
}
// Maybe(T) is a union of T and nil. Absence is part of
// the TYPE, so only values that can be missing are.
find_user :: proc(id: int) -> Maybe(User) {
if id == 1 {
return User{name = "Ada"}
}
return nil
}
main :: proc() {
// .? unwraps and reports whether there was anything.
if user, ok := find_user(1).?; ok {
fmt.println(user.name)
}
missing := find_user(99)
fmt.println(missing == nil)
// or_else supplies a default inline.
fallback := find_user(99).? or_else User{name = "anonymous"}
fmt.println(fallback.name)
}This inverts Ruby's default. There, everything is nullable and
&. exists because you can never be sure; here, nothing is nullable unless its type says so, and the compiler makes you unwrap exactly the values that might be missing. It is the same discipline as Rust's Option or Swift's optionals, without the combinator vocabulary that needs closures.Concurrency & Data Layout
Threads
These are real operating-system threads with no global lock, so they genuinely run at once on separate cores. A thread procedure takes a single
rawptr, which is how state reaches it — there is no closure to capture it.results = []
mutex = Mutex.new
workers = 4.times.map do |index|
Thread.new do
mutex.synchronize { results << index * 10 }
end
end
workers.each(&:join)
puts results.sort.inspect
# The GVL means only one thread runs Ruby code at a time,
# so threads help with I/O, not with computation.package main
import "core:fmt"
import "core:thread"
import "core:sync"
Shared :: struct {
mutex: sync.Mutex,
total: int,
}
worker :: proc(argument: rawptr) {
shared := cast(^Shared)argument
sync.mutex_lock(&shared.mutex)
defer sync.mutex_unlock(&shared.mutex)
shared.total += 10
}
main :: proc() {
shared := Shared{}
workers: [4]^thread.Thread
for index in 0 ..< len(workers) {
workers[index] = thread.create_and_start_with_data(&shared, worker)
}
for handle in workers {
thread.join(handle)
thread.destroy(handle)
}
fmt.println(shared.total)
}Ruby's global VM lock means threads interleave rather than parallelize, so
Thread.new is for waiting on I/O and Ractors or processes are for using more than one core. Odin gives you actual parallelism and, with it, actual data races: nothing warns you about the unlocked write, so the mutex is entirely your responsibility.Array of structs, struct of arrays
Adding
#soa to an array type changes the memory layout — all the x values become contiguous, then all the y values — while fast[index].x still reads exactly as it did. The compiler rewrites the access for you.Particle = Struct.new(:x, :y, :alive)
particles = 4.times.map { |index| Particle.new(index.to_f, 0.0, true) }
particles.each { |particle| particle.x += 1 }
puts particles.map(&:x).inspect
# Each Particle is a separate heap object holding
# references to three more objects. The layout is the
# interpreter's business, not yours.package main
import "core:fmt"
Particle :: struct {
x: f32,
y: f32,
alive: bool,
}
main :: proc() {
// Ordinary array of structs: x,y,alive, x,y,alive, ...
regular: [4]Particle
// #soa stores each FIELD contiguously — all the xs, then
// all the ys — while the syntax stays identical.
fast: #soa[4]Particle
for index in 0 ..< 4 {
regular[index] = Particle{f32(index), 0, true}
fast[index] = Particle{f32(index), 0, true}
}
for index in 0 ..< 4 {
regular[index].x += 1
fast[index].x += 1
}
fmt.println(regular[2].x, fast[2].x)
fmt.println(len(fast))
}A loop that touches only
x reads every cache line fully instead of skipping over y and alive, which is often several times faster on real data. Ruby cannot express this at any level: an array of objects is an array of pointers to separately allocated objects, and the layout belongs to the interpreter.Arrays that do arithmetic
Odin treats fixed-size arrays as mathematical vectors:
+, *, and the rest apply element-wise, matrix[R, C]T is a builtin with true matrix multiplication, and the .xy swizzle selects components by name.require "matrix"
left = Vector[1, 2, 3]
right = Vector[10, 20, 30]
puts (left + right).to_a.inspect
puts left.inner_product(right)
transform = Matrix[[1, 2], [3, 4]]
puts (transform * Vector[1, 1]).to_a.inspect
# Vector and Matrix are stdlib classes built on Array,
# so every operation allocates new objects.package main
import "core:fmt"
import "core:math/linalg"
main :: proc() {
// Arithmetic on fixed-size arrays is element-wise,
// built into the language, and allocates nothing.
left := [3]f32{1, 2, 3}
right := [3]f32{10, 20, 30}
fmt.println(left + right)
fmt.println(left * 2)
fmt.println(linalg.dot(left, right))
// matrix is a builtin type with real matrix multiply.
transform := matrix[2, 2]f32{
1, 2,
3, 4,
}
vector := [2]f32{1, 1}
fmt.println(transform * vector)
// Components can be swizzled by name:
position := [4]f32{1, 2, 3, 4}
fmt.println(position.xy, position.zw)
}This is the one place on the page where Odin is the terser language. Ruby's
Vector and Matrix are stdlib classes wrapping arrays, so every operation allocates a fresh object; Odin's live in registers and compile to vector instructions, which is what a language aimed at graphics work should do.