Small joys of programming in Odin

Update (2023-07-21): I was being a bit overly brief with liberal use of ... to mean “unimportant” in the code examples. Ginger Bill (creator of Odin) noted it might be clearer to have more correct/explicit Odin and Zig code for the examples given people would be less familiar. Those has been updated

For the past few months I’ve been using the programming language Odin in my spare time regularly. I’ve been off and on with it for a year or so but after this past year’s Advent of Code I decided to stick with it. Briefly, Odin is a C-like language that is data-oriented and pragmatic. Or as the website says “The Data-Oriented Language for Sane Software Development”. In my mind, the reason I’ve enjoyed it so much is: it’s the systems programming language with the fewest “wtf"s I’ve experienced so far1. In fact one of Odin’s guiding principles is “The Joy of Programming” and after getting the basics down, I’ve found Odin to be pretty intuitive.

On that note, this post is a showcase of a couple features that brought me small moments of joy while using the language. I’m not going to do a full overview of Odin – if you want that the overview, demo.odin, and package docs have you covered – instead I’m going to explain some small pain points I’ve had with other languages and then explain how Odin removed those minor pains.

While there isn’t anything super technical in here, I’m probably assuming some knowledge and I do swap between three different languages to show examples (Odin, C++, and Zig). A general programming background might be helpful to see the structure over the details.


Overview

Odin has a number of built-in quality of life features that enhances day to day coding: bit_sets for flags (which work when interop-ing with C!), or array programming which makes anything graphics related very nice. The language doesn’t have everything – the big example being there’s no compile-time execution of functions – but the features it does have are well integrated. Below are just two smaller features that led to moments where I thought “that’s fantastic.”

#caller_location

Odin has the compiler directive #caller_location which, as the name suggests, creates a struct containing the current file name, procedure name, line, and column of the caller of a procedure (proc). The type is Source_Code_Location and, as with all of Odin’s built-in types, you can find its definition in the runtime package. #caller_location isn’t just an afterthought: Odin’s core collection (what Odin calls its standard library) uses it in most allocations, asserts, or logging. It’s fairly well integrated.

For your own procs, you can capture caller information by assigning #caller_location to an argument like so:

