In the previous chapter, I said that Odin lets you write useful data-oriented APIs without reaching for Class-based inheritance and polymorphism. Let’s explore what that means in this chapter.
If you come from a language like Java or C#, your instinct for “many types, one operation” is to define an interface or abstract class. You’d be disappointed to learn that Odin does not have interfaces. It has no methods, no classes, and no inheritance either. Suffice to say, Odin forces you towards data-oriented programming.1 The programming paradigm doesn’t change the problem, rather it affects how you reason about it, and your approach to solving it. Real programs still need one operation that works across many types, so how would you solve it in Odin?
Odin has a couple of language features that get’s you closer to achieving that goal, each explicit about when the decision of “which code runs” gets made:
- procedure groups — resolved at compile time, costing nothing at runtime.
- composition with
using— no dynamic dispatch, just shared data and behaviour. - unions and procedure pointers — resolved at runtime, but visible to the calling code.
By the end of this chapter, you’ll know how each one works and, more importantly, when to reach for each one.
A Small Example: Exporting a Report
Let’s start with a small, simple example to demonstrate how to make a single operation work across many types. Imagine you’re building a reporting software that can take database records and render it either as JSON or plain text to the terminal. Without class inheritance and an interface, how would you solve that?
Let’s start by defining the report’s data structure. Create a file named exporters.odin and paste in the following code:
package main
import "core:fmt"
import "core:strings"
Report :: struct {
title: string,
author: string,
pages: int,
}
Next, add a procedure that takes a report and outputs JSON string:
Json_Exporter :: struct {
pretty: bool,
}
export_json :: proc(e: Json_Exporter, r: Report) -> string {
if e.pretty {
return fmt.aprintf(
"{{\n \"title\": \"%s\",\n \"author\": \"%s\",\n \"pages\": %d\n}}",
r.title,
r.author,
r.pages,
)
}
return fmt.aprintf(
`{{"title": "%s", "author": "%s", "pages": %d}}`,
r.title,
r.author,
r.pages,
)
}
The export_json procedure formats the report, where the output format depends on if pretty is enabled. The fmt.aprintf procedure formats a string into freshly allocated memory (using the implicit context.allocator, from chapter 2), therefore, its result must be freed to avoid a memory leak. Since it returns that value, the memory cleanup would have to happen at the call site. Regarding the format specifiers, core:fmt reserves {{ and }} for literal open and close braces, %s for the uninterpreted bytes of the string or slice, and %d for base 10 integers.
Moving on to the plain text exporter, add a export_text procedure as follows:
Text_Exporter :: struct {
uppercase: bool,
}
export_text :: proc(e: Text_Exporter, r: Report) -> string {
if e.uppercase {
upper := strings.to_upper(r.title)
defer delete(upper)
return fmt.aprintf("%s by %s (%d pages)", upper, r.author, r.pages)
}
return fmt.aprintf("%s by %s (%d pages)", r.title, r.author, r.pages)
}
It behaves quite similarly to the JSON exporter, but this time with its own config structure, Text_Exporter. You should notice the code defer delete(upper), which frees the memory used for converting the title to uppercase. We do that because strings.to_upper() allocates memory for the value it generates, implicitly using context.allocator if you didn’t explicitly specify an allocator.
You have two export procedures but how can you define one procedure that compiles to either of them based on the export type?
You do that using a procedure group.
Introducing Procedure Group
Procedure grouping is a way to do procedure overloading, somewhat similar to method overloading in C# and some OOP languages. Unlike C# with implicit method overloading, procedure overloading has to be explicitly declared, which gives the following advantages:
- It is easy to tell what is being overloaded, and the scope the entity name belongs to.
- You can still refer to the specific procedures if needed.
- When more than one member could match, the most specific one wins. In essence, a concrete parameter type beats a polymorphic one.
- They can include parametric polymorphic procedures with where clauses.
For the report export, you add a export procedure group and use it as follows:
export :: proc { export_json, export_text }
main :: proc() {
report := Report{ title = "Systems Notes", author = "A. Lovelace", pages = 42 }
json_out := export(Json_Exporter{pretty = true}, report)
defer delete(json_out)
text_out := export(Text_Exporter{uppercase = true}, report)
defer delete(text_out)
fmt.println(json_out)
fmt.println(text_out)
}
This creates a single name, export, that stands for a fixed set of procedures. When you call export(Json_Exporter{pretty = true}, report), the compiler looks at the argument types, selects the matching member, and emits an ordinary call to export_json.2
You should see the following output when you run the file with odin run exporters.odin -file:
{
"title": "Systems Notes",
"author": "A. Lovelace",
"pages": 42
}
SYSTEMS NOTES by A. Lovelace (42 pages)
A limitation of this style of function/procedure overloading is that nobody can add a member from another package. If your callers need to plug in implementations later, a procedure group won’t do it. For that, keep reading.
Composition Over Inheritance with using
Taking the example further, both exporters will eventually want shared configuration, for example, whether to include the author’s name. The object-oriented answer is usually a base class that gets extended. We can apply the principle of composition over inheritance and use a language feature that allows us embed the shared data directly 3:
Exporter_Config :: struct {
include_author: bool,
}
Json_Exporter :: struct {
using config: Exporter_Config,
pretty: bool,
}
Text_Exporter :: struct {
using config: Exporter_Config,
uppercase: bool,
}
The using directive on a struct field promotes the embedded fields such that json_exporter.include_author reads as if the field lived directly in Json_Exporter. Although using exporter.config.include_author still works in that context, it is unnecessary.
It also creates a one-way subtype relationship (polymorphism). For example, a procedure written against Exporter_Config can accept a Json_Exporter and still work:
describe :: proc(c: Exporter_Config) -> string {
return fmt.aprintf("include_author=%v", c.include_author)
}
exporter := Json_Exporter{pretty = true, include_author = true}
describe(exporter) // works, no cast or wrapper type needed
This gives you an inheritance-like behaviour — shared fields, shared procedures, and is-a substitutability — without vtables, hidden base-class layout, or constructors possibly running in an order you didn’t intend. The memory layout is predictable, which means you can still reason about size, alignment, and serialisation. There are more things you can do with the using directive, so check the docs for the full range of what it has to offer.
When Dispatch Has to Wait Until Runtime
Procedure overloading choice is made at compile-time, but there are times when the choice cannot be known until runtime. There are two ways for this, and the difference between them is whether the set of implementations is closed or open.
A closed set via discriminated union
The first pattern is using tagged union in Odin. For example:
Any_Exporter :: union {
Json_Exporter,
Text_Exporter,
}
export_any :: proc(e: Any_Exporter, r: Report) -> string {
switch v in e {
case Json_Exporter:
return export_json(v, r)
case Text_Exporter:
return export_text(v, r)
}
unreachable()
}
Did you notice how the pieces compose? The runtime dispatch goes through the union, and inside each case, a plain call to the exact procedure (which could just as well be a procedure group call).
The switch statement4 decide the procedure to call, and because switches over unions are exhaustive, adding a third exporter to the union turns every unhandled switch into a compile error that points at the code you need to update.
An open set via function pointers
When the set of implementations can’t be known in advance, e.g. a package/API that can be reused for various kinds of application, you can use a technique where you store the behaviour in the struct. For example:
Exporter :: struct {
data: rawptr,
export_proc: proc(data: rawptr, r: Report) -> string,
}
Each implementation wires up its own procedure and data pointer, and callers invoke, for example, handle.export_proc(handle.data, report). This obscure-looking pattern is how Odin’s core:io models streaming values. A Stream in that package is a procedure value plus its raw data pointer.
Here’s a small piece of code from that package, just to give you an idea of how it is implemented:
Stream_Proc :: #type proc(stream_data: rawptr, mode: Stream_Mode, p: []byte, offset: i64, whence: Seek_From) -> (n: i64, err: Error)
Stream :: struct {
procedure: Stream_Proc,
data: rawptr,
}
Reader :: Stream
Writer :: Stream
read :: proc(s: Reader, p: []byte, n_read: ^int = nil) -> (n: int, err: Error) {
if s.procedure != nil {
n64: i64
n64, err = s.procedure(s.data, .Read, p, 0, nil)
n = int(n64)
if n_read != nil { n_read^ += n }
} else {
err = .Unsupported
}
return
}
You have to be careful when using this approach because the rawptr casts are yours to get right. The compiler can’t check them for you. So reach for it only when it is needed for the specific problem.
What We’ve Learned
Odin’s answer to “how do I write an API without classes and OOP patterns” turned out to be a choice over various language features, each with their benefits and tradeoffs. We looked at procedure groups, using directive, tagged union, and function pointers. A procedure group gives a name to one logical operation over several types, with zero runtime cost. using directive enabled struct composition over shared/common data and behaviour, giving you subtype polymorphism while keeping the memory layout predictable. Procedure pointers and union were used as options to enable runtime selection over which procedure to call.
How and when you use them depends on the problem at hand. This table overview could serve as a quick reference when you have to make that choice.
| When you need… | Reach for… |
|---|---|
| One name, with types known at the call site | a procedure group |
| Common fields/behaviour across your known types | using directive |
| Runtime dispatch over a fixed set of types | a union + switch |
| Implementation is left to the user/caller | procedure pointers |
In the next article/chapter in this series, we’re going to explore a few things about the language and tooling as we wrap up the series. Stay tuned!
Notes & References
Footnotes
-
Is Odin an object-oriented language? — it isn’t, but subtype polymorphism is possible through
usingdirective. ↩ -
A procedure group shines when the set of types is fixed and known at the call site, and having one name reads better than its variants. For example,
clone_fromincore:stringsis a procedure group overstring,[]byte,cstring, and a^byteplus length. That gives you one name for “clone from anything string-like”. ↩ -
OOP aside, you can reason about certain problems by breaking things down into divisible components that are composable. This way of reasoning about composition is useful irrespective of whatever paradigm or pattern you choose to apply, and it can be found in other disciplines like art ↩
-
A type switch is similar to a regular switch statement, except that the cases are types. In the case of a union, the only case types allowed are those of the union type. See the docs for more info about unions. ↩