Odin Study Projects

Three small programs that pull the track together: a pointer-driven linked list, a generic stack with tests, and a command-line word counter. Each is short enough to type out in one sitting, and each one combines two or three phases at once.

Type them rather than copying them. A three-hundred-line program you have written yourself teaches more than a three-thousand-line program you have read, because every line you type was a decision you had to make. When a project works, break it on purpose and put it back together.

Singly Linked List

The linked list is the classic exercise for pointers, and it stays classic because nothing about it is hidden: nodes are allocated one at a time, the chain exists only through next pointers, and releasing the structure means walking it in the right order. It also demonstrates the two-value result in the form that suits a list — "here is the value, and here is whether there was one".

Node, Allocator and Operations

Two types carry the whole design. A Node holds a value and points forward; a List holds the head pointer and a count. The count is a convenience, and everything else is derived by walking.

package main

import "core:fmt"

// A node owns its value and points at the next node. That pointer is the only
// link in the chain, which is what makes the structure growable.
Node :: struct {
	value: int,
	next:  ^Node,
}

// A list is the head pointer plus a count. With no nodes, head is nil — so
// "empty" and "points at nothing" are the same condition, and no separate flag
// is needed.
List :: struct {
	head:  ^Node,
	count: int,
}

// push adds a node at the front. The new node becomes the head, and its next
// points at whatever was the head before — two assignments, and no shifting.
push :: proc(list: ^List, value: int) {
	node := new(Node)			// one node, from the context allocator
	node.value = value
	node.next = list.head
	list.head = node
	list.count += 1
}

// pop removes the front node and hands back its value. The second result says
// whether there was anything to remove, which is how an empty list is handled
// rather than crashed into.
pop :: proc(list: ^List) -> (value: int, ok: bool) {
	if list.head == nil {
		return 0, false			// nothing to remove, and we say so
	}

	old := list.head			// remember the node before unlinking it
	list.head = old.next		// the list now starts at the second node
	list.count -= 1

	value = old.value
	free(old)					// the node is no longer reachable — release it
	return value, true
}

Read pop twice, because the order of its three lines is the whole of manual memory management. Save the node, unlink it, release it. Releasing before unlinking would leave the list pointing at freed memory, and that mistake is exactly what this exercise exists to make you feel.

Traversing and Freeing

Traversal and freeing have the same shape — follow the links — and both must respect the same rule: read what you need from the current node before moving on.

// walk visits every node and hands each value to a procedure. Passing the
// action in as a parameter keeps traversal and use separate: the same walk
// serves a printer, a sum, and a search.
walk :: proc(list: ^List, visit: proc(value: int)) {
	current := list.head
	for current != nil {		// nil is the end of the chain
		visit(current.value)
		current = current.next
	}
}

// destroy releases every node. The order is the important part: the link is
// saved BEFORE the node is freed, because after `free` the node is gone and
// its `next` field cannot be read any more.
destroy :: proc(list: ^List) {
	current := list.head
	for current != nil {
		next := current.next	// save the link first...
		free(current)			// ...then release the node
		current = next			// ...then continue from the saved link
	}
	list.head = nil				// the list is genuinely empty now
	list.count = 0
}

main :: proc() {
	list: List					// zero-valued: head is nil, count is 0
	defer destroy(&list)		// one release for the whole structure

	for value in []int{3, 7, 11} {
		push(&list, value)
	}
	fmt.println("count after pushes:", list.count)		// 3

	// Newest first, because push works at the front.
	walk(&list, proc(value: int) {
		fmt.println("value:", value)					// 11, 7, 3
	})

	// Popping hands back the value and the fact that it existed.
	top, ok := pop(&list)
	fmt.println("popped:", top, "ok:", ok)				// 11 true

	// Keep popping until the list is empty — the classic loop for this shape.
	for {
		value, more := pop(&list)
		if !more {
			break
		}
		fmt.println("draining:", value)
	}
	fmt.println("count after draining:", list.count)	// 0
}

Two things are worth noticing in main. The defer destroy is written on the line after the list is declared, so every exit path — including an early return you add tomorrow — releases the nodes. And the procedure literal passed to walk is an ordinary procedure, which is how a traversal stays reusable without a callback registry or an interface.

Where to take it next: give push a comparator and keep the list sorted, or make the node generic with Node :: struct($T: typeid) so the list can hold strings. Both changes are small, and both will make you think about ownership: whoever allocates a node is still the one who must free it.

Generic Stack With Tests

The second project is a container you can reuse, which means two things at once: the type parameter that makes it work for any element type, and the tests that make it trustworthy. Odin supports both without leaving the language — $T for the parameter, @(test) for the checks.

The Container

A stack is a dynamic array plus a rule about which end you use. Because a dynamic array owns its memory, every stack needs a matching release.

package stack

// The element type is a parameter, so the container does not care what it
// holds. One source definition, one compiled version per element type used.
Stack :: struct($T: typeid) {
	items: [dynamic]T,
}

// push puts an item on top. The address of the stack is needed because
// append may need to grow the backing array.
push :: proc(s: ^Stack($T), item: T) {
	append(&s.items, item)
}

// pop removes the top item. Taking from an empty stack is a normal outcome,
// not an error: the second result answers "was there anything?".
pop :: proc(s: ^Stack($T)) -> (item: T, ok: bool) {
	if len(s.items) == 0 {
		return item, false		// `item` is the zero value of T; ok says it is not real
	}
	// pop on the dynamic array removes and returns its last element.
	return pop(&s.items), true
}