// odin calls functions procedures
some_procedure :: proc(x, y: int, loc := #caller_location) {}

You can then pass the location information on to downstream proc calls if you want.

For a while the directive sat resoundly in my “…neat” category. I could see how maybe it would be nice to have when you want it. But also I figured it’s essentially just __LINE__ and the like from C… so I promptly ignored it.

Until I was writing some tests2 while following along with Writing an Interpreter in Go

Small annoyances when testing in C++3

When writing tests in C++ (and other languages), often times you want to verify the state of some object multiple times in a test. Perhaps the type has many values or you’re trying to assert some invariant between the members. Maybe you just want your test framework to tell you which member had the incorrect value rather than the struct as a whole. When the combinations of values to check start getting large you might write a helper check function that asserts the validity. The following is a stripped down example4 (i.e. don’t expect it to compile):

// C++
struct Quotes {
    int32_t bid_price;
    int32_t bid_size;
    int32_t ask_price;
    int32_t ask_size;
};

void assert_quotes_safe(const Quotes &quote, const Safeties &safeties) {
    if (quote.bid_price < 0 && quote.ask_price < 0) {
        // requires writing an `operator<<` overload for Quotes
        // this is apparently how you send error strings to the test framework for gtest
        EXPECT_TRUE(false) << "Quotes had negative prices: " << quote;
        ASSERT_TRUE(false); // exits the test case immediately
    }
    
    EXPECT_TRUE(quote.bid_price <= quote.ask_price);
    EXPECT_TRUE(quote.bid_price * quote.bid_size <= safeties.max_dollars);
    // more checks
}

TEST(TestSomeQuoteGeneration) {
    // setup
    assert_quotes_safe(current_quotes, safeties);
    // trigger a quote gen
    assert_quotes_safe(current_quotes, safeties);
    // do some follow up that may or may not gen new quote
    assert_quotes_safe(current_quotes, safeties);
}

When TestSomeQuoteGeneration fails, the test framework will print out something similar to:

TestSomeQuoteGeneration - test.cpp:13:assert_quotes_safe Quotes had negative prices: bid_price = ....

That is it prints the line where the EXPECT() failed. The issue is every test now fails in the assert_quotes_safe function and you need to go figure out where exactly the failure came from. With C++ you can usually run the test in gdb and then just step up one frame when the test fails. Unless you’ve been compiling with undefined behavior sanitizer (ubsan) in which case your debug information is useless and you have to recompile the entire project (thankfully there’s ccache).

There are definitely solutions to this problem. You could have assert_quotes_safe return a bool or error enum and then have an additional ASSERT_TRUE(assert_quotes_safe(...)) in the calling code. Again we aren’t discussing a massive issue here. It’s a small pain point you have to do a little more work for, plan for ahead of time, and/or add something at all locations you call assert_quotes_safe.

A small joy when testing in Odin

Odin, like many modern languages, has built-in testing support. To add a test you use the attribute @(test) on any procedure. To make code organization slightly easier, Odin detects when a filename ends with _test.odin and will drop those files during a normal build. Odin also has a core package called testing which contains a number of useful utilities.

If you’re familiar with Go, the code will look very familiar.

// Odin
package some_financial_thing

import "core:testing"

Quotes :: struct {
    bid_price, bid_size, ask_price, ask_size: i32,
}

// this code is even nicer after https://github.com/odin-lang/Odin/pull/2597
assert_quotes_safe :: proc(t: ^testing.T, quotes: Quotes, safeties: Safeties, loc := #caller_location) {
    if quotes.ask_price < 0 && quotes.bid_price < 0 {
        testing.errorf(t, "quotes had negative prices: %#v", quotes, loc = loc)
        testing.fail_now(t, loc = loc) // returns from test immediately
    }
    
    testing.expect(t, quotes.bid_price <= quotes.ask_price, loc = loc)
    testing.expect(t, quotes.bid_price * quotes.bid_size <= safeties.max_dollars, loc = loc)
    // more tests
}

@(test)
test_some_quote_genration :: proc(t: ^testing.T) {
    // setup
    assert_quotes_safe(current_quotes, safeties);
    // send some trigger
    assert_quotes_safe(current_quotes, safeties);
    // do some follow up that may or may not gen new quote
    assert_quotes_safe(current_quotes, safeties);
}

The procedures in the testing package such as expect_value or errorf all have a loc := #caller_location argument. By passing the location information we captured, we’ll get a more useful error message on failures such as:

test.odin:(27:5) - quotes had negative prices: Quotes{bid_price = ...}

The line number is where we actually call assert_quotes_safe! So now we can just set our breakpoint before the second call to assert_quotes_safe rather than spending time figuring out which one it was.

Again, this isn’t massive. But given the amount I’ve had to trace back tests before it’s very welcome.

@(disabled = <condition>)

I’ve used a few C / C++ replacement languages that don’t have macros. I’m always pretty happy with the result. Even in C++ I try to avoid writing macros unless I actually have to (constexpr all the things!). Zig was the first C replacement I tried and I immediately loved its comptime (compile time evaluation/execution). It was essentially everything I wanted from C++ and then some but in a simpler form5. The fact Zig can create an SoA type in userspace with comptime is amazing. If I were actively using Zig at work I’d be very happy6.

But there are certain things I’m used to from C++ that are a pain in most of these languages that don’t have macros. The immediate example that never really has a good solution is logging.

A brief look at logging

Just a brief talk on why logging is a prime example - logging is expensive. Even fast logging frameworks still require time to copy data onto some queue so another thread can do the formatting. On the other hand, having debug logs in your code is fantastic for debugging (shocker). To balance that, you want some system to enable or disable debug logging at build time. In C++ you’d do this with macros. Note I’m leaving a lot of the code as ... for brevity and because it’s irrelevant to the point.

enum class LogLevel {
    Debug,
    Info
};

void log_something(LogLevel level, ...);

#ifndef LOG_LEVEL_DEBUG
#define LOG_DEBUG(...)
#else
#define LOG_DEBUG(...) log_something(LogLevel::Debug, ...);
#endif

#define LOG_INFO(...) log_something(LogLevel::Info, ...);

void some_function() {
    // do things
    foo();
    // log some data
    LOG_DEBUG("some sort of verbose information: %s", expensive_to_string(my_data_type));
}

If you’re not familiar, the above LOG_DEBUG macro is essentially “If LOG_LEVEL_DEBUG is defined, replace LOG_DEBUG with a call to log_something and forward all arguments. If it’s not defined replace the whole thing with an empty string including the arguments.” It’s a pretty simple macro to use, you just call it like a function.

Zig doesn’t have a macro system though, so you might define your logging in a similar way using comptime7:

// probably use @import("root") in actual code
const LOG_LEVEL = LogLevel.debug;

const LogLevel = enum {
    debug,
    info,
};

pub fn logSomething(comptime level: LogLevel, comptime format: []const u8, args: anytype) {
    if level >= LOG_LEVEL {
        actuallyLogTheThing(level, format, args);
    }
}

pub fn logDebug(comptime format: []const u8, args: anytype) {
    logSomething(LogLevel.debug, format, anytype);
}

fn someFunction() {
    // do things
    foo();
    // log some data
    logDebug("some sort of verbose information {s}", .{expensiveToString(my_data_type)});
}

Zig’s comptime is fantastic and the above is very similar to the C++. At compile time if you’ve defined LOG_LEVEL to be info then logDebug becomes a noop. No copies to a queue, no formatting. It seems essentially the same as the C++.

A minor annoyance with macroless languages

It’s great that logDebug is a noop. It sounds like exactly what we want. However, what happens to the arguments you pass that noop? As the name implies expensiveToString is costly. In the C++ version the call is removed by the preprocessor, but what about the Zig version? Maybe the compiler notices they’re unused and optimizes them out in a release build. But what if your expensive used-for-debug-logging-only function has side effects like incrementing some debug metrics? What if the function is from a C library so it can’t be inlined at all? In a lot of code few extraneous calls might not be the end of the world as long as the log itself gets dropped downstream (what’s a few hundred nanos or a mic or two between friends?8), but sometimes it’s unnacceptable.

The solution in Zig is to wrap everything in a comptime if:

fn someFunction() {
    // do things
    foo();
    // log some data
    if (log_level <= LogLevel.debug) {
        const s = expensiveToString(my_data_type);
        logDebug("some verbose information: %s", .{s})    
    }
}

Which is fine, but you need to litter your code with the if log_level <= LogLevel.debug. You also need to know at every call site either the value the logger uses to enable/disable debug logging, or you need to define your own aliases e.g. LOG_LEVEL_DEBUG and use those. For production level code this is, again, a minor annoyance at worst. But it’s just annoying enough that I don’t really enjoy it either for my personal projects (which are just for fun anyway).

A small joy with an Odin attribute

At first I thought I’d need to do the same thing in Odin. It does have when which does conditional compilation. I was somewhat resigned to wraping all debug logging in a when LOG_LEVEL <= .Debug {} and calling it a day. However, one of the things that I like about Odin is it’s very pragmatic. It doesn’t necessarily try to solve every problem with the same hammer.

I figured I’d post my annoyance in #beginners on the Odin discord in case someone had a good solution, and that’s when I was told about @disabled. @disabled is an attribute that removes all uses of a procedure when true. It doesn’t just turn the proc into a noop, it specifically removes the usage at call sites including any arguments passed to the procedure. The end result is similar to C++’s string replacement macros without the macros. The above example can be solved in Odin with e.g.:

Log_Level :: enum {
    Debug,
    Info,
}

LOG_LEVEL :: Log_Level.Debug

log_something :: proc(level: Log_Level, fmt: string, args: ..any, loc := #caller_location) {}

@(disabled = LOG_LEVEL > .Debug)
log_debug :: proc(fmt: string, args: ..any, loc := #caller_location) { // and a nice #caller_location
    log_something(.Debug, fmt, ..args, loc = loc)
}

some_function :: proc() {
    // do things
    foo()
    // log some data
    log_debug("some verbose information: %s", expensive_to_string(my_data_type))
}

In the above, any invocation of log_debug will be stripped out if LOG_LEVEL is .Info. That includes any arguments passed to the proc. That means any expensive or non-inlineable procedures (like expensive_to_string) also won’t be called. It doesn’t rely on -o:speed optimizations to figure out the call is unused. A debug build with log_debug disabled will have the same behavior. In addition, user code just has to call log_debug and not worry about how the logging procs determine if they are enabled or not.

This example in particular was maybe a little more than just a small joy. It was an exact solution to what I wanted. Even without expensive_to_string, it’s nice to just log without having to think about it. The logging procs I write will handle it properly. There are most likely other cases where you still need a when like the Zig solution but it’s at least an 80/20 solution that works well.

Wrapping up

To hammer the point again, nothing above is a game changer in programming. They’re just small, quality of life bonuses in Odin that sparked a tiny amount of joy for me personally.

If you are interested in Odin, I recommend reading over the main website to get a better idea if it’s something for you. If you decide it’s interesting then you should probably also join the discord. There are dozens of us!


  1. Before anyone jumps on that, I don’t consider different syntax a “wtf”, it’s just part of learning a new language. The fact pointers are ^ not *, procs are declared name :: proc() {}, or types are on the right not the left is not a “wtf” to me. ↩︎

  2. I’m not making the disctinction between unit tests, integration tests, etc. because it’s not relevant to the point I’m making. Also I will almost certainly get the definition wrong and if anyone ends up reading this then it’s a certainty that someone would come correct me. See Cunningham’s Law ↩︎

  3. Caveat: In C++20 there is std::source_location::current() which would allow for similar behavior. But at the time of writing it’s not exactly integrated into testing frameworks or the STL. Also I use C++17 in my day to day. ↩︎

  4. It’s normal to represent prices as a fixed point integer in financial systems. Also yes there are terms like notional I could have used but I’m expecting anyone reading this to be experienced in programming not financial jargon ↩︎

  5. I first found Zig in ~2019/2020 and I may have been working on a particulary heavy template metaprogramming codebase in C++. Zig was a breath of fresh air at the time ↩︎

  6. I think Zig is great by the way. I’m just pointing out something that mildly annoys me ↩︎

  7. Note in reality you’d use std.log which already does this but again I’m just highlighting how it works ↩︎

  8. I always love Admiral Grace Hopper Explains the Nanoseconds ↩︎