PONYλM2Modula-2

Fortran.CodeCompared.To/Odin

An interactive executable cheatsheet comparing Fortran and Odin

Fortran 2018 (GCC 16.2) Odin 2026-08
Program Structure & Building
Hello, World
Odin's program unit is a package, and execution begins at main. The :: operator binds a compile-time constant — the same operator declares types and named constants — so main :: proc() reads as "main is a procedure".
program hello implicit none print *, "Hello, World!" end program hello
package main import "core:fmt" main :: proc() { fmt.println("Hello, World!") }
The shapes correspond directly: program/end program becomes package plus a main procedure, and print * becomes fmt.println. Odin needs no implicit none because implicit typing never existed — every name must be declared before it can be used.
Compiling and dependencies
Because the whole directory is one compilation unit, Odin has no separate interface file and no declaration-before-use rule across files — the compiler sees every declaration in the package at once.
! Fortran has no standard build system or package manager. ! Compilation is per-file, and a module must be compiled ! before anything that uses it: ! gfortran -c geometry.f90 produces geometry.mod ! gfortran -c main.f90 ! gfortran -o program *.o ! fpm, CMake, and Makefiles all exist to manage that ordering. program building implicit none print *, "the .mod file is the compiled interface" end program building
// A DIRECTORY is a package: every .odin file in it is compiled // together, declaration order does not matter, and there are // no .mod files or interface artifacts to sequence. // odin build . compile a directory // odin run . -o:speed optimized // odin test . run the test procedures // // There is no package manager either. Dependencies are // VENDORED into your tree and imported by path. package main import "core:fmt" main :: proc() { fmt.println("no compile order to get right") }
This removes a specific Fortran chore: the dependency ordering that .mod files impose, which is why a Fortran project needs a build tool the moment it exceeds two files. What Odin does not give back is a package registry — like Fortran before fpm, dependencies are source you copy into your own tree.
Declarations
Odin declarations use name : Type = value, the same colon Fortran uses to separate the type from the entity list — only with the type on the right. Declarations may appear anywhere in a procedure, not only in a header block.
program declarations implicit none integer :: count real :: ratio logical :: finished character(len=10) :: label count = 42 ratio = 1.5 finished = .false. label = "ready" print *, count, ratio, finished, trim(label) end program declarations
package main import "core:fmt" main :: proc() { // Full form reads left to right: name : Type = value count: int = 42 ratio: f32 = 1.5 finished: bool = false label: string = "ready" // Drop the type and := infers it. inferred := 42 fmt.println(count, ratio, finished, label) fmt.println(inferred) // Everything is zero-initialized unless you opt out. uninitialized: int fmt.println(uninitialized) // 0, guaranteed }
Two habits transfer immediately: explicit types (Odin has no implicit typing to switch off) and initialization. Fortran leaves an undeclared-value variable genuinely undefined, while Odin guarantees zero unless you write = --- to opt out — so the = 0 initializers that discipline demands are unnecessary.
Named constants
The :: that declares a procedure also declares a constant, because in Odin procedures, types, and values are all compile-time constants — one operator covers them.
program constants implicit none integer, parameter :: MAXIMUM_ITEMS = 100 real, parameter :: PI = 3.14159265358979 integer, parameter :: SIZES(3) = [10, 20, 30] print *, MAXIMUM_ITEMS print *, PI print *, SIZES end program constants
package main import "core:fmt" import "core:math" // :: declares a compile-time constant: no storage, no // address, substituted at every use. MAXIMUM_ITEMS :: 100 SIZES :: [3]int{10, 20, 30} main :: proc() { fmt.println(MAXIMUM_ITEMS) fmt.println(math.PI) fmt.println(SIZES) // MAXIMUM_ITEMS = 200 // Error: cannot assign to a constant }
This maps almost exactly onto parameter: both are compile-time values with no runtime storage, both can be used as array bounds, and both reject assignment. The difference is that Odin does not need a separate attribute — the declaration operator itself says "constant".
Types & Kinds
Kinds become sized types
Odin spells the width into the type name — i32, f64 — so there is no kind parameter, no selected_real_kind, and no _real64 literal suffix. A bare int is a machine word.
program kinds use iso_fortran_env, only: int32, int64, real32, real64 implicit none integer(int32) :: small integer(int64) :: large real(real32) :: single real(real64) :: double small = 2147483647 large = 9223372036854775807_int64 single = 1.0_real32 / 3.0_real32 double = 1.0_real64 / 3.0_real64 print *, small, large print *, single print *, double end program kinds
package main import "core:fmt" main :: proc() { // The width is IN the type name. No kind numbers, no // iso_fortran_env, and no literal suffixes. small: i32 = 2147483647 large: i64 = 9223372036854775807 single: f32 = 1.0 / 3.0 double: f64 = 1.0 / 3.0 fmt.println(small, large) fmt.println(single) fmt.println(double) // Available: i8 i16 i32 i64 i128, u8 u16 u32 u64 u128, // f16 f32 f64. 'int' and 'uint' are machine words. fmt.println(size_of(int) * 8, "bit machine word") }
This removes the portability question iso_fortran_env was introduced to answer: f64 is 64 bits everywhere, so the real(kind=8)-versus-real(real64) ambiguity that made older code non-portable has no equivalent.
Type conversion
Odin performs no implicit numeric conversions at all: mixing an int and an f64 in one expression is a compile error until you write the conversion.
program conversion implicit none integer :: count real :: ratio count = 7 ! Mixed-mode arithmetic converts IMPLICITLY, which is ! how the classic integer-division bug happens: ratio = count / 2 ! 3.0 — integer division first print *, ratio ratio = real(count) / 2.0 ! 3.5 — as intended print *, ratio print *, int(3.9), nint(3.9) end program conversion
package main import "core:fmt" main :: proc() { count := 7 // ratio := count / 2.0 // Error: mismatched types // Every conversion is written out. ratio := f64(count) / 2.0 fmt.println(ratio) // Integer division still truncates — but you can SEE // that both operands are integers. truncated := f64(count / 2) fmt.println(truncated) nearly_four := 3.9 fmt.println(int(nearly_four)) // truncates toward zero fmt.println(int(nearly_four + 0.5)) // the nint() idiom }
This is a real safety gain over Fortran's mixed-mode rules. The ratio = count / 2 bug — where the division happens in integer arithmetic before the assignment widens it — cannot be written in Odin, because the compiler stops at the mixed expression rather than silently promoting.
Complex numbers
complex64 and complex128 are builtin types with the arithmetic operators built in, and real, imag, and conj are builtin procedures. Odin also has quaternion types, which Fortran has no equivalent of.
program complex_demo implicit none complex :: first, second, product first = (1.0, 2.0) second = (3.0, -1.0) product = first * second print *, product print *, real(product), aimag(product) print *, abs(first) print *, conjg(first) end program complex_demo
package main import "core:fmt" import "core:math/cmplx" main :: proc() { // complex is a BUILTIN type with real operators, not a // library struct — one of the few languages that kept it. first := complex(f64(1.0), f64(2.0)) second := complex(f64(3.0), f64(-1.0)) product := first * second fmt.println(product) fmt.println(real(product), imag(product)) fmt.println(cmplx.abs(first)) fmt.println(conj(first)) }
This is one of the strongest points of agreement between the two languages, and it is rare — most languages a Fortran programmer would consider make complex arithmetic a library type with method calls instead of operators. Here first * second means the same thing in both columns.
Logical values
Odin uses the C-family operators &&, ||, and ! in place of .and., .or., and .not., and both are guaranteed to short-circuit.
program logical_demo implicit none logical :: ready, finished ready = .true. finished = .false. print *, ready .and. .not. finished print *, ready .or. finished print *, ready .eqv. .true. print *, ready .neqv. finished ! A logical is a storage unit, not a single bit. print *, storage_size(ready) end program logical_demo
package main import "core:fmt" main :: proc() { ready := true finished := false fmt.println(ready && !finished) fmt.println(ready || finished) fmt.println(ready == true) fmt.println(ready != finished) // bool is one byte; b8/b16/b32/b64 are sized variants. fmt.println(size_of(bool) * 8, "bits") // && and || SHORT-CIRCUIT, which .and. and .or. do not // promise. The condition must be an actual bool. values := []int{1, 2, 3} if len(values) > 0 && values[0] == 1 { fmt.println("safe to index") } }
Short-circuiting is the substantive difference. The Fortran standard permits — and gfortran sometimes performs — evaluation of both operands of .and., which is why the guard-then-index idiom in the last block is not reliably safe in Fortran and must be written as nested if statements.
Optional values
Maybe(T) is a union of T and nil, and .? unwraps it into a value plus a bool. Unlike an unallocated allocatable, it costs no heap allocation — the tag lives beside the value.
program optional_values implicit none integer, allocatable :: maybe_value integer :: found ! An unallocated allocatable is Fortran's "no value". print *, allocated(maybe_value) allocate(maybe_value) maybe_value = 42 print *, allocated(maybe_value), maybe_value found = lookup(1) print *, found found = lookup(99) print *, found ! -1 as a sentinel: the usual approach contains function lookup(id) result(value) integer, intent(in) :: id integer :: value value = merge(42, -1, id == 1) end function lookup end program optional_values
package main import "core:fmt" // Maybe(T) is a union of T and nil, so "no value" is part // of the TYPE rather than a sentinel you have to remember. lookup :: proc(id: int) -> Maybe(int) { if id == 1 { return 42 } return nil } main :: proc() { // .? unwraps and reports whether there was anything. if value, ok := lookup(1).?; ok { fmt.println("found", value) } missing := lookup(99) fmt.println(missing == nil) // or_else supplies a default inline. fmt.println(lookup(99).? or_else -1) }
The sentinel return value (-1, -999, huge(0)) is a deeply worn Fortran habit, and its flaw is that the sentinel is also a legal value. Maybe removes the ambiguity, and unlike allocated() it does not require the value to be on the heap to express absence.
Arrays
Indexing starts at zero
Odin arrays are 0-based and the lower bound cannot be changed — there is no equivalent of integer :: custom(0:4) or offset(-2:2). An index range that starts anywhere else must be shifted explicitly.
program indexing implicit none integer :: values(5) integer :: custom(0:4) integer :: offset(-2:2) integer :: index values = [10, 20, 30, 40, 50] print *, values(1) ! FIRST element print *, values(5) ! LAST element print *, lbound(values), ubound(values) ! The lower bound is yours to choose: custom = values print *, custom(0) offset = values print *, offset(-2), offset(0) do index = 1, size(values) write (*, "(i0, a)", advance="no") values(index), " " end do print * end program indexing
package main import "core:fmt" main :: proc() { values := [5]int{10, 20, 30, 40, 50} fmt.println(values[0]) // FIRST element fmt.println(values[len(values) - 1]) // LAST element fmt.println(0, len(values) - 1) // the bounds, always // The lower bound is ALWAYS zero. It cannot be chosen, // so an offset has to be applied by hand: OFFSET :: 2 for logical_index in -2 ..= 2 { fmt.print(values[logical_index + OFFSET], "") } fmt.println() for value in values { fmt.print(value, "") } fmt.println() }
This is the single largest source of bugs when porting numerical code, and it is worth handling deliberately rather than by care: do i = 1, n becomes for i in 0 ..< n, and every hand-written index expression drops by one. Losing the chosen lower bound also matters more than it sounds — a stencil indexed (-1:1) now needs the offset written at every use.
Whole-array arithmetic
Element-wise arithmetic on fixed-size arrays is built into Odin's operators — prices * 0.9 scales all four elements — and the result lives in registers rather than a temporary. This works at any length, not just the two-to-four sizes graphics libraries usually hard-code.
program array_arithmetic implicit none real :: prices(4), discounted(4), taxes(4) prices = [10.0, 20.0, 30.0, 40.0] ! No loops: the operation applies to the whole array. discounted = prices * 0.9 taxes = discounted * 0.2 print *, discounted print *, discounted + taxes print *, sum(prices), maxval(prices), minval(prices) print *, product([1.0, 2.0, 3.0]) end program array_arithmetic
package main import "core:fmt" main :: proc() { // Odin kept whole-array arithmetic for FIXED-SIZE arrays: // the operators apply element by element, with no loop // and no allocation. prices := [4]f32{10, 20, 30, 40} discounted := prices * 0.9 taxes := discounted * 0.2 fmt.println(discounted) fmt.println(discounted + taxes) // Reductions are not operators — they are loops or // procedures from core:slice. total: f32 = 0 largest := prices[0] smallest := prices[0] for price in prices { total += price if price > largest { largest = price } if price < smallest { smallest = price } } fmt.println(total, largest, smallest) }
Very few modern languages kept this, and its presence is a large part of why Odin suits a Fortran reader. The limit is that it applies to fixed-size arrays only, so a []f32 slice needs the loop — and the reduction intrinsics (sum, maxval, product, dot_product) have no operator form at all.
Array sections and slices
Odin's slice syntax is half-open: values[2:6] is four elements starting at index 2, so a Fortran (3:6) becomes [2:6]. A slice is a pointer plus a length that views the source — it does not copy.
program sections implicit none integer :: values(10) integer :: index values = [(index * index, index = 1, 10)] print *, values(3:6) ! elements 3 through 6 print *, values(1:10:2) ! every second element print *, values(:4) print *, values(7:) print *, size(values(3:6)) ! A section is a first-class value: assignable in place. values(1:3) = 0 print *, values end program sections
package main import "core:fmt" main :: proc() { values: [10]int for index in 0 ..< 10 { values[index] = (index + 1) * (index + 1) } // A slice is HALF-OPEN: low inclusive, high exclusive, // and it VIEWS the original rather than copying it. fmt.println(values[2:6]) // Fortran's (3:6) fmt.println(values[:4]) fmt.println(values[6:]) fmt.println(len(values[2:6])) // There is no stride. Every second element is a loop. strided: [dynamic]int defer delete(strided) for index := 0; index < len(values); index += 2 { append(&strided, values[index]) } fmt.println(strided) // Writing through the view writes the original. window := values[0:3] for index in 0 ..< len(window) { window[index] = 0 } fmt.println(values) }
Two things differ beyond the indexing. There is no stride, so values(1:10:2) becomes an explicit loop. And an Odin slice always aliases, where Fortran decides between aliasing and copying based on context — which is safer here, since writing through the view visibly writes the original.
Row-major, not column-major
A multidimensional array in Odin is an array of arrays — [3][4]int is three rows of four — and it is indexed grid[row][column] with separate brackets rather than grid(row, column).
program storage_order implicit none integer :: grid(3, 4) integer :: row, column do column = 1, 4 do row = 1, 3 grid(row, column) = row * 10 + column end do end do ! Fortran is COLUMN-major: grid(1,1), grid(2,1), grid(3,1), ! grid(1,2)... so the LEFTMOST index varies fastest and the ! inner loop should walk rows. print *, grid(1, 1), grid(2, 1), grid(3, 1) print *, shape(grid) print *, reshape(grid, [12]) end program storage_order
package main import "core:fmt" main :: proc() { // [3][4]int is an array of 3 rows, each of 4 ints. grid: [3][4]int for row in 0 ..< 3 { for column in 0 ..< 4 { grid[row][column] = (row + 1) * 10 + (column + 1) } } // Odin is ROW-major: grid[0][0], grid[0][1], grid[0][2]... // so the RIGHTMOST index varies fastest and the inner // loop should walk columns — the opposite nesting. fmt.println(grid[0][0], grid[0][1], grid[0][2]) fmt.println(len(grid), len(grid[0])) // The flat view, in memory order: flat := transmute([12]int)grid fmt.println(flat) }
The storage order inverts, and this is a performance trap rather than a correctness one: the loop nesting that streams cache lines in Fortran walks memory in strides in Odin, and vice versa. The rule to carry over is unchanged in substance — make the last index the inner loop here, where Fortran wants the first.
Array intrinsics
core:slice supplies max, min, sort, contains, and linear_search. There is no sum, no count, no mask= argument, and no where construct — those become loops.
program intrinsics implicit none real :: values(5) logical :: mask(5) values = [3.0, 1.0, 4.0, 1.0, 5.0] mask = values > 2.0 print *, sum(values), sum(values, mask=mask) print *, maxval(values), maxloc(values) print *, count(mask), any(mask), all(mask) print *, dot_product(values, values) ! where is a masked assignment over the whole array: where (values > 2.0) values = 0.0 print *, values end program intrinsics
package main import "core:fmt" import "core:slice" main :: proc() { source := []f32{3, 1, 4, 1, 5} values := slice.clone(source) defer delete(values) // core:slice covers some of this; the rest is a loop. fmt.println(slice.max(values), slice.min(values)) total: f32 = 0 masked_total: f32 = 0 matching := 0 for value in values { total += value if value > 2 { masked_total += value matching += 1 } } fmt.println(total, masked_total) fmt.println(matching, matching > 0, matching == len(values)) // where(...) is an ordinary guarded loop. for index in 0 ..< len(values) { if values[index] > 2 { values[index] = 0 } } fmt.println(values) }
This is where Odin gives the least back to a Fortran reader. Fortran's array intrinsics with optional mask= arguments are genuinely expressive and have no equivalent — though writing the loop does fuse what sum, count, and where would each traverse separately.
Passing arrays to procedures
Taking values[:] converts a fixed array into a slice — the pointer-plus-length pair that Fortran passes as a hidden array descriptor for an assumed-shape argument.
program passing_arrays implicit none real :: values(5) values = [1.0, 2.0, 3.0, 4.0, 5.0] print *, average(values) print *, average(values(2:4)) contains ! An assumed-shape dummy argument carries its own bounds, ! but only because the compiler passes a hidden descriptor ! and the interface is visible. function average(numbers) result(mean) real, intent(in) :: numbers(:) real :: mean mean = sum(numbers) / size(numbers) end function average end program passing_arrays
package main import "core:fmt" // A slice IS the descriptor: a pointer and a length, passed // as two words. len() is always right, and no separate // interface declaration is needed anywhere. average :: proc(numbers: []f32) -> f32 { total: f32 = 0 for number in numbers { total += number } return total / f32(len(numbers)) } main :: proc() { values := [5]f32{1, 2, 3, 4, 5} fmt.println(average(values[:])) fmt.println(average(values[1:4])) }
The mechanism is nearly the same; the difference is that it is visible and unconditional. A Fortran assumed-shape argument only works when an explicit interface is in scope, which is exactly why modules and contains exist. An Odin slice carries its length everywhere, with no interface block to keep in sync.
Allocatable & Dynamic Memory
allocatable becomes make and delete
defer schedules a statement to run when the enclosing scope exits, by any path including an early return. Writing it immediately after the allocation is the convention that keeps the pair visible.
program allocatable_demo implicit none real, allocatable :: values(:) integer :: index allocate(values(5)) do index = 1, 5 values(index) = index * 1.5 end do print *, values print *, allocated(values), size(values) deallocate(values) print *, allocated(values) ! An allocatable is deallocated AUTOMATICALLY when it goes ! out of scope, which is why leaks are rare in modern Fortran. end program allocatable_demo
package main import "core:fmt" main :: proc() { // make allocates; delete releases. Nothing happens // automatically at scope exit. values := make([]f32, 5) defer delete(values) for index in 0 ..< len(values) { values[index] = f32(index + 1) * 1.5 } fmt.println(values) fmt.println(len(values)) // defer runs the release at scope exit by ANY path, so // it sits on the line after the allocation. other := make([]f32, 3) defer delete(other) fmt.println(len(other)) }
Modern Fortran deallocates an allocatable automatically at scope exit, which is genuinely safer than what Odin offers — defer is a line you must remember, and forgetting it leaks. The compensation is control: the memory-allocator rows below let you choose where the storage comes from, which Fortran cannot express at all.
Growing an array
[dynamic]T tracks a length and a capacity separately, and append is a builtin taking a pointer so it can reallocate the buffer. values[:] produces the plain slice view.
program growing implicit none integer, allocatable :: values(:) integer :: index allocate(values(0)) ! Fortran 2003 allows growing by whole-array assignment, ! but every step reallocates and copies the whole thing. do index = 1, 5 values = [values, index * index] end do print *, values print *, size(values) deallocate(values) end program growing
package main import "core:fmt" main :: proc() { // [dynamic]T owns a growable buffer with amortized // doubling — no quadratic copying. values: [dynamic]int defer delete(values) for index in 1 ..= 5 { append(&values, index * index) } fmt.println(values) fmt.println(len(values), cap(values)) // It converts to a slice with [:] for anything that // takes a plain []int. fmt.println(len(values[:])) }
The Fortran idiom in the left column is quadratic: each values = [values, x] allocates a new array and copies everything. Growing a collection is common enough that having the amortized version built in is a genuine improvement, and it is why Odin needs no equivalent of the manual double-and-copy routine that Fortran codebases usually carry.
Pointers
The ^ serves as both the pointer type (^int) and the dereference suffix (reference^), and & takes an address. Struct field access dereferences automatically, so the explicit ^ mostly appears with scalars like this.
program pointer_demo implicit none integer, target :: value integer, pointer :: reference integer, allocatable :: buffer(:) value = 42 reference => value ! association, not assignment print *, reference reference = 99 ! writes through to value print *, value print *, associated(reference) nullify(reference) print *, associated(reference) allocate(buffer(3)) buffer = 7 print *, buffer deallocate(buffer) end program pointer_demo
package main import "core:fmt" main :: proc() { value := 42 // ^int is the pointer type; & takes an address; ^ after // an expression dereferences it. No 'target' attribute // is needed — any variable can be pointed at. reference: ^int = &value fmt.println(reference^) reference^ = 99 fmt.println(value) fmt.println(reference == nil) // A pointer to heap storage: allocated := new(int) defer free(allocated) allocated^ = 7 fmt.println(allocated^) }
Fortran keeps pointer association (=>) syntactically distinct from assignment (=) precisely because confusing them is disastrous; Odin distinguishes them by the ^ on the left-hand side instead. The target attribute has no equivalent — any variable's address can be taken, which removes a declaration chore and removes an aliasing guarantee the optimizer used to have.
Choosing where memory comes from
The implicit context carries the current allocator, and assigning context.allocator redirects every allocation below that point — inside procedures you call and inside the standard library alike, with no signature changes anywhere.
program allocation_strategy implicit none real, allocatable :: workspace(:) integer :: index ! Fortran has ONE allocator. allocate() goes to the heap ! the runtime provides, and there is no way to redirect it, ! pool it, or pre-reserve a region for a phase of work. allocate(workspace(1000)) do index = 1, 1000 workspace(index) = real(index) end do print *, workspace(1), workspace(1000) deallocate(workspace) end program allocation_strategy
package 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) workspace := make([]f64, 1000) for index in 0 ..< len(workspace) { workspace[index] = f64(index + 1) } fmt.println(workspace[0], workspace[999]) // No delete. The arena owns all of it. } fmt.println("one teardown released the whole workspace") }
This is the capability Fortran has no way to express, and it is what makes manual memory management practical rather than merely possible. Scratch workspace for one timestep, one iteration, or one solve stops needing individual deallocation: allocate into the arena and reset it when the phase ends.
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.
program leak_check implicit none real, allocatable :: values(:) allocate(values(100)) print *, allocated(values), size(values) ! allocated() is the only introspection the language offers, ! and it answers about one variable. Finding a genuine leak ! means valgrind or a compiler's -fsanitize option. deallocate(values) print *, allocated(values) end program leak_check
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([]f64, 10) delete(released) forgotten := make([]f64, 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) } } }
This is the tool that makes the loss of automatic deallocation manageable. Instead of reaching for valgrind, you wrap the allocator for one run and get a list of allocations with the exact line that made each of them — which is a considerably shorter investigation than the Fortran equivalent.
Characters & Strings
Fixed-length characters become slices
An Odin string carries its actual length rather than a declared one, so there is no blank padding, no trim before every use, and no truncation when a longer value is assigned.
program character_demo implicit none character(len=10) :: name character(len=:), allocatable :: flexible name = "Ada" print *, len(name) ! 10 — the DECLARED length print *, len_trim(name) ! 3 — ignoring blank padding print *, "[" // name // "]" print *, "[" // trim(name) // "]" flexible = "grows to fit" print *, len(flexible), flexible end program character_demo
package main import "core:fmt" main :: proc() { // A string is a pointer and a length over UTF-8 bytes. // It is never padded and never has a declared capacity. name := "Ada" fmt.println(len(name)) // 3 — the ACTUAL length fmt.printf("[%v]\n", name) // Assigning a different length is just a different value. longer := "a considerably longer name" fmt.println(len(longer)) // Concatenation allocates, so the result is owned. combined := fmt.tprintf("%v and %v", name, longer) fmt.println(combined) }
Fortran's character(len=n) is a fixed-width field that pads with blanks and silently truncates anything longer — which is why trim() appears in almost every print statement in real code. The deferred-length character(len=:), allocatable form is the closer analog, and Odin's string behaves like that one by default.
String operations
Everything lives in core:strings as a free procedure taking the string first. Note which ones allocate: to_upper, split, and join build new values that the caller must delete, while index, contains, and trim_space only look or return a view.
program string_operations implicit none character(len=20) :: text text = "Hello, World" print *, index(text, "World") print *, trim(text) // "!" print *, len_trim(text) print *, text(1:5) ! substring print *, adjustl(text), adjustr(text) ! There is no built-in case conversion, no split, and no ! join — every Fortran project writes its own. print *, achar(iachar("a") - 32) end program string_operations
package main import "core:fmt" import "core:strings" main :: proc() { text := "Hello, World" fmt.println(strings.index(text, "World")) fmt.println(strings.contains(text, "World")) fmt.println(text[0:5]) // substring, half-open upper := strings.to_upper(text) defer delete(upper) fmt.println(upper) pieces := strings.split(text, ", ") defer delete(pieces) fmt.println(pieces) joined := strings.join(pieces, " | ") defer delete(joined) fmt.println(joined) fmt.println(strings.trim_space(" padded ")) }
Odin's string library is much larger than Fortran's handful of intrinsics — case conversion, splitting, joining, and prefix tests all ship with the language rather than being written per project. The price is that the allocating half needs a matching delete, which Fortran's deferred-length assignment handles for you.
Building a string
fmt.sbprintf formats directly into a strings.Builder, and strings.to_string returns a string that points into the builder's buffer rather than copying it — so the builder must outlive every use of the result.
program string_building implicit none character(len=:), allocatable :: buffer character(len=20) :: piece integer :: index buffer = "" do index = 1, 3 write (piece, "(a, i0, a)") "row ", index, "; " buffer = buffer // trim(piece) end do print *, buffer print *, len(buffer) end program string_building
package main import "core:fmt" import "core:strings" main :: proc() { // A Builder owns a growable byte buffer, so repeated // appends do not reallocate the whole string each time. builder := strings.builder_make() defer strings.builder_destroy(&builder) for index in 1 ..= 3 { fmt.sbprintf(&builder, "row %v; ", index) } assembled := strings.to_string(builder) fmt.println(assembled) fmt.println(len(assembled)) }
The Fortran version does two awkward things this avoids: it needs an internal write to a fixed-width scratch variable just to format an integer, and each // concatenation reallocates and copies the entire accumulated string. The builder makes both costs disappear.
Numbers and text
Multiple return values are a language feature in Odin, so a fallible parse returns the value and a bool together — no status variable to declare and no separate check.
program string_conversion implicit none character(len=20) :: text character(len=20) :: rubbish integer :: value integer :: status ! An internal write converts a number to text... write (text, "(i0)") 42 print *, trim(text) ! ...and an internal read converts it back. The unit must ! be a character VARIABLE, never a literal. read (text, *, iostat=status) value print *, value, status rubbish = "not a number" read (rubbish, *, iostat=status) value print *, "iostat on bad input:", status /= 0 end program string_conversion
package main import "core:fmt" import "core:strconv" main :: proc() { // Number to text: text := fmt.tprintf("%v", 42) fmt.println(text) // Text to number — the second result reports success, // so the failure arrives as a value. value, ok := strconv.parse_int(text) fmt.println(value, ok) bad, bad_ok := strconv.parse_int("not a number") fmt.println(bad, bad_ok) // 0 false ratio, _ := strconv.parse_f64("3.14") fmt.println(ratio) }
Fortran's internal read/write pair does this job through the I/O system, which is why converting a number to a string requires declaring a fixed-width scratch variable and remembering to trim it. Both languages report failure rather than raising, so the discipline is the same — Odin just spells it in one line.
Bytes 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.
program unicode_demo implicit none character(len=6) :: text integer :: index ! Default character is one byte per element, so a ! multi-byte UTF-8 character occupies several elements ! and len() counts bytes, not characters. text = "héllo" print *, len(text) do index = 1, len_trim(text) write (*, "(i0, a)", advance="no") iachar(text(index:index)), " " end do print * end program unicode_demo
package 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 decodes UTF-8 and yields (rune, byte offset). // The VALUE comes first. for character, offset in text { fmt.printf("%v@%v ", character, offset) } fmt.println() // Indexing still gives a raw byte: fmt.println(text[1]) }
Both languages store text as bytes and both report byte counts from len, so the starting point is the same. What Odin adds is a decoding iteration and a rune type, where Fortran's only standard route to non-ASCII text is a selected character kind that few compilers implement usefully.
Control Flow
if and else
Odin drops the parentheses around the condition and requires the braces, and the condition must be an actual bool — there is no conversion from a number.
program conditionals implicit none integer :: temperature temperature = 22 if (temperature > 30) then print *, "hot" else if (temperature > 15) then print *, "mild" else print *, "cold" end if ! A one-line if needs no then and no end if: if (temperature > 20) print *, "warm enough" end program conditionals
package main import "core:fmt" main :: proc() { temperature := 22 // Braces are mandatory; parentheses are not used. if temperature > 30 { fmt.println("hot") } else if temperature > 15 { fmt.println("mild") } else { fmt.println("cold") } // An if may open with a statement scoped to the branch: if adjusted := temperature + 2; adjusted > 20 { fmt.println("adjusted is", adjusted) } // The ternary reads in English word order. label := "warm" if temperature > 20 else "cool" fmt.println(label) }
The if statement; condition form has no Fortran equivalent and is worth adopting: the variable exists only inside the branch, so a value computed for one test cannot leak into the rest of the procedure. Fortran's single-statement if has no counterpart — braces are always required.
do loops
One keyword covers every loop shape: for range, for condition (a while loop), bare for (infinite), and the three-clause C form. Fortran's exit is break and cycle is continue.
program do_loops implicit none integer :: index, total do index = 1, 5 write (*, "(i0, a)", advance="no") index, " " end do print * do index = 10, 0, -2 write (*, "(i0, a)", advance="no") index, " " end do print * total = 0 do while (total < 10) total = total + 3 end do print *, total do total = total - 1 if (total <= 0) exit end do print *, total end program do_loops
package main import "core:fmt" main :: proc() { // ..< is exclusive, ..= is inclusive. Fortran's // do index = 1, 5 becomes for index in 1 ..= 5. for index in 1 ..= 5 { fmt.print(index, "") } fmt.println() // A step needs the three-clause form; there is no // counted loop with a stride. for index := 10; index >= 0; index -= 2 { fmt.print(index, "") } fmt.println() // for with one condition is a while loop. total := 0 for total < 10 { total += 3 } fmt.println(total) // Bare for is infinite; exit is spelled break. for { total -= 1 if total <= 0 { break } } fmt.println(total) }
The counted loop translates directly once you account for the bounds: do index = 1, n becomes for index in 0 ..< n when it indexes an array, or 1 ..= n when the number itself matters. The one loss is the stride form — do index = 10, 0, -2 needs the explicit three-clause loop.
select case
An empty case: is the default arm, and ranges are written 1 ..= 4 where Fortran writes (1:4). Arms end by themselves — Odin follows Fortran here rather than C.
program select_demo implicit none integer :: score score = 7 select case (score) case (0) print *, "zero" case (1:4) print *, "low" case (5:10) print *, "high" case default print *, "out of range" end select end program select_demo
package main import "core:fmt" main :: proc() { score := 7 switch score { case 0: fmt.println("zero") case 1 ..= 4: fmt.println("low") case 5 ..= 10: fmt.println("high") case: fmt.println("out of range") } // Arms do NOT fall through, so no break is needed. // Ask for it explicitly when you want it. switch score { case 7: fmt.println("seven") fallthrough case 8: fmt.println("...and the arm below it") } }
This is a close correspondence, and the no-fall-through behavior means the C habit of a forgotten break never arrives. Odin's switch also has a form that matches on the active variant of a tagged union, which the derived-types section uses.
Named loops
A loop can carry a label, and break label or continue label targets that loop by name from any depth inside it — the same construct as Fortran's named do with exit and cycle.
program loop_labels implicit none integer :: grid(2, 2) integer :: row, column grid = reshape([1, 2, 3, 4], [2, 2]) search: do row = 1, 2 inner: do column = 1, 2 if (grid(row, column) == 3) then print *, "found at", row, column exit search end if end do inner end do search print *, "searched" end program loop_labels
package main import "core:fmt" main :: proc() { grid := [2][2]int{{1, 3}, {2, 4}} search: for row in 0 ..< 2 { for column in 0 ..< 2 { if grid[row][column] == 3 { fmt.printf("found at %v %v\n", row, column) break search } } } fmt.println("searched") }
This transfers almost verbatim, which is not something most languages allow: labeled loop exit is one of Fortran's quieter good ideas and C, C++, and C# all lack it. Odin only labels the loop being exited, not the end do, so there is no closing label to keep in sync.
Cleanup on the way out
Deferred statements run in reverse order at scope exit, so cleanup unwinds the setup that preceded it — and each one is written beside the thing it balances rather than at every return.
program cleanup implicit none integer :: status ! Assign first: calling a procedure that prints from inside ! a print statement is "recursive I/O" and aborts at runtime. status = do_work(.false.) print *, status status = do_work(.true.) print *, status contains function do_work(should_fail) result(status) logical, intent(in) :: should_fail integer :: status real, allocatable :: workspace(:) print *, "acquire" allocate(workspace(10)) if (should_fail) then deallocate(workspace) ! must be repeated on print *, "release" ! EVERY exit path status = 1 return end if print *, "using it" deallocate(workspace) print *, "release" status = 0 end function do_work end program cleanup
package main import "core:fmt" do_work :: proc(should_fail: bool) -> int { fmt.println("acquire") workspace := make([]f32, 10) // Written ONCE, runs on every exit path. defer delete(workspace) defer fmt.println("release") if should_fail { return 1 } fmt.println("using it") return 0 } main :: proc() { fmt.println(do_work(false)) fmt.println(do_work(true)) }
Fortran's automatic deallocation covers the allocatable case, so the left column is worse than real code needs to be for memory alone. It is exactly right for everything else: closing a unit, restoring a saved setting, printing a trailer. Those cleanups genuinely do have to be repeated on every exit path in Fortran, and defer is why they do not here.
Procedures
Functions and subroutines
A procedure is declared like every other constant: name :: proc(parameters) -> results { }. Parameters sharing a type share one annotation, as left, right: int does — the same shorthand Fortran's entity list gives.
program procedures implicit none print *, add(2, 3) call report(42) contains ! A function returns a value... function add(left, right) result(total) integer, intent(in) :: left, right integer :: total total = left + right end function add ! ...a subroutine does not, and is invoked with call. subroutine report(value) integer, intent(in) :: value print *, "value is", value end subroutine report end program procedures
package main import "core:fmt" // One construct. A procedure with no results is Fortran's // subroutine; with results, its function. No 'call'. add :: proc(left, right: int) -> int { return left + right } report :: proc(value: int) { fmt.println("value is", value) } main :: proc() { fmt.println(add(2, 3)) report(42) }
The function/subroutine split disappears, and with it the call keyword and the result(name) clause. Declaration order does not matter either, so the contains block that makes an interface explicit has no equivalent — every procedure in a package is visible to every other.
intent becomes the signature
Odin parameters are immutable bindings, which is intent(in) enforced by default. Mutation requires ^T in the signature and &value at the call site, so both sides show it — and outputs are simply returned.
program intents implicit none integer :: total, doubled total = 10 call adjust(total, doubled) print *, total, doubled contains subroutine adjust(value, output) integer, intent(inout) :: value ! read and written integer, intent(out) :: output ! written only value = value * 2 output = value + 1 end subroutine adjust end program intents
package main import "core:fmt" // intent(in) is the default: parameters are IMMUTABLE, and // assigning to one is a compile error. // intent(out) becomes a return value. // intent(inout) becomes an explicit pointer. adjust :: proc(value: ^int) -> (output: int) { value^ *= 2 return value^ + 1 } main :: proc() { total := 10 doubled := adjust(&total) fmt.println(total, doubled) }
Fortran's intent attributes are one of its best features, and Odin arrives at the same discipline by different means. The improvement is at the call site: call adjust(total, doubled) gives no hint that either argument is modified, while adjust(&total) marks it and a returned value needs no out-parameter at all.
Returning several values
Multiple return values are a language feature rather than an array or a set of out-parameters, so each result keeps its own type and nothing is allocated to carry them.
program multiple_results implicit none integer :: quotient, remainder ! A Fortran function returns exactly one value, so ! several results means several intent(out) arguments. call divide(17, 5, quotient, remainder) print *, quotient, remainder contains subroutine divide(numerator, denominator, quotient, remainder) integer, intent(in) :: numerator, denominator integer, intent(out) :: quotient, remainder quotient = numerator / denominator remainder = mod(numerator, denominator) end subroutine divide end program multiple_results
package main import "core:fmt" // Multiple results are part of the type. Naming them // documents the call site. divide :: proc(numerator, denominator: int) -> (quotient: int, remainder: int) { return numerator / denominator, numerator % denominator } main :: proc() { quotient, remainder := divide(17, 5) fmt.println(quotient, remainder) // Discarding a result requires the explicit blank _, // so it stays visible in review. _, only_remainder := divide(17, 5) fmt.println(only_remainder) }
This removes the main reason Fortran code reaches for a subroutine over a function. It is also the shape the error-handling section builds on — a value paired with a status is the standard Odin signature, and it is a genuine function call rather than a call statement that cannot appear inside an expression.
Optional arguments
A default value is written directly in the parameter list, and any parameter that has one can be passed by name with name = value, in any order.
program optional_arguments implicit none print *, scaled(10) print *, scaled(10, factor=3) contains function scaled(value, factor) result(output) integer, intent(in) :: value integer, intent(in), optional :: factor integer :: output integer :: effective effective = 2 if (present(factor)) effective = factor output = value * effective end function scaled end program optional_arguments
package main import "core:fmt" // A default value replaces optional + present(). Any // parameter with a default can be passed by name. scaled :: proc(value: int, factor := 2) -> int { return value * factor } main :: proc() { fmt.println(scaled(10)) fmt.println(scaled(10, factor = 3)) fmt.println(scaled(10, 3)) }
This is strictly simpler than the Fortran pattern: no optional attribute, no present() test, and no local variable holding the effective value. The one thing lost is the ability to distinguish "not supplied" from "supplied with the default value" — where that matters, a Maybe(T) parameter says so explicitly.
Generic interfaces
proc { a, b } declares a procedure group — one name that the compiler resolves to one of its members by matching the argument types, exactly as a generic interface does.
module describing implicit none ! A generic interface gathers several specific procedures ! under one name. It has to live in a module, because ! 'module procedure' needs a module. interface describe module procedure describe_integer module procedure describe_real end interface describe contains subroutine describe_integer(value) integer, intent(in) :: value print *, "integer:", value end subroutine describe_integer subroutine describe_real(value) real, intent(in) :: value print *, "real:", value end subroutine describe_real end module describing program generic_interface use describing, only: describe implicit none call describe(42) call describe(3.5) end program generic_interface
package main import "core:fmt" describe_integer :: proc(value: int) { fmt.println("integer:", value) } describe_real :: proc(value: f64) { fmt.println("real:", value) } // A procedure group is Fortran's generic interface: one // name, resolved to a member by the argument types. describe :: proc { describe_integer, describe_real, } main :: proc() { describe(42) describe(3.5) }
This is a direct correspondence, and it is unusual: most languages express overloading by letting several declarations share a name, while Fortran and Odin both make the grouping an explicit declaration you can read. Odin adds a second route the next section covers — a single parametric procedure, which handles the case where the bodies would be identical.
pure, elemental, and recursion
Odin has no purity annotations at all, so pure and elemental have no equivalent — and recursion needs no keyword, since Fortran 2018 made recursive the default anyway.
program purity implicit none real :: values(4) values = [1.0, 4.0, 9.0, 16.0] ! An elemental function applies to a whole array: print *, halve(values) print *, factorial(5) contains elemental function halve(value) result(output) real, intent(in) :: value real :: output output = value / 2.0 end function halve recursive function factorial(value) result(output) integer, intent(in) :: value integer :: output output = 1 if (value > 1) output = value * factorial(value - 1) end function factorial end program purity
package main import "core:fmt" // There is no 'pure', no 'elemental', and no 'recursive' // keyword — recursion just works, and purity is a property // of the code rather than a declaration. halve :: proc(value: f32) -> f32 { return value / 2 } factorial :: proc(value: int) -> int { if value > 1 { return value * factorial(value - 1) } return 1 } main :: proc() { values := [4]f32{1, 4, 9, 16} // The elemental case is a loop, or — for arithmetic — // the array operators do it directly. fmt.println(values / 2) halved: [4]f32 for value, index in values { halved[index] = halve(value) } fmt.println(halved) fmt.println(factorial(5)) }
elemental is the real loss: lifting a scalar function over an entire array is expressive and lets the compiler vectorize it. Odin recovers part of it — arithmetic on fixed arrays is element-wise natively, as the first line shows — but a user-defined procedure has to be applied by a loop.
Derived Types & Structs
Derived types become structs
Field access uses . rather than %, and a struct literal takes either positional values or named fields — the same two forms as a Fortran structure constructor.
program derived_types implicit none type :: particle real :: x, y logical :: alive end type particle type(particle) :: first, second first = particle(1.0, 2.0, .true.) second = first ! a copy second%x = 99.0 print *, first%x, second%x print *, first end program derived_types
package main import "core:fmt" Particle :: struct { x: f32, y: f32, alive: bool, } main :: proc() { // Positional or named initialization, same as Fortran's // structure constructor. first := Particle{1, 2, true} second := first // a copy second.x = 99 fmt.println(first.x, second.x) // %v formats any type with no code from you. fmt.println(first) named := Particle{x = 0, y = 0, alive = false} fmt.println(named, size_of(Particle)) }
The semantics match closely: both are plain value types with no hidden header, both copy on assignment, and both lay their fields out in declaration order. What Odin adds free is formatting and comparison — fmt.println(first) and first == second work on any struct, derived from the layout rather than written by hand.
Type-bound procedures
Odin has no contains block on a type and no class(...) dummy argument — the value being operated on is the first parameter, and Odin auto-dereferences through pointers so rectangle.width works either way.
module shapes_demo implicit none ! Type-bound procedures require a module: the binding needs ! an explicit interface, which only a module provides. type :: rectangle real :: width, height contains procedure :: area => rectangle_area procedure :: scale => rectangle_scale end type rectangle contains function rectangle_area(self) result(output) class(rectangle), intent(in) :: self real :: output output = self%width * self%height end function rectangle_area subroutine rectangle_scale(self, factor) class(rectangle), intent(inout) :: self real, intent(in) :: factor self%width = self%width * factor self%height = self%height * factor end subroutine rectangle_scale end module shapes_demo program type_bound use shapes_demo, only: rectangle implicit none type(rectangle) :: shape shape = rectangle(3.0, 4.0) print *, shape%area() call shape%scale(2.0) print *, shape%area() end program type_bound
package main import "core:fmt" Rectangle :: struct { width: f32, height: f32, } // No type-bound procedures and no 'self'. The thing being // operated on is simply the first parameter. rectangle_area :: proc(rectangle: Rectangle) -> f32 { return rectangle.width * rectangle.height } rectangle_scale :: proc(rectangle: ^Rectangle, factor: f32) { rectangle.width *= factor rectangle.height *= factor } main :: proc() { shape := Rectangle{3, 4} fmt.println(rectangle_area(shape)) rectangle_scale(&shape, 2) fmt.println(rectangle_area(shape)) }
The type_verb naming convention does the work shape%area() does in Fortran. Losing the binding also loses what class(...) enables — dispatch to an extended type — and the tagged union in the next row is the replacement for it.
Type extension becomes a union
switch specific in shape both tests which variant is active and binds it, so inside each arm specific has that concrete type. The union is as large as its biggest variant plus a tag, with no heap allocation.
program type_extension implicit none type :: shape real :: scale end type shape type, extends(shape) :: circle real :: radius end type circle type(circle) :: round round%scale = 1.0 round%radius = 2.0 ! The parent's components are inherited by name... print *, round%scale, round%radius ! ...and reachable through the parent component too. print *, round%shape%scale end program type_extension
package main import "core:fmt" import "core:math" Circle :: struct { radius: f32 } Square :: struct { side: f32 } // Instead of a base type plus extensions, a union lists // every variant. The compiler 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)) } }
Odin does have using on a struct field, which embeds another struct and promotes its fields — that is the closest match to extends for the data. What it cannot do is dispatch, so class(shape) polymorphism becomes this union, which the compiler checks for missing arms instead of leaving to a select type you wrote by hand.
Arrays of derived types
Adding #soa to an array type changes the memory layout — every x becomes contiguous, then every y — while fast[index].x still reads as it did. The compiler rewrites each access.
program array_of_types implicit none type :: particle real :: x, y logical :: alive end type particle type(particle) :: swarm(4) integer :: index do index = 1, 4 swarm(index) = particle(real(index), 0.0, .true.) end do ! The array is stored particle-by-particle, so a loop ! touching only x strides over y and alive as well. do index = 1, 4 swarm(index)%x = swarm(index)%x + 1.0 end do print *, swarm(3)%x print *, swarm%x ! a component section of the array end program array_of_types
package main import "core:fmt" Particle :: struct { x: f32, y: f32, alive: bool, } main :: proc() { swarm: [4]Particle for index in 0 ..< 4 { swarm[index] = Particle{f32(index + 1), 0, true} } for index in 0 ..< 4 { swarm[index].x += 1 } fmt.println(swarm[2].x) // #soa stores each FIELD contiguously — all the xs, then // all the ys — while the indexing syntax is unchanged. fast: #soa[4]Particle for index in 0 ..< 4 { fast[index] = Particle{f32(index + 1), 0, true} fast[index].x += 1 } fmt.println(fast[2].x) fmt.println(len(fast)) }
Fortran gives you swarm%x as a component section, but the storage is still interleaved, so reading it strides through memory. Splitting the array of derived types into parallel arrays is the standard hand optimization in HPC codes; #soa is that transformation as one directive, with the field syntax preserved.
Modules & Packages
Modules become directories
Odin's compilation unit is the directory, so splitting a package across files needs no declaration in either one. Imports are always qualified at the point of use — there is no use that pulls names in unqualified, and therefore no only: clause to write.
module geometry_demo implicit none real, parameter :: PI = 3.14159265358979 contains function circle_area(radius) result(area) real, intent(in) :: radius real :: area area = PI * radius * radius end function circle_area end module geometry_demo program use_module use geometry_demo, only: circle_area, PI implicit none print *, PI print *, circle_area(2.0) end program use_module
// A DIRECTORY is a package. Every .odin file in it shares // one namespace and sees every declaration, in any order — // no 'contains', no .mod file, no compile ordering. // // geometry/circle.odin package geometry // geometry/rectangle.odin package geometry // main.odin import "geometry" package main import "core:fmt" import "core:math" circle_area :: proc(radius: f32) -> f32 { return math.PI * radius * radius } main :: proc() { fmt.println(math.PI) fmt.println(circle_area(2)) }
This is close to what Fortran modules do, with the ordering problem removed: no .mod artifact to build first, and no rule that a module must be compiled before its users. What is lost is use, only: — Odin always qualifies, so geometry.circle_area is written in full every time, which also means nothing can be shadowed by an import.
public and private
Visibility is an attribute written above the declaration rather than a statement listing names, so each declaration carries its own answer and there is no separate export list to keep in sync.
module cache implicit none private public :: fetch contains function fetch(key) result(output) integer, intent(in) :: key integer :: output output = normalize(key) end function fetch function normalize(key) result(output) integer, intent(in) :: key integer :: output output = abs(key) end function normalize end module cache program use_cache use cache, only: fetch implicit none print *, fetch(-42) end program use_cache
package main import "core:fmt" // Two levels, both compile-time facts: // @(private) hidden from other packages // @(private = "file") hidden from other files here too @(private = "file") normalize :: proc(key: int) -> int { return abs(key) } fetch :: proc(key: int) -> int { return normalize(key) } main :: proc() { fmt.println(fetch(-42)) }
The models correspond well, with Odin defaulting to public and Fortran modules conventionally declaring private and then listing exports. Odin's extra level is @(private = "file"), which hides a helper even from the other files in its own package — finer than anything Fortran offers.
Module variables
A variable declared at package scope is the module variable, and @(static) inside a procedure keeps a local alive between calls — the two roles Fortran's save attribute covers.
module counters implicit none integer, save :: total = 0 contains subroutine increment(amount) integer, intent(in) :: amount total = total + amount end subroutine increment end module counters program use_counters use counters, only: total, increment implicit none call increment(5) call increment(3) print *, total end program use_counters
package main import "core:fmt" // A package-scope variable is the module variable. It is // zero-initialized and lives for the whole program. total: int increment :: proc(amount: int) { total += amount } main :: proc() { increment(5) increment(3) fmt.println(total) // A procedure-local that survives between calls: next_id() fmt.println(next_id()) } next_id :: proc() -> int { @(static) counter: int counter += 1 return counter }
Both languages zero-initialize this storage, so the = 0 in the Fortran declaration is doing what save plus initialization implies rather than what it looks like. The habits around it should transfer too: package-scope mutable state is as awkward to test and as unsafe across threads here as a module variable is in Fortran.
Input & Output
print and formatted output
Odin uses C-style format verbs — %d, %f, %s, with an optional width and precision — rather than Fortran's edit descriptors. %v has no Fortran counterpart: it formats any value, including structs and slices, without being told the type.
program output implicit none integer :: count real :: ratio character(len=10) :: label count = 42 ratio = 3.14159 label = "sample" print *, count, ratio, trim(label) write (*, "(i5)") count write (*, "(f8.3)") ratio write (*, "(a, i0, a, f6.2)") "count=", count, " ratio=", ratio write (*, "(a10, 1x, i4)") trim(label), count end program output
package main import "core:fmt" main :: proc() { count := 42 ratio := 3.14159 label := "sample" // println space-separates and adds a newline, like // list-directed print *. fmt.println(count, ratio, label) // printf uses C-style verbs rather than edit descriptors. fmt.printf("%5d\n", count) fmt.printf("%8.3f\n", ratio) fmt.printf("count=%v ratio=%.2f\n", count, ratio) fmt.printf("%10s %4d\n", label, count) }
The correspondence is direct: i5 is %5d, f8.3 is %8.3f, a10 is %10s. The thing to unlearn is the repeat-count and slash syntax of complex format strings — Odin has no equivalent, so a multi-line record is a loop over printf calls.
Reading and writing files
There are no unit numbers and no open/close pairing for the common case: os.read_entire_file and os.write_entire_file handle a whole file, and the returned buffer is yours to delete. Streaming handles exist in core:os when a file will not fit in memory.
program file_io implicit none integer :: unit_number, status character(len=100) :: line open (newunit=unit_number, file="example_scratch.txt", status="replace", action="write") write (unit_number, "(a)") "first line" write (unit_number, "(a)") "second line" close (unit_number) open (newunit=unit_number, file="example_scratch.txt", status="old", action="read") do read (unit_number, "(a)", iostat=status) line if (status /= 0) exit print *, trim(line) end do close (unit_number, status="delete") end program file_io
package main import "core:fmt" import "core:os" import "core:strings" main :: proc() { path := "example_scratch.txt" defer os.remove(path) contents := "first line\nsecond line\n" write_error := os.write_entire_file(path, contents) fmt.println("wrote:", write_error == nil) // Read it back whole, then split. data, read_error := os.read_entire_file(path, context.allocator) defer delete(data) if read_error != nil { fmt.println("could not read:", read_error) return } lines := strings.split_lines(string(data)) defer delete(lines) for line in lines { if len(line) == 0 { continue } fmt.println(line) } }
The unit-number bookkeeping that newunit= was introduced to make safe simply has no equivalent, and neither does the read-until-iostat-is-nonzero loop. What replaces the iostat= discipline is the same idea in Odin's shape — a second return value reporting success, which the caller must name to look at.
Command-line arguments
os.args is an ordinary []string, so len gives the count and the loop yields (value, index) — value first.
program arguments implicit none integer :: count, index, length, status character(len=256) :: value count = command_argument_count() print *, "argument count:", count do index = 0, count call get_command_argument(index, value, length, status) if (status /= 0) exit print *, index, trim(value(1:length)) end do end program arguments
package main import "core:fmt" import "core:os" main :: proc() { // os.args is a plain slice of strings; element 0 is the // program path, matching get_command_argument(0). fmt.println("argument count:", len(os.args) - 1) for value, index in os.args { fmt.println(index, value) } }
The Fortran version needs a fixed-width buffer, a returned length, a status code, and a trim to recover the actual text, because character(len=256) cannot describe a string of unknown size. This is the fixed-length character problem from the strings section in its most visible form.
Error Handling
stat= and iostat= become return values
An error enum's zero value is the success case, so error != nil reads as "something went wrong" for enums, unions, and pointers alike. The error is a named value rather than an integer whose meaning is compiler-specific.
program status_codes implicit none real, allocatable :: values(:) integer :: status character(len=200) :: message allocate(values(10), stat=status, errmsg=message) if (status /= 0) then print *, "allocation failed:", trim(message) else print *, "allocated", size(values) deallocate(values) end if print *, safe_divide(10, 0) contains function safe_divide(numerator, denominator) result(output) integer, intent(in) :: numerator, denominator integer :: output output = -1 ! a sentinel, by convention if (denominator /= 0) output = numerator / denominator end function safe_divide end program status_codes
package main import "core:fmt" // An error enum whose zero value means "fine" is the // idiomatic shape. There are no exceptions in either language. Division_Error :: enum { None, Division_By_Zero, } divide :: proc(numerator, denominator: int) -> (quotient: int, error: Division_Error) { if denominator == 0 { return 0, .Division_By_Zero } return numerator / denominator, .None } main :: proc() { quotient, error := divide(10, 2) fmt.println(quotient, error) _, failed := divide(10, 0) if failed != nil { fmt.println("failed:", failed) } }
Fortran already works this way — stat=, iostat=, and sentinel returns are all error values — so the discipline transfers directly. The two improvements are that the error type is a named enum instead of a magic integer, and that a sentinel like -1 is no longer needed, since the status travels beside the value rather than inside it.
Propagating an error
or_return is a suffix that collapses "if the last return value is non-zero, return it from this procedure" into a single word. It only compiles when the enclosing procedure's results are named.
program propagate implicit none integer :: value, status call parse_and_double("21", value, status) print *, value, status call parse_and_double("nope", value, status) print *, value, status contains subroutine parse_and_double(text, output, status) character(len=*), intent(in) :: text integer, intent(out) :: output, status integer :: parsed ! Every call site repeats the check by hand. read (text, *, iostat=status) parsed if (status /= 0) then output = 0 return end if output = parsed * 2 end subroutine parse_and_double end program propagate
package main import "core:fmt" import "core:strconv" Parse_Error :: enum { None, Not_A_Number } parse :: proc(text: string) -> (value: int, error: Parse_Error) { parsed, ok := strconv.parse_int(text) if !ok { return 0, .Not_A_Number } return parsed, .None } // or_return returns early on any non-zero error. It requires // NAMED return values on the enclosing procedure. parse_and_double :: proc(text: string) -> (value: int, error: Parse_Error) { parsed := parse(text) or_return return parsed * 2, .None } main :: proc() { good, good_error := parse_and_double("21") fmt.println(good, good_error) bad, bad_error := parse_and_double("nope") fmt.println(bad, bad_error) }
This is the piece Fortran has no answer to. Checking iostat and returning early has to be written by hand at every call, which is exactly why so much Fortran code checks it once and then stops bothering. or_return makes forwarding shorter than ignoring.
error stop and assertions
assert and panic abort the process, print a message and a stack trace, and cannot be caught — the same finality as error stop. Assertions are removed entirely under -o:speed.
program assertions implicit none real :: values(3) values = [1.0, 2.0, 3.0] print *, average(values) ! error stop halts the program with a nonzero exit status. ! There is no way to catch it, which is the point. if (size(values) == 0) error stop "empty input" print *, "still running" contains function average(numbers) result(mean) real, intent(in) :: numbers(:) real :: mean mean = sum(numbers) / size(numbers) end function average end program assertions
package main import "core:fmt" average :: proc(numbers: []f64) -> f64 { // assert is for conditions that should be IMPOSSIBLE if // the code is correct. It panics, and there is no recover. 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)) fmt.println("assertions are compiled out under -o:speed") }
Both languages draw the same line, which is unusual and worth noticing: an expected failure is a status value, while a violated invariant halts the program. Odin adds the stack trace and the ability to compile assertions out of a release build, neither of which error stop offers.
Bounds checking
Odin checks bounds by default in a debug build and removes the checks under -o:speed or a #no_bounds_check block. Because a fixed array's length is part of its type, a constant out-of-range index fails at compile time.
program bounds implicit none integer :: values(5) integer :: index values = [1, 2, 3, 4, 5] index = 3 print *, values(index) ! gfortran -fcheck=bounds catches an out-of-range index at ! runtime; without it, the read silently goes past the end. ! Bounds checking is OFF by default in every compiler. print *, size(values), lbound(values, 1), ubound(values, 1) end program bounds
package main import "core:fmt" main :: proc() { values := [5]int{1, 2, 3, 4, 5} index := 2 fmt.println(values[index]) // Bounds checking is ON by default in debug builds and // removed by -o:speed or #no_bounds_check. fmt.println(len(values)) // A constant out-of-range index is a COMPILE error, // because the length is part of the type: // fmt.println(values[9]) // Error: index 9 out of range slice := values[1:4] fmt.println(len(slice), slice) }
The defaults are inverted, and Odin's is the safer one: Fortran compilers ship with -fcheck=bounds off, so an out-of-range read in production silently returns whatever was next in memory. The compile-time rejection of a constant index has no Fortran equivalent at all.
Numeric Computing
Matrices and linear algebra
The literal is written in row-major reading order — the first row, then the second — which is the opposite of what reshape fills in Fortran. * on two matrices is a genuine matrix product, and transpose and determinant are builtin or in core:math/linalg.
program matrices implicit none real :: a(2, 2), b(2, 2), product(2, 2) real :: vector(2), transformed(2) a = reshape([1.0, 3.0, 2.0, 4.0], [2, 2]) b = reshape([1.0, 0.0, 0.0, 1.0], [2, 2]) vector = [1.0, 1.0] product = matmul(a, b) transformed = matmul(a, vector) print *, product print *, transformed print *, dot_product(vector, vector) print *, transpose(a) end program matrices
package main import "core:fmt" import "core:math/linalg" main :: proc() { // matrix[R, C]T is a BUILTIN type, and * is a real // matrix product — not element-wise. a := matrix[2, 2]f32{ 1, 2, 3, 4, } b := matrix[2, 2]f32{ 1, 0, 0, 1, } vector := [2]f32{1, 1} fmt.println(a * b) fmt.println(a * vector) fmt.println(linalg.dot(vector, vector)) fmt.println(linalg.transpose(a)) fmt.println(linalg.determinant(a)) }
Very few languages ship a matrix type with a real product operator, and Odin is one of them — matmul(a, b) becomes a * b. Note the layout difference in the literal: Fortran's reshape fills down the first column, so the same visual arrangement means different matrices in the two columns.
Mathematical intrinsics
The mathematical functions live in core:math rather than being intrinsics, while abs, min, and max stay builtin. Note that math.ln is the natural logarithm — math.log takes an explicit base.
program math_intrinsics implicit none real :: value value = 2.0 print *, sqrt(value), exp(value), log(value) print *, sin(value), cos(value), atan2(1.0, 1.0) print *, abs(-value), mod(7, 3), modulo(-7, 3) print *, floor(2.7), ceiling(2.1), nint(2.5) print *, max(1.0, 2.0), min(1.0, 2.0) end program math_intrinsics
package main import "core:fmt" import "core:math" main :: proc() { value := f64(2.0) one := f64(1.0) fmt.println(math.sqrt(value), math.exp(value), math.ln(value)) fmt.println(math.sin(value), math.cos(value), math.atan2(one, one)) negative_seven := -7 fmt.println(abs(-value), 7 % 3, math.floor_mod(negative_seven, 3)) fmt.println(math.floor(f64(2.7)), math.ceil(f64(2.1)), math.round(f64(2.5))) fmt.println(max(one, value), min(one, value)) }
The set corresponds nearly one for one, including the mod/modulo distinction: % truncates toward zero like mod, and math.floor_mod follows the sign of the divisor like modulo. That difference causes real bugs with negative operands in both languages.
Floating-point limits
The limits are named constants in core:mathF64_EPSILON, F64_MIN — and max(f64) is the builtin that answers what huge() answers, evaluated at compile time.
program precision_demo use iso_fortran_env, only: real64 implicit none real(real64) :: value value = 1.0_real64 print *, epsilon(value) print *, huge(value) print *, tiny(value) print *, precision(value), range(value) print *, 0.1_real64 + 0.2_real64 == 0.3_real64 end program precision_demo
package main import "core:fmt" import "core:math" main :: proc() { fmt.println(math.F64_EPSILON) fmt.println(max(f64)) fmt.println(math.F64_MIN) fmt.println(0.1 + 0.2 == 0.3) // The comparison you actually want, in both languages: left := 0.1 + 0.2 right := 0.3 fmt.println(abs(left - right) < math.F64_EPSILON * 4) }
Fortran's inquiry intrinsics are more systematic: epsilon(x) takes any variable and answers for its kind, so generic numerical code can query the precision it was instantiated with. Odin names each constant per type, so a parametric procedure needs a when on the type to pick the right one.
Random numbers
The generator travels in the implicit context, so seeding it once redirects every rand call below that point — the same mechanism the allocator uses, applied to randomness.
program random_demo implicit none real :: value real :: values(5) integer :: seed_size integer, allocatable :: seed(:) call random_seed(size=seed_size) allocate(seed(seed_size)) seed = 42 call random_seed(put=seed) call random_number(value) call random_number(values) print *, value >= 0.0 .and. value < 1.0 print *, size(values) deallocate(seed) end program random_demo
package main import "core:fmt" import "core:math/rand" main :: proc() { // A seeded generator is an explicit value you create and // pass, rather than hidden global state. generator := rand.create(42) context.random_generator = rand.default_random_generator(&generator) value := rand.float64() fmt.println(value >= 0.0 && value < 1.0) values: [5]f64 for index in 0 ..< len(values) { values[index] = rand.float64() } fmt.println(len(values)) fmt.println(rand.int_max(100) < 100) }
Fortran's random_seed is process-global and its size is compiler-dependent, which is why seeding it portably takes four statements. Putting the generator in the context means a routine can be given its own reproducible stream without changing any signature — useful when a simulation needs each worker seeded independently.
Generics & Compile Time
Generic procedures
A $ marks a parameter the compiler resolves at the call site: []$T matches a slice of any element type and binds T to it. Each distinct instantiation is compiled separately, so there is no dispatch and no boxing at runtime.
module largest_demo implicit none ! Fortran has no generics. The same body must be written ! once per type and gathered under a generic interface, ! which in turn has to live in a module. interface largest module procedure largest_integer module procedure largest_real end interface largest contains function largest_integer(values) result(best) integer, intent(in) :: values(:) integer :: best best = maxval(values) end function largest_integer function largest_real(values) result(best) real, intent(in) :: values(:) real :: best best = maxval(values) end function largest_real end module largest_demo program generics use largest_demo, only: largest implicit none print *, largest([3, 9, 1]) print *, largest([3.0, 9.0, 1.0]) end program generics
package main import "core:fmt" // $T is a type parameter resolved at compile time. ONE body, // and a separate specialized copy is generated per type used. largest :: proc(values: []$T) -> T { best := values[0] for value in values[1:] { if value > best { best = value } } return best } main :: proc() { integers := []int{3, 9, 1} reals := []f64{3, 9, 1} fmt.println(largest(integers)) fmt.println(largest(reals)) }
This is the largest capability gain on the page. Fortran's answer to a generic algorithm is duplication — the same body written once per kind and per type, collected in a generic interface, which is why numerical libraries ship four copies of every routine and why preprocessor-based code generation is so common in the ecosystem.
Parameterized types
Stack($T: typeid) declares a parametric type, and Stack(int) instantiates it. A procedure written against ^Stack($T) matches any instantiation and binds T from it.
program parameterized implicit none ! Fortran 2003 parameterized derived types exist, but ! compiler support has been patchy for two decades, so ! most code uses a fixed kind or duplicates the type. type :: real_stack real, allocatable :: items(:) integer :: count = 0 end type real_stack type(real_stack) :: stack allocate(stack%items(10)) stack%count = 1 stack%items(1) = 3.5 print *, stack%count, stack%items(1) deallocate(stack%items) end program parameterized
package main import "core:fmt" // A type can take a type parameter, so one declaration // covers every element type. 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: Stack(int) defer delete(numbers.items) stack_push(&numbers, 1) stack_push(&numbers, 2) fmt.println(stack_pop(&numbers)) reals: Stack(f64) defer delete(reals.items) stack_push(&reals, 3.5) fmt.println(stack_pop(&reals)) }
A Stack(int) and a Stack(f64) are separate types the compiler generates from one declaration, with no possibility of mixing them up. Fortran's parameterized derived types aim at the same thing but have had unreliable compiler support since 2003, so the practical Fortran answer remains duplication.
Compile-time array sizes
A $ parameter can bind a value as well as a type: [$N]f32 matches a fixed array of any length and makes N a compile-time constant inside the body.
program compile_time_size implicit none integer, parameter :: N = 4 real :: fixed(N) integer :: index do index = 1, N fixed(index) = real(index) end do ! The size is a parameter, but a procedure taking this ! array cannot specialize on it — an assumed-shape dummy ! gets its extent at runtime from the descriptor. print *, total(fixed) print *, total([1.0, 2.0]) contains function total(values) result(sum_of) real, intent(in) :: values(:) real :: sum_of integer :: index sum_of = 0.0 do index = 1, size(values) sum_of = sum_of + values(index) end do end function total end program compile_time_size
package main import "core:fmt" // $N binds the array LENGTH as a compile-time constant, so // the loop bound is known in each generated copy. total :: proc(values: [$N]f32) -> f32 { sum: f32 = 0 for index in 0 ..< N { sum += values[index] } return sum } main :: proc() { four := [4]f32{1, 2, 3, 4} two := [2]f32{1, 2} fmt.println(total(four)) fmt.println(total(two)) fmt.println(N_is_not_a_runtime_value()) } N_is_not_a_runtime_value :: proc() -> string { return "each call compiled its own loop bound" }
The consequence is that the compiler can fully unroll or vectorize the loop, because the trip count is known when the copy is generated. Fortran can only get there with an explicit-shape dummy argument declared for one specific size — the assumed-shape form used above learns its extent from the descriptor at runtime.
Conditional compilation
when is the compile-time sibling of if: its condition must be a constant expression, and the branch not taken is discarded before type checking — which is what makes it safe to reference platform-specific procedures inside one.
program conditional implicit none ! Fortran has no standard preprocessor. The C preprocessor ! is used by convention (.F90 files, gfortran -cpp) with ! #ifdef, which works on text before the compiler sees it ! and has no type checking or scoping. print *, "compiled at:", compile_note() contains function compile_note() result(note) character(len=20) :: note note = "build time unknown" end function compile_note end program conditional
package main import "core:fmt" main :: proc() { // 'when' is part of the LANGUAGE, evaluated by the // compiler. The untaken branch is never type-checked // and never emitted — no preprocessor involved. when ODIN_DEBUG { fmt.println("debug build") } else { fmt.println("release build") } 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(ODIN_ARCH, size_of(rawptr) * 8, "bit") }
Fortran leans on the C preprocessor for this, which is why so many projects carry .F90 files and a -cpp flag. when is a real language construct with real scoping, and it composes with $ parameters so a generic procedure can specialize its body per instantiated type.
C Interoperability
Calling a C function
A foreign block declares procedures that live in another library; the trailing --- marks a declaration with no body. core:c supplies c.int, c.double, and the rest of the C type names — Odin's iso_c_binding.
program c_interop use iso_c_binding implicit none interface function c_abs(value) bind(c, name="abs") result(output) import :: c_int integer(c_int), value :: value integer(c_int) :: output end function c_abs end interface print *, c_abs(-42_c_int) end program c_interop
package main import "core:fmt" import "core:c" // The library name is platform-specific, so the import is // chosen at compile time. when ODIN_OS == .Darwin { foreign import libc "system:System.framework" } else { foreign import libc "system:c" } @(default_calling_convention = "c") foreign libc { // link_name avoids colliding with Odin's builtin abs. @(link_name = "abs") c_abs :: proc(value: c.int) -> c.int --- } main :: proc() { fmt.println(c_abs(-42)) }
The two approaches are structurally the same: declare the foreign signature with C types, name the symbol, and call it. Odin needs no value attribute because pass-by-value is already the default, where Fortran passes by reference and must be told otherwise on every scalar argument.
Interoperable data layout
Odin lays structs out in declaration order with C-compatible padding by default, so no attribute is needed for interoperability. #packed, #align(N), and #raw_union are there to change the layout when a binary format demands it.
program c_layout use iso_c_binding implicit none ! bind(c) guarantees the layout matches a C struct. type, bind(c) :: point real(c_float) :: x, y end type point type(point) :: location location = point(1.5_c_float, 2.5_c_float) print *, location%x, location%y print *, c_sizeof(location) end program c_layout
package main import "core:fmt" import "core:c" // Odin structs already use C layout by default, so no // attribute is needed to pass one to a C function. Point :: struct { x: c.float, y: c.float, } // The attributes are there when you need to CHANGE it: Packed_Header :: struct #packed { kind: u8, length: u32, } main :: proc() { location := Point{1.5, 2.5} fmt.println(location.x, location.y) fmt.println(size_of(Point)) // #packed removes padding: 5 bytes, not 8. fmt.println(size_of(Packed_Header)) }
Fortran needs bind(c) because its own derived types carry no layout guarantee, and it has no standard way to express a packed structure at all — matching a wire format or a file header means reading bytes and assembling fields by hand. #packed is a real improvement for anyone parsing binary data.
Parallelism & Data Layout
do concurrent and threads
A thread procedure takes a single rawptr, so the work description — which slice, which index range — is a struct you pass. core:thread and core:sync are ordinary library code, not compiler directives.
program concurrency implicit none real :: values(1000) integer :: index ! do concurrent asserts that iterations are independent, ! letting the compiler vectorize or parallelize them. do concurrent (index = 1:1000) values(index) = sqrt(real(index)) end do print *, values(1), values(1000) ! Actual threading is OpenMP directives or coarrays, both ! outside the base language and both compiler-dependent. print *, sum(values) > 0.0 end program concurrency
package main import "core:fmt" import "core:math" import "core:thread" Work :: struct { values: []f64, from: int, to: int, } fill :: proc(argument: rawptr) { work := cast(^Work)argument for index in work.from ..< work.to { work.values[index] = math.sqrt(f64(index + 1)) } } main :: proc() { values := make([]f64, 1000) defer delete(values) // Real OS threads, in the standard library, with the // range split explicitly. No directives, no runtime. chunks := [2]Work{ {values, 0, 500}, {values, 500, 1000}, } handles: [2]^thread.Thread for index in 0 ..< len(chunks) { handles[index] = thread.create_and_start_with_data(&chunks[index], fill) } for handle in handles { thread.join(handle) thread.destroy(handle) } fmt.println(values[0], values[999]) }
This is a step backward in convenience and a step forward in explicitness. do concurrent and an OpenMP !$omp parallel do parallelize a loop with one line and no restructuring; Odin makes you partition the range yourself. What Odin does not have any answer to is coarrays — distributed-memory parallelism is entirely outside its scope.
Protecting shared state
Putting the sync.Mutex inside the struct it protects is the convention worth adopting, and defer sync.mutex_unlock releases it on every exit from the scope. sync.atomic_add covers the pure-counter case with no lock at all.
program shared_state implicit none integer :: total integer :: index total = 0 ! With OpenMP this would need a reduction or a critical ! section. In plain Fortran 2018 there is no portable ! mutex, so the serial version is the only safe one. do index = 1, 4 total = total + 10 end do print *, total end program shared_state
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) }
The base Fortran language has no threads and therefore no mutex — synchronization arrives through OpenMP directives, coarray sync statements, or a C binding, all outside the standard language. Odin puts real threads and real locks in the standard library, and puts the responsibility for using them correctly entirely on you.
Laying out data for the cache
#soa on an array type stores each field contiguously — every x, then every y — while planar[index].x continues to read exactly as it did on the interleaved version.
program layout implicit none type :: body real :: x, y, z real :: mass end type body ! Array of structures: x,y,z,mass repeated. A loop over ! positions alone still reads mass into every cache line. type(body) :: bodies(4) ! The hand optimization is parallel arrays, which loses ! the grouping that made the type meaningful: real :: xs(4), ys(4), zs(4), masses(4) integer :: index do index = 1, 4 bodies(index) = body(real(index), 0.0, 0.0, 1.0) xs(index) = real(index) end do print *, bodies(2)%x, xs(2) end program layout
package main import "core:fmt" Body :: struct { x, y, z: f32, mass: f32, } main :: proc() { // Array of structs: x,y,z,mass repeated. interleaved: [4]Body // #soa: all the xs, then all the ys, then all the zs — // parallel arrays, but you still write bodies[i].x. planar: #soa[4]Body for index in 0 ..< 4 { interleaved[index] = Body{f32(index + 1), 0, 0, 1} planar[index] = Body{f32(index + 1), 0, 0, 1} } fmt.println(interleaved[1].x, planar[1].x) // The layout change is invisible to every reader: total: f32 = 0 for index in 0 ..< 4 { total += planar[index].x } fmt.println(total) }
This is the page's clearest win for numerical work. Splitting an array of derived types into parallel arrays is the standard hand optimization in HPC codes, and its cost is that the grouping disappears from the source: xs, ys, zs, and masses no longer say they describe one body. #soa gives the layout without the loss.
Vector arithmetic
A fixed-size array in Odin doubles as the SIMD vector type, so left * right + 1 compiles to vector instructions directly, and .xy/.zw select components by name the way a shading language does.
program vectorization implicit none real :: left(4), right(4), result(4) left = [1.0, 2.0, 3.0, 4.0] right = [10.0, 20.0, 30.0, 40.0] ! Whole-array arithmetic gives the compiler permission to ! vectorize, though whether it does is up to the backend. result = left * right + 1.0 print *, result print *, sum(left * right) end program vectorization
package main import "core:fmt" import "core:math/linalg" main :: proc() { // Arithmetic on fixed-size arrays maps onto SIMD // registers directly — the array IS the vector type. left := [4]f32{1, 2, 3, 4} right := [4]f32{10, 20, 30, 40} result := left * right + 1 fmt.println(result) fmt.println(linalg.dot(left, right)) // Components can be selected by name: fmt.println(result.xy, result.zw) // Comparison yields a per-element boolean array: fmt.println(left, right) }
Both languages express the computation without a loop, which is more than most can say. The difference is one of guarantees: Fortran's whole-array expression is permission for the backend to vectorize, while an Odin fixed array of a hardware-friendly width maps onto the register directly — and the reduce half (sum, dot_product) is a procedure call here rather than an intrinsic.