// peek looks at the top item without removing it.
peek :: proc(s: ^Stack($T)) -> (item: T, ok: bool) {
	if len(s.items) == 0 {
		return item, false
	}
	return s.items[len(s.items)-1], true
}

// destroy releases the container's buffer. The caller allocated nothing
// directly, and the caller still has to release it — the same rule as any
// other owner.
destroy :: proc(s: ^Stack($T)) {
	delete(s.items)
}

Note the shape of the release rule here. Nobody called new or make in this file, and yet destroy exists and must be called — because append grew a buffer from the context allocator. Ownership is about who called the allocator, not about where the call appears.

Tests That Keep It Honest

The tests assert the three properties that make a stack a stack. They also demonstrate the detail that makes Odin's test runner more than a convenience: because memory is tracked, a test that forgets its destroy is reported even when every expectation held.

// A separate file in the SAME package. That is why the tests can call the
// container's procedures directly, and why no import of the stack is needed.
import "core:testing"

@(test)
stack_is_last_in_first_out :: proc(t: ^testing.T) {
	s: Stack(int)
	defer destroy(&s)		// the test allocated, the test releases

	push(&s, 1)
	push(&s, 2)
	push(&s, 3)

	// The most recent push must come back first — the defining property.
	top, ok := pop(&s)
	testing.expect(t, ok, "a stack with three items must not be empty")
	testing.expect_value(t, top, 3)
	testing.expect_value(t, len(s.items), 2)
}

@(test)
popping_an_empty_stack_is_reported :: proc(t: ^testing.T) {
	s: Stack(string)
	defer destroy(&s)

	// No pushes at all: an empty stack must answer, not crash.
	value, ok := pop(&s)

	testing.expect(t, !ok, "an empty stack must report that there was nothing")
	testing.expect_value(t, value, "")		// the zero value of string
}

@(test)
peek_does_not_remove :: proc(t: ^testing.T) {
	s: Stack(f64)
	defer destroy(&s)

	push(&s, 1.5)

	// Looking and taking are different operations, and a test should show it.
	_, found := peek(&s)
	testing.expect(t, found, "peek must find the item that was pushed")
	testing.expect_value(t, len(s.items), 1)	// still there after peeking

	_, taken := pop(&s)
	testing.expect(t, taken, "pop must find the same item")
	testing.expect_value(t, len(s.items), 0)	// and now it is gone
}

Run these with odin test . from the folder holding the package. The run reports passes, failures, and any memory that was acquired and never released — so the three defer destroy lines above are not decoration: without them, three passing tests would be reported as leaking.

Command-Line Word Count

The third project turns the language outwards: it takes its input from the command line and reports on it. It is the smallest program on this page that you could genuinely keep and use, and it puts os.args, a map and a sort to work in about thirty lines.

Reading os.args

A program receives its arguments in os.args, with the program's own path at index 0. That first entry is why an argument count is never simply what the user typed — you have to remember to skip past yourself.

package main

import "core:fmt"
import "core:os"
import "core:slice"

main :: proc() {
	// Index 0 is the program itself, so the words start at index 1.
	// Slicing with [1:] gives "everything from here on", which is exactly the
	// argument list and nothing else.
	words := os.args[1:]

	if len(words) == 0 {
		// A usage message belongs on standard error, not on standard output:
		// it is a complaint, and someone may be piping the real output onward.
		fmt.eprintln("usage: wordcount <word> [word...]")
		return
	}

	fmt.println("received", len(words), "word(s)")
}

Run it with arguments passed after the separator, so the compiler does not try to interpret them:

odin run wordcount.odin -file -- the quick brown fox the fox

Tallying and Reporting

The counting itself is the map idiom from Phase 3: an absent key reads as zero, so counts[word] += 1 handles the first sighting without a special case.

	// The tally. A map owns memory, so it is made and released in the same
	// breath — the habit from the allocators lesson.
	counts := make(map[string]int)
	defer delete(counts)

	for word in words {
		counts[word] += 1		// an absent key reads as zero, so this just works
	}

	// A map yields its entries in no particular order, so the keys are gathered
	// and sorted before anything is printed. Predictable output is worth three
	// extra lines.
	keys := make([dynamic]string, 0, len(counts))
	defer delete(keys)

	for word, _ in counts {
		append(&keys, word)
	}
	slice.sort(keys[:])

	// The report is one line per word, in the sorted order gathered above.
	// printfln takes a format string and its values, which keeps the layout in
	// one place instead of spread across separate write calls.
	for word in keys {
		fmt.printfln("%v : %v", word, counts[word])
	}

	// The summary line answers the question the tool exists for.
	fmt.println("total words:", len(words), "distinct words:", len(keys))
}

Three habits from this project are worth carrying into everything you write from now on. The release is written next to the acquisition (defer delete under make). The error message goes to standard error, so a pipeline stays clean. And the output is sorted even though the map has no order, because a tool that prints the same input in different orders on different runs is a tool nobody can test.

Where to take it next: reading a file instead of arguments is the natural next step, and it is where the platform-facing side of core:os comes in. Once your program reads text from somewhere other than its command line, the tallying code above does not change at all — which is the point of keeping the counting and the input in separate places.

After the Three Projects

You now have a container you wrote yourself, a structure built from raw pointers and freed by hand, and a small tool that talks to its caller. Between them they exercise every phase of this track: values and control flow, data shapes, manual memory, generic reuse, and the standard packages that do the boring work.

From here the useful move is not a fourth exercise but a real program — something you actually want. A program you have a reason to finish will teach you the standard library far faster than any list of features can, and the habits are already in place: name the allocator, release what you acquire, state your expectations in a test, and prefer the shape that makes the failure visible.