Odin and Me · Chapter 2

Chapter 2: Enough Odin to be dangerous

This chapter:

  • Variables, constants, and how to convert between types.
  • The four ways to use Odin's loop keyword, for.
  • switch, with no fallthrough by default.
  • Procedures: multiple return values and default arguments.
  • A working example that uses all of it: a small "what kind of number is this" checker.

Where we left off: a working toolchain and one line of printed output. This chapter teaches the vocabulary the rest of our journey depends on.


2.1 Variables and constants

Two ways to introduce a name in Odin:

message := "hello"        // type inferred from the value: string
count: int = 0            // type stated explicitly
pi :: 3.14159             // a constant, computed at compile time

:= declares a variable and infers its type from the right-hand side. name: Type = value states the type explicitly: useful when you want a specific type (f32 rather than the inferred f64, say) or when there's no initial value yet. :: declares a constant: a name bound once, at compile time, that can never be reassigned.

Odin note: why bother stating a type at all, if := can infer it? Mostly for the numeric types. Odin infers int for a whole-number literal and f64 for anything with a decimal point; if you actually want f32 or a smaller integer, you say so explicitly.

2.2 Basic types

The most common ones we'll use:

  • int: a whole number, the default for anything counted or indexed.
  • f64: a floating-point number (my default; f32 shows up rarely).
  • bool: true or false.
  • string: an immutable, read-only view over text (properly explained in Chapter 4).
  • rune: a single Unicode codepoint (more in Chapter 4).

2.3 Odin doesn't coerce

This is the single biggest adjustment if you're coming from Python, JavaScript or even C: Odin never silently converts one numeric type into another. Try to add an int and an f64 and the compiler stops you:

a: int = 3
b: f64 = 2.0
c := a + b   // compile error
Error: Mismatched types in binary expression 'a + b' : 'int' vs 'f64'

You have to say what you mean, with an explicit conversion:

c := f64(a) + b   // 5.0, fine, both operands are now f64

Why does Odin insist on this? It's a deliberate design feature. A silent int + float mix hides a real decision: which number is getting reinterpreted, and do you lose precision doing it? Odin makes you write the conversion, so the decision is visible in the code rather than buried in a language rule you have to memorise.

2.4 if and else

No parentheses required around the condition; braces are required around the body, even for one statement:

if age >= 18 {
	fmt.println("adult")
} else if age >= 13 {
	fmt.println("teenager")
} else {
	fmt.println("child")
}

2.5 for, in four shapes

Odin has exactly one loop keyword, for, used four ways. If you're coming from a C-family language, this is the other big adjustment: there's no separate while.

// 1. Three-part, C-style: init; condition; post
for i := 0; i < 3; i += 1 {
	fmt.println("three-part:", i)
}

// 2. Condition only: this is Odin's "while"
j := 0
for j < 3 {
	fmt.println("condition-only:", j)
	j += 1
}

// 3. No condition at all: loops forever until a `break`
count := 0
for {
	if count >= 3 {
		break
	}
	fmt.println("infinite (broken):", count)
	count += 1
}

// 4. Range-based: the one you'll write most often
for i in 0 ..< 3 {
	fmt.println("range:", i)
}

Odin note: reading 0 ..< 3. ..< is a half-open range: 0, 1, 2; it excludes the top end, exactly like indexing a slice of length 3. ..= is the closed variant, which includes the top end (0 ..= 3 is 0, 1, 2, 3). You'll see me use both; reach for ..< whenever you're counting up to but not including a length, which is most of the time.

2.6 switch

switch compares a value against several cases, and, unlike C, does not fall through to the next case by default:

switch day {
case 1, 7:
	fmt.println("weekend")
case 2, 3, 4, 5, 6:
	fmt.println("weekday")
case:
	fmt.println("not a valid day")
}

A bare switch with no value to compare, and boolean conditions in each case, is a clean way to write a chain of if/else if that reads a little tidier once there are more than two branches; you'll see it in the example further down.

2.7 Procedures

A procedure can return more than one value, and parameters can have default values:

divmod :: proc(a, b: int) -> (q: int, r: int) {
	return a / b, a % b
}

greet :: proc(name: string, excited := false) {
	if excited {
		fmt.printfln("Hello, %s!!", name)
	} else {
		fmt.printfln("Hello, %s.", name)
	}
}
q, r := divmod(17, 5)
fmt.printfln("17 / 5 = %d remainder %d", q, r)   // 17 / 5 = 3 remainder 2

greet("Billy")          // Hello, Billy.
greet("Billy", true)     // Hello, Billy!!

2.8 Chapter Example

Put it together: a procedure that classifies a number, using everything above. Create the file, enter the code and try to run it yourself. The answer is in the checkpoint if you get stuck.

// numbers.odin
package main

import "core:fmt"

is_prime :: proc(n: int) -> bool {
	if n < 2 {
		return false
	}
	for i := 2; i*i <= n; i += 1 {
		if n % i == 0 {
			return false
		}
	}
	return true
}

describe :: proc(n: int) -> string {
	switch {
	case n < 0:
		return "negative"
	case n == 0:
		return "zero"
	case is_prime(n):
		return "prime"
	case:
		return "composite"
	}
}

main :: proc() {
	for n in 0 ..< 12 {
		fmt.printfln("%d is %s", n, describe(n))
	}
}
0 is zero
1 is composite
2 is prime
3 is prime
4 is composite
5 is prime
6 is composite
7 is prime
8 is composite
9 is composite
10 is composite
11 is prime

Consider is_prime a format you'll use often: an early return false for the easy failing cases, then a loop that only needs to find one counterexample to disprove primality, falling through to return true if it never does. And describe is the bare switch from §2.6 doing real work: each case is a condition, checked top to bottom, first match wins, including calling another procedure (is_prime) right inside a case.

Checkpoint

odin run numbers.odin -file prints twelve lines classifying 0 through 11. You've now written and run variables, constants, all four shapes of for, switch, and a two-procedure program with a multi-branch switch doing the real work. And this is only Chapter Two!

Exercises

  1. Add a case to describe for negative even numbers, printed as "negative even" instead of plain "negative". (Solution sketch: a case is just a boolean condition here, add case n < 0 && n % 2 == 0: above the existing case n < 0:, since the first matching case wins.)
  2. Rewrite the range-based loop in §2.8 as a three-part for loop instead, and confirm the output is identical. Which version reads more clearly to you, and why?
  3. is_prime loops for i := 2; i*i <= n; i += 1. Change the condition to i < n instead (check every number below n, not just up to its square root) and confirm the output doesn't change. Now time both versions on n = 10_000_019 (a prime) using your shell's time command. What do you observe, and why does checking only up to i*i <= n still catch every possible factor?
  4. Write a fizzbuzz procedure: for n from 1 to 30, print "Fizz" if n is divisible by 3, "Buzz" if divisible by 5, "FizzBuzz" if both, and the number itself otherwise. Use a bare switch like describe's. (Solution sketch: order matters again, check "both" before either single case, or use case n % 15 == 0: first.)
  5. What happens if you remove the case: (default) arm from describe entirely, leaving only the three specific cases? (Solution sketch: try it: the compiler refuses to build at all, with "Missing return statement at the end of the procedure". describe promises to return a string no matter what, and without a default case the compiler can't prove every path does; it won't wait to find out at runtime whether some input falls through the gap.)

Further reading