Why I like Odin
As someone who is interested in systems programming, and has experience working with Go, but not with C or C++,1 The Odin language is a great fit.
Proper systems language
Odin is a systems language that can viably be used to create programs that you would otherwise create in C or C++.
There’s no garbage collection. There’s no “runtime” doing things behind your back in the background.
This is in contrast to say, Go, which at first tried to claim to be a systems language, even though it has a garbage collector and a background runtime for pre-emptively managing goroutines.
In Odin, like in C, what you code is what you get.
You get to control memory layout.
Local variables are allocated on the stack.
Custom allocators are not only supported but expected, and the core library provides several useful allocators out of the box.
There is even some SIMD support at the language level!
There are no “exceptions”.
There’s no pretense of “memory safety”. The language assumes that you are in charge of memory. The compiler does not force you to check for nulls, prevent you from casting pointers, or otherwise impose restrictions on how you handle data in memory.2
Language-defined compilation units
In C and C++, the language does not define how source files relate to each other. If you just write code in two separate source files, the compiler has no idea how to combine the code into a program. This is the reason why header files are needed. It’s the reason why a build system is required.
This is the thing that makes writing substantial amount of C code not enjoyable for me. It makes integrating libraries really difficult and complicated.
In Odin, the basic compilation unit is a package. A package is just a directory with source files. All source files within a package (i.e. in the same directory) automatically know about each other.
To compile a program, you just point the odin compiler to a directory corresponding to a package that has a main
procedure.
If this package imports other packages, they will be automatically included in the build.
No build system needed. No header files needed. The compiler knows how to put together all the source files into a final program.
Data and procedures
The programming model that Odin assumes is data and procedures.
Define your data types. Define your procedures. That’s it. That’s your program.
No macros. No “interfaces”. No “objects”.
If you need dynamic dispatch, define your data model such that it has function pointers.
If you need generic containers, you have parametric polymorphism.
If you want more complex meta-programming, you have to resort to code generation; just like with Go.
This is not to say there are no visibility controls in the language: there are. You can make some things private: to the file, or the package.
Batteries included
The standard library is quite extensive. It not only covers the basics like pretty printing to stdout and operating with files, but even has things like an LRU cache and a PNG image loader!
The language has builtin support for dynamic arrays and hashmaps - thanks to the language being aware of “allocators”.
A few high quality external libraries are hand picked and distributed with the compiler as vendored packages.
Sensible design choices
Nice and consistent syntax
Conditional compilation (without C style macros)
Everything is utf8
Everything is zero initialized (but no default values for struct fields)
Parametric polymorphism (generics)
Tagged unions
Defer and auto defer
Runtime Type Information (plain data; nothing hidden)
Type inference (more extensive than you might think)
Multiple return values (but no tuples)
Procedure parameters:
Are immutable (aka const ref)
Support default values
Can be passed by name (at call site)
Procedure definitions can be nested (but no closures)
Interop with C and Objective-C
Native macOS applications be written in pure Odin.
Let’s expand on a few of them a little bit
Nice, sane, and consistent syntax
This might not seem like a big deal, but having a nice syntax is a big factor in whether the language is enjoyable or not. This is especially true when it comes to complex types, like function pointers.
In c, function pointers look sort of like this:
void (*fn)(int)
The equivalent in Odin looks like this:
fn: proc(p: int)
Which is just like how you normally define procedures:
my_proc :: proc(p: int) {
// body
}
Defer, and auto defer
Like Go, Odin has a defer
statement. Unlike Go, the defer in Odin works at the scope level, and the entire expression is deferred (including the evaluation of parameters passed to procedures)
So you can always open a small scope within a procedure and put a defer at the top.
myproc :: proc(....) {
// ... lots of code ...
{
start := time.tick_now()
defer fmt.println("Time passed:", time.tick_since(start))
// .. something you want to measure ..
}
// .. more code ..
}
What’s more, you can define a procedure that is automatically called when calling another procedure. Using one of the deferred_xxxx
attributes, you can specify a procedure to be automatically deferred, and specify how parameters are passed to it.
@(deferred_in_out=print_duration)
scoped_measure_duration :: proc(label: string) -> time.Tick {
return time.tick_now()
}
print_duration :: proc(label: string, start: time.Tick) {
duration := time.tick_since(start)
fmt.println(label, duration)
}
Now you can just do this in any scope:
{
scoped_measure_duration("reading file: ")
os.read_entire_file("something.txt")
}
Tagged unions
This is a big one - for me anyway. It’s something I really wish Go had support for.
Example: suppose you want to represent color using 4 different ways: ‘rgb’, ‘rgba’, ‘hsl’, and ‘hsla’. They are wildly different but you know how to handle all of them correctly.
RGB :: struct {
r, g, b: u8
}
RGBA :: struct {
r, g, b, a: u8,
}
HSL :: struct {
h, s, l: f32
}
HSLA :: struct {
h, s, l, a: f32
}
Color :: union {
RGB,
RGBA,
HSL,
HSLA,
}
You can define a color variable and set it to one of these:
c1, c2, c3, c4: Color
c1 = RGB{50, 50, 50}
c2 = RGBA{30, 30, 30, 200}
c3 = HSL{0.5, 0.3, 0.9}
c4 = HSLA{0.5, 0.3, 0.9, 0.2}
You can then “query” the underlying type to do something with it.
Let’s say we want to just figure out the “alpha” value for a color as a float from 0 to 1.
get_opacity :: proc(color: Color) -> f32 {
switch v in color {
case RGB, HSL:
return 1
case HSLA:
return v.a
case RGBA:
return cast(f32)v.a / 255
case: // nil
return 0
}
}
Now we can call this procedure:
fmt.println("c1 opacity:", get_opacity(c1))
fmt.println("c2 opacity:", get_opacity(c2))
fmt.println("c3 opacity:", get_opacity(c3))
fmt.println("c4 opacity:", get_opacity(c4))
And the output:
c1 opacity: 1.000
c2 opacity: 0.784
c3 opacity: 1.000
c4 opacity: 0.200
One thing to note about the switch v in color
construct is that within the case body, v
is immutable (I believe the C++ terminology in const reference), so you can’t change anything about. But, if you switch v in &color
then ` you can. In general, you can also do a switch statement on a pointer and it will work the same way.
Let’s see a procedure to set the opacity:
set_opacity :: proc(color: ^Color, alpha: f32) {
#partial switch v in color {
case RGBA:
v.a = cast(u8) (alpha * 255)
case HSLA:
v.a = alpha
}
}
Note how the case labels are the type names, just like in the union definition. They do not change to case ^RGBA
just because we’re switching type on a pointer variable.
Introspection / Type Information
This is also a big one. You get runtime type information in the form of data.
The runtime type information is just a bunch of structs that describe the types.
Unlike other languages, there’s nothing “hidden” from you behind opaque objects and abstractions. It’s all there laid out in the open.
Introspection is how the standard library print
procedures are able to “pretty print” any value you pass to it. It’s also how the json module knows how to “jsonify” anything you pass to it (as long as it is jsonifyable in principle).
Basically any serialization/deserialization scheme can be implemented using this system.
Type inference
Many modern languages provide type inference in the sense of allowing to declare variables and assign them at the same time without having to be explicit about the type of the variable.
Type inference in Odin goes beyond that.
Enum members don’t require the full enum type before the enum value.
Struct literals don’t require the struct name if the context makes it unambiguous.
Let’s say you have the following enum:
Direction :: enum {
Unknown,
North,
East,
South,
West,
}
We don’t have to say Direction.North
all the time. Most of the time, just .North
will be enough, because the context is clear.
As a simple example:
d: Direction
d = .North
But this even works with procedure parameters.
Let’s say you have a procedure which takes a Direction
:
opposite_direction :: proc(d: Direction) -> Direction {
....
}
You can call it like this:
fmt.println("North <=>", opposite_direction(.North))
And the body of the procedure itself benefits from this feature too:
opposite_direction :: proc(d: Direction) -> Direction {
switch d {
case .North:
return .South
case .South:
return .North
case .East:
return .West
case .West:
return .East
case .Unknown:
return .Unknown
}
return .Unknown
}
This same rule applies to structs as well.
The normal way to assign a struct using a literal is like this:
rgb_color = RGB{ 100, 150, 200 }
But if the variable already has the type assigned, it can be dropped from the struct literal:
rgb_color: RGB;
rgb_color = { 100, 150, 200 }
Again this applies to procedure parameters:
Given this procedure:
set_color :: proc(r: RGB) {
fmt.println("color set to:", r)
}
It can be called like this:
set_color({100, 200, 150})
A word about memory management phobia
If you are interested in delving into systems programming but dread having to work with C or C++, then Odin might be just the right language for you, and I recomend you check it out.
For me, the reasons I “dread” working with C are centered around the complicated compilation model, the awkward syntax, the lack of support for slices, the lack of zero initialization, and the like.
If your “dread” of C comes from fear of memory management, then Odin is probably not for you, and dare I say, maybe systems programming is not for you.
That said, the vast majority of memory corruption in C systems very likely stem from the lack of a proper slice type. It’s common for C programs to pass around pointers and lengths as two separate parameters. Combined with the assumptions that strings are null terminated, creates the conditions where it’s easy to make the mistake of reading beyond the end of buffers.
This problem is pretty much solved in Odin. Most of the time when you deal with buffers, you are not dealing with raw pointers: you are either dealing with a slice, or a dynamic array, where not only the length and capacity are explicitly specified, but even the allocator is specified.
I actually started learning to program with C and C++. I learned all the basics of programming from reading the Half-Life gameplay code that was shipped with the Half-Life SDK.
That said, the last time I seriously used C++ in project was around 2008 during a computer graphics course.
It does have a