Procedures & Parameters
If you have written functions in any other language, most of this page will feel familiar. Two things in Odin are worth special attention: results can be several values at once with names, and procedures are ordinary values you can pass around — which is how Odin gets overloading without a special feature for it.
Why Procedures
A procedure exists to give a name to a piece of work. That sounds modest, and it is the single most powerful idea in programming: naming things is how we stop holding all the details in our head at the same time.
The Shape of a Procedure
Every Odin procedure follows the same pattern: a name, the keyword proc, a parameter list, an optional result, and a body in braces. Here is the whole shape with the parts labelled:
// name parameters results body
// | | | |
greet :: proc(name: string) -> string { // <-- signature
return "Hellope, " + name // <-- body
}
main :: proc() {
message := greet("Ada")
fmt.println(message) // Hellope, Ada
}
Read the first line as a sentence: "greet is a procedure that takes a string called name and produces a string." Everything a caller needs to know is in that line — no hidden parameters, no implicit state, no surprises inside.
Naming and Reading
Odin procedure names are snake_case and, by convention, begin with a verb when they do something. The name plus the signature should be enough to use the procedure without reading its body:
parse_age(text: string) -> (age: int, ok: bool)— takes text, returns a number and whether it worked.average(values: []f64) -> f64— takes a slice of numbers, returns one number.is_empty(queue: []int) -> bool— a question, so the name starts withis_and the result is abool.
That last habit is worth adopting early: a procedure whose name is a question and whose result is a bool reads well at every call site, because if is_empty(queue) is a sentence.
Parameters
Parameters are the inputs of a procedure. Odin's syntax for them has a small convenience that removes most of the repetition, and three features that are worth knowing by name.
One Type, Many Names
Names that share a type are grouped together — exactly as they are in a variable declaration:
// `a, b: int` gives both parameters the same type.
add :: proc(a, b: int) -> int {
return a + b
}
fmt.println(add(2, 3)) // 5
Writing a: int, b: int is also legal and means precisely the same thing; the grouped form is simply what Odin code looks like. Beyond a couple of parameters, grouping is the difference between a signature you can read at a glance and one you have to squint at.
Default Values
A parameter can carry a default, which lets callers leave it out when the common value is fine:
// The second parameter has a default, so callers may skip it.
greet :: proc(name: string, greeting: string = "Hellope") -> string {
return greeting + ", " + name
}
fmt.println(greet("Ada")) // Hellope, Ada
fmt.println(greet("Ada", "Hello")) // Hello, Ada
Defaults are best for small variations — a separator, a width, a greeting. If a choice genuinely matters to the caller, ask for it as a normal parameter and let them say what they mean; a default that nobody should ever override is just a constant in disguise.
Variadic Parameters
Put .. before a parameter's type and it accepts any number of arguments, collected into a slice. Inside the procedure you cannot even tell the difference — it is a slice like any other, so every loop you learned in Phase 2 applies:
// `..int` collects every number after the label into a slice.
total :: proc(label: string, numbers: ..int) -> int {
sum := 0
for n in numbers { // `numbers` is a slice of int
sum += n
}
return sum
}
fmt.println(total("small", 1, 2, 3)) // 6
fmt.println(total("empty")) // 0
You have been using this feature since the first lesson: fmt.println is variadic, which is why it can take one value or twelve.
When the values already live in a slice, spread them into the call with .. — the same operator you met in the range lesson:
values := []int{4, 5, 6}
// `..` spreads the slice into individual arguments.
fmt.println(total("from a slice", ..values[:])) // 15
Passing Data In: Value or Pointer
By default a parameter is passed by value: the procedure receives its own copy, and nothing it does can change the caller's data. When a change should be visible outside, pass a pointer instead:
// By value: the parameter is a copy, so the caller is unaffected.
shout_wrong :: proc(text: string) {
text = text + "!" // only the local copy changes
}
// By pointer: ^string means "the address of a string".
// The ^ inside the body dereferences it — it means "the value at that address".
shout :: proc(text: ^string) {
text^ = text^ + "!"
}
main :: proc() {
word := "hello"
shout_wrong(word)
fmt.println(word) // hello — untouched
shout(&word) // & takes the address of `word`
fmt.println(word) // hello! — changed by the procedure
}
Reach for a pointer in two situations: the procedure must report a change back through its parameters, or the value is large enough that copying it is a real cost. In every other case, pass by value — a procedure that cannot modify your data is a procedure that cannot surprise you.
Returning Results
Odin's result list is where the language quietly shows off. A procedure can hand back several values at once, which removes a whole category of awkward code you may have written in other languages.
One Result
The arrow introduces the result type. Nothing more is needed:
square :: proc(x: int) -> int {
return x * x
}
fmt.println(square(7)) // 49
A procedure that reports nothing simply omits the arrow — you have been writing one of those since the first lesson, because main has no results:
// No arrow: this procedure does something and reports nothing back.
print_banner :: proc(title: string) {
fmt.println("=== " + title + " ===")
}
print_banner("Report") // === Report ===
Several Results
Wrap the results in parentheses and you can return more than one value. This is the idiomatic Odin answer to the question "what happened?" — no wrapper type, no output parameter, no global state:
// Two results, both named in the signature.
divmod :: proc(a, b: int) -> (quotient, remainder: int) {
return a / b, a % b
}
main :: proc() {
q, r := divmod(17, 5)
fmt.println(q, r) // 3 2
}
Look closely at what that does for the caller: divmod(17, 5) cannot be misused into computing one half of an answer, because the two halves arrive together.
Very often the second result is a "did it work?" flag — you already met the pattern in the control-flow lesson:
// A lookup returns the value and whether it existed.
if count, ok := stock["apples"]; ok {
fmt.println("we have", count)
}
We will formalise this properly in the errors lesson. For now, note the shape: return the answer and how it went. In Odin that is ordinary, expected, everyday code.
Named Results
Naming a result does more than document it. The name becomes an ordinary variable inside the body, already set to its zero value, and a bare return hands back whatever it currently holds:
divide :: proc(a, b: int) -> (result: int, ok: bool) {
if b == 0 {
return // a guard clause: returns 0, false — the zero values
}
result = a / b // the named result is a normal variable
ok = true
return // returns whatever result and ok hold now
}
fmt.println(divide(10, 2)) // 5 true
fmt.println(divide(10, 0)) // 0 false
This is where named results earn their keep. Two int results are impossible to tell apart at the call site, but (result, ok) explains itself. And the bare return after a guard clause is a tidy way to say "give the caller the zeros" — the same zero-value guarantee you met in the types lesson, now working in your favour.
The require_results Attribute
Some procedures are pointless to call if you throw the answer away. Odin lets the procedure say so, and then the compiler enforces it:
// @(require_results) makes it an error to call this and drop the results.
@(require_results)
parse_port :: proc(text: string) -> (port: int, ok: bool) {
return 0, false
}
main :: proc() {
// The compiler would reject `parse_port("8080")` on its own line.
port, ok := parse_port("8080")
if ok {
fmt.println("listening on", port)
}
}
That attribute is a small act of API design: the author of the procedure knows that ignoring the result is almost always a bug, so they encode that knowledge into the signature instead of writing it in a comment that nobody reads.
Procedures as Values
In Odin a procedure is not merely a piece of syntax — it is a value, like a number or a string. That single fact gives the language most of the flexibility you might expect to need interfaces for.
Assigning a Procedure
Because a procedure is a value, you can give it a second name simply by writing the name with no parentheses:
double :: proc(x: int) -> int {
return x * 2
}
// No parentheses: we are naming the procedure itself, not calling it.
twice :: double
fmt.println(twice(21)) // 42
And because it is a value, it can be a parameter — which is how you hand behaviour to a procedure, not just data:
// The second parameter is itself a procedure type: proc(int) -> int.
apply :: proc(values: []int, transform: proc(int) -> int) -> int {
total := 0
for v in values {
total += transform(v) // call the procedure we were handed
}
return total
}
values := []int{1, 2, 3}
fmt.println(apply(values, double)) // 12
Read the signature of apply again. "Give me some values and a way to transform one value." That is an extraordinarily useful shape, and it needed no new syntax: a procedure type is written proc(...) -> ..., exactly like the procedures you have been declaring all along.
Explicit Overloading with Procedure Groups
Odin does not silently pick between procedures with the same name — silent overload resolution makes call sites ambiguous to read. But sometimes one name should work for several types, and then you say so explicitly by listing the implementations:
// One implementation per concrete type.
double_i32 :: proc(x: i32) -> i32 { return x * 2 }
double_f64 :: proc(x: f64) -> f64 { return x * 2 }
// A procedure GROUP: one name, several implementations.
double :: proc{ double_i32, double_f64 }
fmt.println(double(i32(21))) // 42
fmt.println(double(1.5)) // 3.0
This is what "explicit procedure overloading" means in Odin: the name carries the list, the compiler chooses the one that fits the argument, and when nothing fits you get a clear error at the call site. Nothing is decided behind your back, and nothing converts silently to make a call work.
Inside a Procedure
Two last facts about procedures, one about where they can live and one about what they are not.
Nested Procedures
A procedure can declare another procedure inside it. The inner one is visible only within the body that declares it, which is exactly right for a helper that exists for one caller:
classify :: proc(score: int) -> string {
// Declared inside, so nothing outside can see or call it.
label :: proc(s: int) -> string {
if s >= 90 { return "excellent" }
if s >= 70 { return "good" }
return "keep going"
}
return label(score) // use it like any other procedure
}
fmt.println(classify(72)) // good
Nesting like this is a form of documentation. When a helper has exactly one caller, moving it inside that caller's body says so, and removes a name from the rest of the file.
No Methods, No Self
Odin has no methods. A procedure that works on one of your types simply takes that type as a parameter — you met this on the very first page of the track:
import "core:math"
Vector2 :: struct { x, y: f32 }
// Not a method: a procedure that takes the data as its first parameter.
length :: proc(v: Vector2) -> f32 {
return math.sqrt(v.x*v.x + v.y*v.y)
}
main :: proc() {
v := Vector2{3, 4}
fmt.println(length(v)) // 5
}
The consequence is worth holding on to: nothing is attached to the type. There is no hidden table of functions, no inheritance chain, and no ambiguity about which procedure a call site runs — it is named right there.
By convention the data comes first, so length(v) reads almost like the method you might have written in another language. It is two characters away from v.length(), and considerably more honest about what happens.
Designing Good Procedures
A signature is a tiny interface between two parts of your own program. Two principles keep those interfaces pleasant to use.
One Job Per Procedure
A procedure should do one thing, and its name should say which thing. If you cannot name it without the word "and", it is really two procedures wearing one hat — and it will be hard to test, hard to reuse, and hard to read. A well-shaped one tells you everything without its body:
// Three numbers in, one number out, no surprises.
clamp :: proc(value, low, high: int) -> int {
if value < low { return low }
if value > high { return high }
return value
}
fmt.println(clamp(15, 0, 10)) // 10
fmt.println(clamp(4, 0, 10)) // 4
You could write that inline every time you needed it, and after the third copy you would have three chances to get the boundaries wrong. Naming it once is cheaper.
Fail Early, Return Early
Guard clauses matter most inside procedures, because a procedure has a contract to keep. Check every reason to refuse before you do any work, and the work below can assume a good input:
// Refuse the cases you cannot handle, then do the real work in peace.
parse_positive :: proc(text: string) -> (value: int, ok: bool) {
if len(text) == 0 {
return 0, false // empty input: refuse, and say so
}
// (Turning text into a number properly comes later in the track.
// The lesson here is the ORDER of the checks, not the parsing.)
value = len(text) // stand-in work, so the shape is complete
ok = true
return
}
Notice how the two named results make the refusals honest. The caller gets a definite answer either way — a value and a flag — so there is no ambiguous state to reason about, and no error path that quietly forgets to set something.
Where This Goes Next
You can now build behaviour. The next lesson is about building data: grouping related values into your own named types with struct, naming sets of options with enum, and combining several possibilities with union.
The Page in One Breath
- A procedure is
name :: proc(parameters) -> results, and its signature is a contract the body must keep. - Names sharing a type are grouped:
proc(a, b: int). - Parameters can have defaults, and a
..parameter collects any number of arguments into a slice. - Parameters are passed by value unless you ask for a pointer with
^T, which is what lets a change travel back to the caller. - Results can be several and named; a bare
returnhands back whatever the named results hold. @(require_results)makes dropping a result a compile error.- Procedures are values: you can rename them, pass them, and collect several implementations under one name with a procedure group.
- Numbers add up: procedures can nest, and there are no methods — the data comes first by convention.
divmod yourself, but with named results and a guard clause that refuses a zero divisor. Then rename it with a plain assignment and pass it to apply from this page. You will have used most of the page in about fifteen lines.
Continue with Structs, Enums & Unions →