I think Odin is terrifically designed overall. There are design choices that I was initially very skeptical about but when I decided to use the language they actually made a lot of sense.
Some overall differences between Odin and Zig and how I relate to them are:
## Exhaustive field setting
Zig requires you to set every field in a struct. Everything everywhere has to be initialized to something, even if it's `undefined` (which means it's the same thing as missing initialization in C, etc.).
Odin instead takes the position that everything is zero-initialized by default. It then also takes the position that everything has a zero value and that zero value is a valid value to have. The libraries think so, the users of the language think so and what you get when everything works like this is code that overwhelmingly talks exactly only about the things that need talking about, i.e. we only set the fields right now that matter.
Exhaustive field setting does remove uncertainty and I was very skeptical about just leaving things to zero-initialization, but overall I would say I prefer it. It really does work out most of the time and I've had plenty of bugs in Zig caused by setting something to `undefined` and just moving on, so it's not really as if that exhaustiveness check for fields was some 100% solution.
## An implicit context vs. explicit parameters
Zig is more or less known for using parameters to pass around allocators, and so on. It's not a new concept in most lower-level languages but it's one of the first languages to be known for baking this into the core community and libraries.
Odin does the same thing except it uses an implicit parameter in all Odin functions that is called `context`. When you want to change the allocator for a scope, you only need to set `context.allocator` (or `context.temp_allocator`) and every function you call in that scope will use that allocator. We can also write functions that take an optional parameter that defaults to the current allocator:
join :: proc(
a: []string,
sep: string,
allocator := context.allocator,
) -> (
res: string,
err: mem.Allocator_Error,
) {
}
This way we get the same behavior and flexibility of talking about allocators but we can also default to either the basic default or whatever the user currently has in scope. We *can* also be more explicit if we want. The ability to have this implicit again makes it so we only need to talk about the things that are special in the code.
The context is also used for logger information, by the way, and you also have a `user_data` field that can be used to hold other stuff but I haven't really needed it for anything so far.
## Error information
Zig is known for its error unions, i.e. a set of error codes that can be inferred and returned from a function based on the functions it calls. These are nice and undoubtedly error handling in Zig is very neat because of it. However, there is a major downside: You can't actually attach a payload to these errors, so these are just tags that you propagate upwards. Not a huge deal but it is annoying; you'll have to have a parameter that you fill in when an error has occurred and you need more info:
// `expect_error` here is only filled in with something if we have an error
pub fn expect(self: *Self, expected_token: TokenTag, expect_error: *ExpectError) !Token {
}
Odin instead has a system that works much the same, we can return early by using what is effectively the same as `try` in Zig: `or_return`. This will check the last value in the return type of the called function to see if it's an error value and return that error value if it is.
The error values that we talk about in Odin are just its normal values and can be tagged unions if we so choose. If we have the following Zig definitions for the `ExpectError` example:
pub const ExpectTokenError = struct {
expectation: TokenTag,
got: Token,
location: utilities.Location,
};
pub const ExpectOneOfError = struct {
expectations: []const TokenTag,
got: Token,
location: utilities.Location,
};
pub const ExpectError = union(enum) {
token: ExpectTokenError,
one_of: ExpectOneOfError,
};
We could represent them as follows and just use them as the error return value without holding a slot as a parameter that we will fill in:
ExpectTokenError :: struct {
expectation: TokenTag,
got: Token,
location: Location,
}
ExpectOneOfError :: struct {
expectations: []TokenTag,
got: Token,
location: Location,
}
ExpectError :: union {
ExpectTokenError,
ExpectOneOfError,
}
expect_token :: proc(iterator: ^TokenIterator, expected_token: TokenTag) ->
(token: Token, error: ExpectError) {
}
They are normal tagged unions and in contrast to Haskell/Rust unions we don't have to bother with having different constructors for these just because a type is part of several different unions either, which is a big plus. We still get exhaustiveness checks, something akin to pattern matching with `switch tag in value { ... }`[0] and so on. Checking for a certain type in a union also is consistent across every union that contains that type, which is actually surprisingly impactful in terms of design, IMO.
## Vectors are special
This isn't going to have a Zig equivalent because, well, Zig just doesn't.
Odin has certain names for the first, second, third, etc., positions in arrays. This is because it's specifically tailored to programmers that might deal with vectors. It also has vector and matrix arithmetic built in (yes, there is a matrix type).
array_long := [?]f32{1, 2, 3, 4, 5}
array_short := [?]f32{0, 1, 2}
fmt.printf("xyzw: %v\n", array_long.xyzw)
fmt.printf("rgba: %v\n", array_long.rgba)
fmt.printf("xyz * 2: %v\n", array_long.xyz * 2)
fmt.printf("zyx + 1: %v\n", array_long.zyx + 1)
fmt.printf("zyx + short_array: %v\n", array_long.zyx + array_short)
This gives the following output:
> odin run swizzling
xyzw: [1.000, 2.000, 3.000, 4.000]
rgba: [1.000, 2.000, 3.000, 4.000]
xyz * 2: [2.000, 4.000, 6.000]
zyx + 1: [4.000, 3.000, 2.000]
zyx + short_array: [3.000, 3.000, 3.000]
It seems like a small thing but I would say that this has actually made a fair amount of my code easier to understand and write because I get to at least signal what things are.
## Bad experiences
### Debug info
The debug information is not always complete. I found a hole in it last week. It's been patched now, which is nice, but it was basically a missing piece of debug info that would've made it so that you couldn't know whether you had a bug or the debug info was just not there. That makes it so you can't trust the debugger. I would say overall, though, that the debug info situation seems much improved in comparison to 2022 when apparently it was much less complete (DWARF info, that is, PDB info seems to have been much better overall, historically).
0 - https://odin-lang.org/docs/overview/#type-switch-statement