Video summary
Bu video, Odin programlama dilinin temel veri tiplerine odaklanan bir giriş niteliğindedir ve izleyicilerin C, Go, Rust veya Zig gibi benzer dillerle olan temel bilgilerini ön koşul olarak kabul ederken, dilin daha karmaşık detaylarını resmi kaynaklara yönlendirerek konuyu başlatır. İlk bölümde sayısal, mantıksal (boolean) ve metinsel veri tipleri ile işaretçiler incelenirken, ikinci ve üçüncü bölümlerde sıralar, dilimeler, haritalar, birleşimler, sayımsallar ve polimorfizm gibi konulara geçileceği belirtilir. Odin'in çöp toplanma mekanizması olmayan bir dil olduğu vurgulanarak, bellek yönetiminin programcının sorumluluğunda olduğunu ve bu bağlamda statik olarak tahsis edilen kod alanındaki veri ile runtime'da dinamik olarak tahsis edilen veriler arasındaki farklar açıklanır. Ayrıca, tüm veri tiplerinin sıfır değerine sahip olması gerektiği ve Odin'in açık döküm (explicit casting) konusunda katı kuralları uyguladığı, özellikle küçük sayı türlerinin büyük türlere atanmasında bile bunun zorunlu kıldığı belirtilir.
Metin işleme ve bellek yönetimi açısından Odin'de string tipleri, UTF-8 veya UTF-16 kodlamalı veri tutarken, C ile uyumluluk için null bit işaretli alternatif tipler de sunulur. Özellikle pointer kavramı detaylı bir şekilde ele alınarak, dilin C'deki yıldız işareti yerine kök (carrot) sembolünü tercih etmesinin, dizi indeksleme ve referans alma işlemlerinde okunabilirliği artırdığı anlatılır. Odin'de pointer aritmetiği doğrudan yapılamaz ancak unsigned integer pointer üzerinden dönüştürülerek gerçekleştirilebilirken, bu işlemlerin güvenli olmadığını ve sadece tahsis edilmiş bellek bölgeleri üzerinde kullanılması gerektiği uyarısı yapılır. Ayrıca, dinamik bellek yönetimi için kullanılan allocatörler (bellek tahsis ediciler) ve bunların nasıl kullanılacağı, özellikle geçici tahsisler ve grup halinde serbest bırakma gibi senaryolar üzerinden örneklerle açıklanır.
Veri yapıları bölümünde dizi, dilimeler, dinamik diziler ve haritaların özellikleri karşılaştırılır; Odin'de dizilerin sabit boyutlu olması ve değer olarak kopyalanırken, dilimelerin alt aralıkları temsil etmesi ve dinamik dizilerin ise kapasite yönetimi sayesinde büyüme imkanı sunması vurgulanır. Birleşim (union) tipleri ve sayımsalların hata yönetimi nasıl kullanıldığı detaylı bir şekilde anlatılır; Odin'de istisna mekanizmasının bulunmaması nedeniyle hataların sıfır olmayan değerler, boolean'lar veya sayımsallar ile temsil edildiği ve bunların son döndürülen değer olarak geri verildiği açıklanır. Hata yönetimi için geliştirilmiş `or return`, `or break` ve `or else` gibi özel operatörlerin, kodun daha temiz ve hatayı ihmal etmeyi önleyici bir şekilde yazılmasını sağladığı örneklerle gösterilir. Sonuç olarak, Odin'in güvenli bellek yönetimi, açık döküm kuralları ve esnek veri yapıları ile modern sistem programlama dillerinde sağlam bir temel sunduğu özetlenir.
Read the full video transcript
This video is an intro to Odin that
tackles the language primarily from the
angle of data types. In the first part,
we'll cover the most basic number,
boolean, and text types plus pointers.
In part two, we'll cover arrays, slices,
maps, and strrus. In part three, we'll
cover enums and unions, including how
they are used as error types. Lastly,
we'll look at polymorphism in two parts.
The first about compile time
polymorphism, and the second about
runtime polymorphism.
The video assumes that the viewer has at
least some familiarity with similar
languages such as C, Go, Rust, or Zigg,
but only the basics. As long as you have
a general idea of what a strruct or
pointer is, you should be able to follow
along. Though Odin is a relatively
simple language compared to most others,
we won't exhaustively cover every topic
and every detail. For more complete
coverage, you should look at the
official Odin site. And I also recommend
Carl Zalinsk's YouTube channel and his
book understanding the Odin programming
language.
So first topic integers. The integer
types come in five different sizes with
both signed and unsigned variants. For
example, an i16 is a signed 16- bit
integer while a u 64 is an unsigned
64-bit integer. The types int and uint
are generally your default choices and
their sizes depend on the target
platform you're compiling for. For
example, when compiling for x64 ins and
uints will both be 64 bits. There's also
the type called bite which is actually
just an alias for u8.
There are three sizes of floating point
16 bit 32-bit and 64-bit. If you really
need high precision, you can use an
F-64, but F-32 is generally fine for
most cases.
A bit surprisingly, booleans also come
in multiple sizes, 8, 16, 32, and
64-bit. Even though, of course, in
principle, a boolean only requires a
single bit. The reason Odin has these
options is mainly to allow easier
interop with various binary formats and
to allow you to better control padding
and alignment in strrus. Most of the
time though, you'll simply default to
using the type called bool which like B8
is 8 bits in size.
The primary string type called string
represents a UTF8 encoded string and the
type called string 16 represents a
UTF-16 encoded string. Concretely, a
string value is actually a pointer to a
buffer of characters plus an integer
representing the length of the text. So
when you assign pass or return a string
value, what's actually being copied is
just a pointer and an integer, not the
actual character data. For ease of
interrupt with C, Odin also has types C
string and cring 16. These cring types
have no integer to represent the length
because they instead use the C
convention of signaling the end of the
character data with a zero bite. Lastly,
Odin also has another integer type
called rune that represents the Unicode
code point of an individual character.
Now, because Odin is not a garbage
collected language, it's important to
keep in mind how the character buffers
pointed to by strings are allocated. For
a string literal, the character buffer
is statically allocated, meaning the
data resides alongside the code of the
executable itself. But for any string
created at runtime, the character buffer
must be allocated dynamically, a topic
we'll discuss later.
All data types in Odin have a concept of
a zero value, meaning the value of the
type represented by all zero bits. When
a variable is left uninitialized, Odin
by default will zero out its bits,
giving it the zero value. For number
types, the zero value is of course the
number zero. For boolean types, the zero
value is false. For pointers, the zero
value is nil, indicating an address that
points to nothing. For strings, the zero
value has a nil pointer plus a length of
zero. For a strruct, the zero value has
fields which are all themselves zero
values. And for a union, the zero value
is usually nil, though there will be
more to say about this when we talk
about unions later.
Compared to C and some other languages,
Odin is much stricter about explicit
casting. For example, to assign this i32
variable to an i64 variable, the cast
cannot be left implicit. Even though a
smaller integer type can be cast into a
larger integer type without distortion,
Odin wants us to make the cast explicit
anyway to help prevent absent-minded
mistakes. By the way, note here the
declaration syntax in Odin. A declared
variable's name is followed by a colon
and then the variable's type.
Literal in Odin have their own distinct
types which a bit confusingly are called
the untyped types. Integer literal are
untyped integers. Floating point literal
are untyped floats. Boolean literal are
untyped booleans. And string literal are
untyped strings. These special untyped
types have a few special rules. First,
they only exist at compile time. So you
can't say create a variable with one of
these untyped types. Second, these types
can be implicitly cast to their related
types. Here, for example, the literal 14
is being implicitly cast to F-32 and the
literal 9 is being implicitly cast to a
U8. The third special thing about these
untyped types is that these implicit
casts perform range checks. So here,
when we try to assign 1,00 to a U8
variable, the compiler gives us an error
because 1,000 exceeds the valid range of
a U8.
In the case of booleans, untyped boolean
can be implicitly cast to any of the
boolean types. And likewise, in the case
of strings, untyped string can be
implicitly cast to any of the string
types.
Again, understand that these untyped
types only exist at compile time. So for
the sake of variable declarations with
inferred types, an untyped integer is
inferred to be an int. An untyped float
is inferred to be an F-64. An untyped
boolean is inferred to be a bool. And an
untyped string is inferred to be a
string rather than a string 16 or one of
the other string types.
Also note here the syntax. When the type
of a variable declaration is left
inferred from the assignment, we put a
space between the variable name and the
colon and we remove any spaces between
the colon and the equal sign. Be clear
though that this is just a formatting
convention. The colon and equal sign are
actually separate symbols. The only
substantive syntax change here from
prior examples is that we've emitted the
variable types after the colons.
As mentioned at the start, this video
assumes a basic familiarity with cike
language concepts including pointers. So
I won't give a lengthy introduction
here. But in short, a pointer is a value
that represents a memory address. And
very importantly, pointers are typed.
Meaning that say an int pointer is
intended to represent the memory address
of only ins while say a string pointer
is intended to represent the memory
addresses of only strings and so forth.
By virtue of being typed in this way,
the compiler can know that when you
dreference a pointer, when you read the
value at the memory address, the
compiler can know that the dreferenced
value is of the pointer's designated
type. For example, if you dreference an
int pointer, you should get back an int
value, not a float or a boolean or
anything else. And thanks to the
statically declared types, the compiler
can ensure at compile time that this
will be the case at runtime.
Anyway, hopefully that's a sufficient
explanation for you to follow along. So,
here is our first pointer variable. This
variable p is declared to be an int
pointer. And note that Odin uses the
carrot symbol instead of C's traditional
asterisk.
If we then also create a regular int
variable I, we can get a pointer value
representing the address of I by using
amperand the address operator. Using the
address operator on an int variable gets
us an int pointer value which we can
assign to p. To get the value referenced
by p, we can use the dreference operator
which is again the carrot symbol but
placed on the right side of its operand
not the left. here in the first line
because P references the location of I,
dreerencing P gets us the int value
stored in I which we here then assign to
int variable X.
In the second line, the target of
assignment is the dreference of P. So
we're assigning the value three to the
location stored in P, which is still the
address of I.
Now, you might be wondering why Odin
puts the dreference operator on the
right rather than keep it on the left
like in C and most other C-ike
languages. Well, moving it to the right
works out nicely when pointers are used
in combination with arrays. We'll cover
arrays later, but really briefly here,
we're declaring a variable P to be a
pointer to an array of five ins and then
assigning the address of an actual array
of five ins to P. When we then
dreference P to get the array, we can
also tack on the index operator to get a
value from the array. And because the
dreference operator and the index
operator both go on the right side of
their operands, they can be easily read
left to right. In contrast, the
equivalent in the traditional C style
syntax requires us to worry about
operator precedence. For instance, in
Go, which uses the C style syntax, the
equivalent expression requires PNS to
get the right order of operations. And
then in the opposite case where we
instead have an array of five int
pointers again Odin allows us to simply
read indexing and dreference expressions
left to right. So here we are getting
the first pointer in the array and then
dreferencing that pointer.
As we've established a pointer normally
has a designated type but sometimes it's
useful to have an untyped pointer that
can represent the memory address of
anything. In C this is called a void
pointer but in Odin the closest analog
is called a raw pointer. Here we're
creating a variable R of type raw
pointer and then we can assign the
address of anything such as an int
variable to this pointer. This address
operator expression returns an int
pointer but we don't have to explicitly
cast the raw pointer because as a
special rule Odin allows implicit casts
from any pointer type to raw pointer.
Going in the other direction however
always requires an explicit cast. Here
we create an intpo pointer variable and
then assign it to the raw pointer value
using an explicit cast.
Now in this case we know the raw pointer
was referencing an int variable. So this
cast makes sense but Odin will actually
let us explicitly cast the raw pointer
to any pointer type. Here for example
we're casting the same raw pointer value
to a string pointer. If we were to then
dreference the string pointer, we would
be interpreting the data at that
location as if it is a string,
potentially with disastrous results.
Even though this is unsafe, Odin still
allows it because one, there are some
cases when reinterpreting binary data is
actually useful. And two, the compiler
cannot track the actual values of
variables, only their types. So it never
knows what a raw pointer actually points
to. Therefore, the compiler must let us
cast a raw pointer into any pointer
type. And it's our responsibility to
only perform casts that make sense.
Similar to raw pointer, we also have
uintpointer which is so named because
it's an unsigned integer which is the
same size as a pointer. Like raw
pointer, we can cast any pointer to uint
pointer but we must do so explicitly.
Unlike C or other C like languages, Odin
doesn't let us do arithmetic directly on
pointers, but instead we can convert a
pointer into a UNP pointer, perform the
arithmetic, and then cast back to a
pointer. In this example, we're taking
the size of int, multiplying it by five,
and then adding that to our Uint pointer
before casting it to an int pointer.
Just be clear that pointer arithmetic is
potentially unsafe as in this case where
we're effectively creating a pointer to
a meaningless location on the call stack
that is five ins up in memory from the
variable I. In real use cases, you
should be careful that the result of
your point arithmetic always points into
known allocated buffers of memory.
Now, while we can do point arithmetic
with pointers, more commonly in Odin, we
should use instead what Odin calls a
multin. A multipointer is a bit more
convenient and a bit less errorprone
though still fundamentally unsafe.
Here the variable m is a multointer of
int as denoted by the square brackets
surrounding the carrot. We can assign an
int pointer to m with a cast to
multipointer left implicit. Instead of
using the dreference operator for a
multipointer we use the index operator
the square brackets. In the first
assignment here, the int value 100 is
being assigned to the location 3 ins up
in memory from the address represented
by the pointer. In the last line, we're
reading the int value at the location
that is 5 in down in memory from the
address represented by the pointer.
Again, keep in mind that arbitrarily
indexing memory is fundamentally unsafe
as in this case where we are jumping to
meaningless locations on the coal stack.
In real use cases, multipointers should
generally only be used to index within
known allocated buffers of memory.
Here in this code, we're declaring a
variable R, which is an array of five
ins. A local variable array in Odin is
fixed in size and stored directly on the
stack. So the variable R here has the
size of five ins. And because arrays in
Odin are fixed in size, the size must
always be specified by a compile time
integer expression.
Arrays can be expressed as literals such
as here where we assigned to R an array
of five ins where the values are 1 2 3 4
and 5. Also as shorthand, we could leave
the size and type implicit to be
inferred from the assignment target.
When declaring a new array variable, we
can use a question mark where the size
normally goes to have the size be
inferred from the number of elements
inside the curly braces. So this array
nums is inferred to be size three.
Another option with array literal is
that we can designate values for
explicit indexes in which case we don't
have to specify values for every index
and the values can be written in
non-sequential order. The assignment
here assigns apple to index 4, banana to
index 1, and orange to index 3, while
the emitted indexes 0 and 2 default to
empty strings. The indexes in array
literal can also be specified as ranges.
In this case, indexes 100 up through 200
are all being assigned the string
banana, while indexes 300 up to, but not
including 400 are assigned the string
orange.
Whereas an array variable in C is
actually a constant pointer value, this
is not the case in Odin. An Odin array
is a proper value unto itself. And so
arrays are assigned past, compared, and
returned by value, not by reference. So
here, when we assign one array variable
to another, the entire array is copied.
And if we compare the two arrays for
equality, all of their corresponding
indexes are compared.
When you do want to assign, pass or
return arrays by reference, you can do
so with array pointers or slices, which
we'll get to shortly.
By default, array index in an Odin is
bounce checked both at compile time and
runtime. When we try to assign to index
100 of this five bool array, the
compiler gives us a compilation error
because it knows that the compile time
value 100 is out of bounds for this
array. If though we index an array with
a runtime expression, the balance check
happens at runtime. So indexing this
array with a variable whose value will
be 100 when this assignment is reached
will trigger a panic.
These runtime bounds checks of course
incur some degree of overhead. So in
some performance critical contexts, you
may wish to disable them. If we do
indexing inside a pound no bounce check
directive block, bounce check panics
will not be thrown and this code will
dangerously read the data that happens
to lie 100 in up in memory from the
start of the array even though that is
well outside the array bounds.
What Odin calls a slice is a value that
represents a subrange of an array or an
array-like buffer. Concretely, a slice
contains a pointer to the start of the
subrange and then an integer for the
length of the subrange. For anyone
coming from Go, it's important to note
that unlike Go slices, Odin slices do
not contain a capacity and there is no
append operation for slices. For the
nearest equivalent of Go slices in Odin,
you instead want what Odin calls a
dynamic array, which we'll also cover
shortly.
Anyway, here we're declaring the
variable s to be a slice of ins as
indicated by the empty square brackets.
If we have an array of ins, we can use
the slice operator to produce a slice of
ins value. The slice operator looks like
the index operator, but with a colon
surrounded by two integers, which
represent the start and end of the
returned slices subrange. In this
example, we're getting a slice
representing the subrange of the array
that starts at index 30 and ends at
index 40, not inclusive. So the slice
value produced will store a pointer to
index 30 and a length of 10.
If we then assign a value to index zero
of the slice, this is logically the same
thing as assigning to index 30 of the
array.
As a convenience, the first integer of a
slice operation can be emitted in which
case it defaults to zero. So here the
slice now runs from index zero of the
array up to but not including index 40
and has a length of 40.
The second integer expression in the
slice operator can also be emitted in
which case it defaults to the length of
the array. So here the slice now runs
from index 20 of the array up to but not
including index 100 and has a length of
80.
Commonly we want to get a slice with a
certain length. So often we'll compute
the end index as the starting offset
plus the desired length. Here for
example, we want a slice that starts at
index 5 and has a length of 9. So the n
index is computed as offset plus length
5 + 9. A nice idiom that lets us express
this a little more elegantly is to
actually do two slice operations. First
we get a slice running from the desired
offset to the end of the array. Then we
get a slice of the slice starting from
its first index up to our desired
length. The end result is the same. But
this way we can write the offset
expression just once instead of twice.
Before completing our discussion of
slices and before introducing dynamic
arrays, we need to talk a bit about
allocators in Odin. This is a larger
topic than we can cover here, but we'll
hit the key ideas. Because Odin is not a
garbage collected language, you the
programmer are responsible for
allocating and deallocating any heat
memory that you want to use. For
example, if we want to create a slice
whose reference data resides on the
heap, we can call the make slice
procedure from the base library, which
returns a slice that references newly
allocated heap memory. And then when
we're done with the slice, we should
call delete slice from the base library
to deallocate the slices heap memory.
Whereas before we were creating slices
that reference the memory of stack
allocated arrays, here the slice
references heap allocated memory with no
array involved. and be clear that the
slice variable itself is still stack
allocated.
The hidden detail here is that make
slice delete slice and all other Odin
procedures that allocate and deallocate.
These procedures by convention let you
specify an allocator. Different
allocators track their allocations in
different ways and some allocators may
perform better than others in different
use cases. In our example, we aren't
explicitly passing an allocator in these
calls. So both calls use Odin's default
allocator. The default allocator is
normally accessible as
context.allocator.
So we can get the same result by passing
context.allocator explicitly.
The other allocator that's most commonly
used is normally accessible as
context.temp allocator. This temp
allocator does not track each allocation
individually. Instead of just tracks how
much of its allotted space has been used
such that its allocations can only be
freed as a group instead of
individually.
Here, if we use the temp allocator,
attempting to then deallocate the slice
individually will trigger a segmentation
fault. Instead, what we should do is
eventually call free all on the temp
allocator to deallocate all of its
allocations as a group. This effectively
resets the temp allocator, making all of
its memory available again for
subsequent allocations.
In practice, temp allocations are useful
in situations where you know that a
group of allocations can all be safely
freed at the same time. For example,
video games typically allocate many
objects in a frame that can be neatly
deallocated all at once at the end of
the frame. For these allocations, a game
can use the temp allocator. But for
things that need to live longer than an
individual frame, a game may need to use
an allocator that individually tracks
allocations, such as Odin's default
allocator.
Now that we have some understanding of
allocators, we can talk about dynamic
arrays. Whereas a normal Odin array is
fixed in size, a dynamic array has no
fixed size and so can grow and shrink.
Here we're declaring a variable R which
is a dynamic array of ins as denoted by
the reserved word dynamic inside the
square brackets. Concretely, a dynamic
array value resembles a slice in that it
also consists of a pointer and an
integer representing the length. But
additionally, a dynamic array has an
integer representing its capacity plus a
reference to an allocator. This capacity
and allocator reference allows a dynamic
array to behave more like go's version
of slices in that were able to append
values to a dynamic array. Whereas Odin
slices often reference subranges of
stack allocated arrays. This is not an
intended use case for dynamic arrays.
Instead, the data referenced by a
dynamic array is normally heap allocated
via base library procedures such as here
where this call allocates a block of
seven ins and returns a dynamic array
pointing to the block with a length of
four, capacity of seven and a reference
to the default allocator. When we later
no longer need this dynamic array, we
should call delete dynamic array, which
uses the dynamic arrays referenced
allocator to know which allocator to
deallocate the block from.
As a side note, the fact that slice
values do not include an allocator
reference can create a bit of a hassle
because you must then separately track
the allocators used for each of your
allocated slices. In contrast, dynamic
arrays conveniently reference their
allocator. And this in fact is generally
the recommended pattern for any data
types that require allocations. They
should store a reference to their
allocator.
Now, as promised earlier, we can append
to a dynamic array as here where we use
the base library procedure append
elements to append three values. The
procedure requires a pointer to the
dynamic array so that it may update its
length and also potentially update its
pointer and capacity. In this case, the
block referenced by the dynamic array
already has sufficient capacity for
three more values. So, the pointer and
capacity remain the same. But in cases
where the append operation exceeds the
existing capacity, then a new larger
block is allocated. The data is copied.
The new values are appended to this new
block. The original block is freed and
the pointer and capacity are updated to
match the new allocation. Here this
second append exceeds the existing
capacity. So a new allocation is made
with a capacity that is at least large
enough to accommodate the appended
values.
One more type of collection built into
Odin are maps which are hashmaps of key
value pairs. Concretely, a map value
consists of a pointer to a block of
memory where the key value pairs reside,
an integer indicating the number of key
value pairs and a reference to an
allocator here. Now, for example, this
variable m is declared to be a map of
string keys with int values. Before
using the map, we must allocate it. And
like all allocated things, we should
eventually deallocate it when we no
longer need it. Once the map is
allocated, we can add, set, and read key
value pairs with the index operator.
Here, we add a key high with the value
five to our empty map, increasing its
length to one. When we assign to an
existing key, we replace its existing
value, and the length of the map remains
unchanged. To remove a key, we call the
base library procedure delete key. Pass
in a pointer to the map and the key we
want to remove.
Like in C and other Cike languages, a
strct in Odin is a composite data type
that consists of named members called
fields. Here we're defining a type named
cat, which is a strct consisting of two
fields, an int named A and an F32 named
B. If we declare a cat variable, we can
assign to its individual fields with the
dot operator. We can also create cat
values with a literal syntax where each
field can be provided a value by name.
Any emitted fields default to their zero
values and even the strruct name on the
literal can be left implicit if it can
be inferred from the assignment target.
Here we're creating a variable whose
type is an unnamed anonymous strct. We
can explicitly cast to and from any
named strruct type that has the exact
same field names and types. Here the cat
strruct has the same fields with the
same types as the anonymous strruct. So
we can cast between these two types
though the casts must be explicit.
Anonymous strcts are particularly
convenient for strrus nested as fields
and other strrus. Here this dog strct
has a field named fu whose type is an
anonymous strruct and we can read and
write the fields of this anonymous
nestruct individually or as a complete
strruct. While using a namestruct
instead wouldn't change the semantics.
The anonymous strruct effectively allows
us to logically group fields together
without the hassle of defining a
separate named strruct type.
An enum in Odin is an integer type with
discreetly named compile time values.
Here we have a type direction which is
defined as a U32 enum with four named
values. North having the value zero,
east having the value one, south having
the value two and west having the value
three. We then create a direction
variable and assign it the direction
value south which has the U32 value 2.
When defining an enum, we can leave the
integer type unspecified, in which case
it defaults to int. If we omit the value
for the first name, it defaults to zero
and then any subsequent emitted value
will default to one greater than the
prior. In this example, then north
defaults to zero, but south then
defaults to 1338 because it is one
greater than the prior value east.
If we leave all the values of an enum
unspecified, they effectively run from
zero up to one less than the number of
values.
In a context where an enum type is
expected, such as in an assignment to an
enum variable, we can emit the name of
the enum type before the dot as
shorthand. Here, the compiler
understands that south is shorthand for
direction. South because the assignment
target is a direction variable.
Normally we only want to use the named
values of an enum but we can actually
cast any integer value into an enum type
such as here where we make a direction
value from 9 even though direction has
no named value for 9. We can even do
arithmetic with enum values though there
aren't many cases where this is useful
in a for loop. We can loop over every
named value of an enum type in the order
that they're listed in the enum
definition. Here as specified by the in
clause, this loop will iterate over each
named value of the direction enum and
each iteration will assign the direction
value to the first variable d and assign
the index of the iteration to the second
variable index.
We can also switch over enum values such
as here where this switch will execute
the case corresponding to the value of
this direction variable. Note that we
can use shorthand for the enum values in
each case.
By default, Odin strictly demands that
an enum switch have a separate case for
every named value. So here, when we emit
cases for a north and west, we'll get a
compilation error. However, if we add
the partial directive to our switch,
Odin will allow us to emit cases, and we
also then can have a default case.
To get an enum value name as a string,
we can call a procedure from the reflect
package. The procedure enum name from
value returns the name of an enum value
as a string. The procedure also returns
a boolean that will be false if the enum
value has no name. For instance, if the
direction value here is 9, the returned
boolean will be false because 9 has no
name in the direction enum.
A union is a data type defined as a set
of variant types such that the union can
store values of any of its variants.
Here, for example, we have a union named
pet, which is defined to have three
variant types, cat, dog, and bird, which
we haven't here written definitions for,
but just assume they're strct types. If
we then create a pet variable, the
variable has sufficient space to store a
value of the largest variant type. And
we can assign values of any variant type
to this variable without an explicit
cast. So, first here we assign a cat
value to variable pet, which stores the
cat value. Next though we assign a dog
value to pet which overwrites the cat
value with the dog value. Again any
variant type can be implicitly cast to
the union type. However, we cannot cast
even explicitly in the other direction.
Instead to get the stored value out of a
union, we need to use what's called a
type assertion. This type assertion here
tests if the union variable pet
currently holds a cat. If so, it returns
the cat value and the boolean true. If
the variable doesn't currently hold a
cat value, a type assertion returns the
cat zero value and the boolean false.
How is it known what variant type is
held in a union? Well, alongside the
variant value, a union also stores a tag
integer which denotes the variant type.
So, at runtime, a type assertion checks
tag to determine if the union value
currently holds the expected type.
In cases where we're confident what
variant a union value will hold at
runtime, we can use the shorter form of
type assertion that returns just the
variant type. This form of type
assertion still checks the tag at
runtime, but instead of returning a
boolean, this type assertion will
trigger a panic if the held variant is
not a cat.
In addition to type assertions, we can
get the variant values held in a union
with a form of switch called a type
switch. Here we're switching on the pet.
If variable pet stores a cat, then the
cat case is executed and variable P will
have type cat. If variable pet stores a
dog, then the dog case is executed and P
will have type dog. And if variable pet
stores a bird, then the bird case is
executed and P will have type bird. By
default, our type switch must cover all
the variant types individually. But if
we add the partial directive on our type
switch, we can then emit cases and
optionally add a default case. Here the
default case is executed when pet is
either a dog or bird and P in the
default case will have type pet.
By default a union type includes the
special value nil as one of its variants
and this nil variant is represented by
the tag zero. Therefore the zero value
of these union types is equal to nil
here. Then when we assign nil to the pet
union variable, it is implicitly cast to
the pet union type. The important thing
to understand is that the compiler
considers the nil value of one union
type to be distinct from nil pointers
and distinct from the nil values of
other union types. Here we can't assign
a nil pointer to our pet variable. Nor
can we assign a nil fruit union value.
Neither of these nils are pet union
values and so these assignments are
invalid.
Unlike many other languages, Odin has no
exception mechanism. It does have
runtime panics which are triggered by
some operations such as failing bounce
checks and these panics will unwind the
call stack. But there's no way in the
language to catch and recover from these
panics except to do some logging and
cleanup before the program terminates.
Consequently, panics are not a mechanism
for normal error handling.
Instead, normal errors in Odin are
represented as ordinary data values and
these error values should follow three
strong conventions. First, error values
should always be represented either as
boolean, enum, or union types. Second,
error values should always be returned
from procedures as the last return
value. So, if a procedure returns other
things in addition to an error, the
error should always be the last of the
return types. Third, success, meaning
the absence of an error, should be
indicated by true for booleans, the zero
value for enums, and nil for unions. So
for example, a procedure that returns a
boolean error value should return true
to indicate success and false to
indicate that an error has occurred.
For some examples of error values, we'll
look at a few procedures from the base
libraries.
For an example of a boolean error,
here's the parse F64 procedure from the
stir conff package. This procedure
attempts to parse the string argument as
an F64 number and when successful, it
returns true. Otherwise, if the string
can't be parsed as an F64, the procedure
returns false.
For an example of an enum error type, a
number of library procedures that
perform allocations uses allocator error
enum to signal allocation errors.
Because allocations may fail in multiple
ways, it's useful to convey that
information with an enum instead of just
using a boolean to signal whether an
error has occurred. So here for example
when we call the alloc procedure from
the mem package it returns two values a
pointer for the allocated data plus an
allocator error. If the allocation
succeeds the procedure will return the
zero value of allocator error which has
the named value none. Otherwise
depending on the nature of the failure
the procedure will return one of the
other allocator error named values.
Note that we don't use a partial switch
here. So the compiler forces us to cover
every named value of the enum. It's
unwise to ignore errors. So it's
generally best to avoid partial switches
when processing an error enum.
While enum errors provide more
information than a simple boolean, you
sometimes want an error value with other
kinds of data such as string messages.
And this is where union errors become
useful. The variant types of a union can
be anything such as strings, strrus,
other unions, or whatever. So a union
error can hold any information that we
need it to. Here's an example union
error type from the OS package. One
called error. The procedure open from
the OS package attempts to open a file
and returns this union error type. What
we should do in response to an error of
course depends on the specific error and
the specific context which is beyond our
scope here. But in the general case, we
can account for all the possible kinds
of error by using a type switch. Again,
you should generally avoid using partial
type switches to handle errors as it can
make errors too easy to inadvertently
ignore.
Very commonly with error values, we want
to immediately return the error if it is
non zero. Here we're getting the error
returned from the procedure then
immediately returning it if it is non
zero. This pattern is so common that
Odin provides the or return operator as
shorthand for the same logic. The or
return operator follows its operand
which is always a procedure called
returning at least one value and the or
return effectively consumes the last
returned value of its operand returning
it when the value indicates an error. So
in this case if the parse f64 call
returns true the or return simply
consumes the boolean and the whole
expression evaluates into just the f64
returned by the call. If however the
call returns false indicating an error
then or return will immediately return
false from the containing function.
Note that because parse f64 returns a
boolean error it can only be used with
or return in a procedure that also
returns a boolean. Likewise if we use on
return with a procedure call that
returns an enum or union type error the
containing procedure must also return
that same error type.
to use or return inside a procedure that
returns multiple types. Then the error
type must be last and the return types
must be named return variables. Here for
example, the return types of procedure
fu are given variable names x, y and
error which allows us then to use or
return in the procedure on any call that
returns a bool as its last return type.
So here if the call the bar returns
false the or return will trigger a
return from the function with the
current values of x and y plus the value
false.
Odin also has an or break operator which
is much like or return except it simply
performs a break rather than a return.
There's also an or continue operator
which of course continues instead of
breaks.
Lastly, there is or else which unlike
the other operators takes a second
operand on its right. This right operand
is only evaluated if the left operand
returns a non-success error value in
which case the or else expression
evaluates into the right operand value
instead of the left operand. In effect,
an or else lets us conveniently specify
a default value expression for the event
of an error.