Video summary
Bu video, Odin programlama dilinin temel özelliklerini ve kullanım örneklerini derinlemesine inceleyen bir dizi kod örneğinden oluşmaktadır. Örnekler, Exercism projesinden alınmış olup, ikili arama, pangram kontrolü, string ters çevirme, kelime sayımı, kısaltma oluşturma, anagram bulma, hiyerarşik yapıların düzleştirilmesi, halka tamponları ve bağlı listeler gibi çeşitli algoritmik problemleri çözmektedir. Video, Odin'in tip parametreleri, opsiyonel döndürülen değerler, bit set'ler, Unicode desteği ve bellek yönetimi gibi konularını bu pratik uygulamalar üzerinden açıklamaktadır. Özellikle, dönen boolean değerlerin `optional ok` direktifi sayesinde göz ardı edilebilmesi veya tip parametrelerinin sadece ilk argümanda `$T` ile tanımlanması gibi Odin'e özgü mekanizmalar detaylı bir şekilde ele alınmıştır.
Bellek yönetimi ve kaynak tahsisi, Odin kodlamasında kritik öneme sahiptir ve bu video boyunca sıkça vurgulanmaktadır. Dinamik olarak ayrılan dizi veya string builder'lar için `defer` ifadesi kullanılarak fonksiyonun sonlandığında otomatik olarak belleğin serbest bırakılması sağlanırken, çağırıcının dönen değerlerin sorumluluğunu üstlenmesi gerektiği belirtilmektedir. Ayrıca, Odin'in bit set yapısı ile ilgili olarak, farklı karakter aralıklarının tamamen ayrı tipler oluşturduğu ve bu nedenle tip güvenliği açısından önemli olduğu açıklanmıştır. Kelime sayımı örneğinde, haritaların alt dizeleri nasıl yönetildiği ve ana string serbest bırakıldığında tüm ilişkili belleğin nasıl temizlendiği gösterilmiştir.
Son örneklerde Odin'in genel yapılar (generic structs) ve parametreli tiplerin nasıl kullanıldığı detaylandırılmıştır. Özellikle bağlı liste yapısında, her düğümün önceki ve sonraki referansları tutması ve bu yapıların dinamik olarak genişleyebilmesi gösterilmiştir. Ayrıca, halka tamponlarında hata durumlarının (boş veya dolu) nasıl yönetildiği ve `overwrite` gibi fonksiyonların doluluk durumunda mevcut veriyi nasıl sildiği anlatılmıştır. Video, Odin'in hem performans odaklı hem de bellek güvenliği açısından dikkatli bir yaklaşım sergilediğini ve özellikle okuma-yazma sonrası serbest bırakma (read-after-free) hatalarını önlemek için `defer` kullanımının önemini vurgulamaktadır.
Read the full video transcript
This is a follow-up to two prior videos
that introduce the Odin programming
language.
In this video, I'm going to walk through
several small examples of Odin code,
though we won't be focusing on the
details of the specific problems and
solutions. Rather, we'll just focus on
the aspects of the samples that are
unique to Odin.
Most of the Odin features in this code
were covered in the prior two videos,
but we'll also encounter a few things
not yet covered.
The code samples are all taken from the
Exercism project, which is a collection
of programming practice problems for
many various languages, including now
Odin.
So, here first is a procedure
implementing binary search. The
procedure named find takes a slice and a
target value, then returns the index of
the target value within the slice, plus
a boolean indicating whether the value
was found or not.
As usual for a binary search, the input
list is assumed to be sorted, and the
target value, if present, is assumed to
occur only once in the list.
In the body, we first declare two
variables, a start index initialized to
zero, and an end index initialized to
the length of the list. We then loop for
as long as start is less than end, and
each iteration we get the midpoint
between start and end, check if the
value there is equal to the target, and
if so, return the midpoint index and
true.
Otherwise, we adjust the end or start
index depending on whether the midpoint
value is greater than or less than the
target.
If the target value is not present, the
start index eventually equals or exceeds
the end index, ending the loop, and then
we return zero and false.
All of that should be familiar to you if
you've ever implemented a binary search
before.
Anyway, the first Odin feature to note
here is the optional okay directive
after the return type.
This directive allows calls to the
procedure to ignore the last boolean
return type.
Normally, when we call a procedure, if
we use the return values, we have to
have an assignment target for them all.
So, here the call to find has two
assignment targets.
We can always use underscore to discard
any your values we don't need, but
thanks to the optional okay directive
here, we can also call the procedure as
if it just returns an int, making this
last call valid.
The reason Odin has this feature is
mainly because a number of common base
library procedures return a boolean as
indicator of success, and often this
boolean can be safely ignored, hence
this convenience.
The other interesting Odin feature here
is the use of the type parameter dollar
sign T.
Note that the dollar sign prefix must
only be put on the first occurrence of a
type parameter in the parameter list, so
only the first T here has the dollar
sign.
What the parameter list here indicates
is that the first argument must be a
slice, but can be a slice of any kind of
element, and then the second argument
must match the type of the slice
elements.
So, say if the first argument to a call
is a slice of ints, then the second
argument in that call must be an int.
Likewise, if the first argument to a
call is a slice of strings, then the
second argument in that call must be a
string.
However, keep in mind that the procedure
must be compiled separately for each
call with a different type for T, and
the procedure may not necessarily
compile for all types of T.
In this case, the target value is used
in a less than operation, so T must be a
numeric type to be accepted by the
compiler.
If we call the procedure with, say, a
slice of booleans, then that call will
be rejected by the compiler.
Here we see some tests for the binary
search procedure.
The standard testing API is imported as
core testing, and then each test is
defined as a procedure having the test
attribute and a single parameter of type
pointer to testing.t,
which be clear is a struct type defined
in the testing package, not a type
parameter, despite the choice of name.
In a test procedure, we can call various
procedures from the testing package,
such as expect value, which logs a test
error if its second and third arguments
are not equal.
Note that expect value, like most
procedures of the testing API, takes the
testing.t pointer as first argument.
The second code example is a procedure
that returns true if the provided string
is a pangram, meaning a string that
contains every letter of the alphabet.
The solution is case-insensitive and
ignores characters that aren't letters
of the English alphabet.
In the procedure, we use an Odin data
type called a bit set.
Here we define a type alphabet to be a
bit set that encompasses the range of
characters from lowercase A to lowercase
Z,
meaning each alphabet value has a bit
for every character in this range.
Because there are 26 letters in this
range, an alphabet value must be at
least 26 bits in size and so must occupy
at least 4 bytes.
In many cases, Odin will round up the
size of a bit set for the sake of
alignment.
Be clear that Odin considers bit sets
with different ranges of values to be
completely separate types.
For example, if we created a bit set
musical notes with the range of
characters A through G, that would be a
separate type with a different size.
Anyway, after defining the alphabet
type, we then create an alphabet
variable expected, which will represent
the set of letters we expect to find in
the string.
By the way, the zero value of a bit set
is empty, meaning all the bits are
unset.
We then assign the variable an alphabet
literal containing every character of
the alphabet set, the full lowercase
alphabet.
In the next assignment though, we
demonstrate a more convenient way to get
the same result. We simply negate the
empty set with the tilde operator.
Anyway, we'll then define a constant
we'll need representing the difference
between ASCII lowercase A and ASCII
uppercase A, which should be 97 - 65
equaling 32.
Next, before looping through the
characters of the string, we declare
another alphabet variable found, which
will track the letters we find in the
string.
And we leave it uninitialized so it
defaults to the zero value, meaning an
empty set.
The for in loop will assign each rune of
the string to variable r.
Remember, a rune is an unsigned integer
representing a Unicode code point.
And then, when we add two bitset values
together, that returns the union of the
two sets. So, here we can add a value to
the found set by adding it together with
a set containing just the individual
rune.
If the character is lowercase, we add
the rune as is, but if it's uppercase,
we add the upper to lower diff to get
its lowercase equivalent.
Lastly, after the loop, we return the
equality test of the found and expected
sets.
If the string has every letter of the
English alphabet, then found and
expected should match, so the procedure
will return true.
Looking at the tests for this procedure,
the only new thing here is that the
tests use the expect procedure, whereas
expect value logs an error if two values
don't match, expect logs an error if its
boolean argument is false.
The next sample implements a procedure
reverse that reverses the characters of
a string.
To work correctly with a broad range of
Unicode characters, we use a procedure
from the Unicode UTF-8 package called
decode grapheme clusters.
A grapheme represents what a user
perceives as an individual character.
For some characters in complex writing
systems, a single grapheme may be
represented by a cluster of code points
rather than just a single code point.
So, this procedure returns a dynamic
array of graphemes plus three other
values, which we don't need here, so we
just discard.
Because the returned dynamic array is
allocated, we should eventually free it,
so we defer a call that will delete the
array.
Because the call is deferred, it won't
execute until execution returns from the
procedure.
To make a new string from pieces to
build it up incrementally, we'll need a
string builder, so we create one by
calling builder make from the strings
package.
This call also allocates, but we don't
delete it because we're going to return
the string built by the string builder,
and then it will be the caller's
responsibility to ultimately deallocate.
Next, we then use a foreign loop to
iterate over the graphemes, but in
reverse order as denoted by the reverse
directive.
Inside the loop, each grapheme will be
assigned to G, and its index will be
assigned to I.
For each grapheme, byte index denotes
the index in the string where the bytes
of its code point close to reside, but
to determine the size in bytes of the
cluster, we have to look at the byte
index of the next grapheme.
Once we have the byte index and the
number of bytes, we can slice the string
to get the bytes of the individual
grapheme, and then we can write these
bytes to the string builder.
Notice though that we slice twice, first
to get a slice that starts at byte
index, then second to truncate the slice
to have a length of num bytes.
Also note that we have to treat the last
grapheme as a special case because its
cluster runs to the end of the string.
Anyway, once the loop is done, we return
the assembled string from the string
builder.
The next sample defines a count words
procedure that returns the count of each
unique word in a string.
It takes a string as input and returns a
word count struct, which contains a map
of strings to integers and a string that
will contain all the words stored in the
map.
As we'll see, the word counts needs to
retain the full string so that we can
properly deallocate.
First in the procedure, we convert the
string to lowercase so that all repeated
occurrences of a word have the same
case.
Notice that we are assigning to a newly
declared variable with the same name as
the input parameter. Odin doesn't let us
reassign the value of a parameter
variable, but we can effectively shadow
it with another local variable of the
same name.
We could, of course, just pick a
different name, but this seemed to make
sense.
In the next line, we create a word count
struct with the lowercase string
assigned to the string field.
We then define an array of strings
containing the delimiter characters
we'll use to split the string.
Next, the for in loop iterates over
every string returned by split multi
iterate from the strings package.
This procedure does not actually return
an array or slice of any kind of
collection.
Instead, it returns a string and a
boolean.
When such a procedure is called in the
in clause of a for loop, it's the
returned boolean that determines if the
loop runs another iteration, and the
other returned values are assigned to
the loop variables.
So, this loop here keeps iterating as
long as split multi iterate returns
true.
Also notice that this split procedure
takes a pointer rather than just a
string. This allows the procedure to
update the string. In each call, split
multi iterate scans through the string
to find the first occurrence of any of
the delimiter strings.
If none is found, then returns false,
and the loop ends.
If a delimiter is found, then the
substring after the delimiter becomes
the new value of input, and the
substring before the delimiter is
returned.
Effectively, each iteration returns the
first word of the input, and that word
plus the first delimiter is truncated
from the input string.
Be clear though that nothing here is
actually modifying the actual character
data.
All of the strings created in this loop
here represent sub ranges of the string
that was returned by the call to two
lower.
Anyway, in our loop now, we trim away
any leading or trailing punctuation
marks, which also understand returns
just a subset of the original string
rather than a newly allocated one.
If the length of the word is zero, we
skip it, but otherwise, we increment the
count in the map associated with this
key.
When the loop finishes, the counts have
all been tallied, and we return.
Lastly, we have an additional procedure
which deallocates the members of the
word count struct. It is the
responsibility of the count word color
to eventually deallocate the word counts
members by calling delete word counts.
You might be unclear though where the
members of the word count struct are
getting allocated exactly as it's not
super obvious if you're expecting to see
calls to make procedures.
Well, looking at the code again, the key
stir field is assigned the newly
allocated string returned by two lower
and the data field is implicitly
allocated with Odin's default allocator
the first time we add a word to the map.
Each time we add an additional word to
the map, the map may get reallocated if
it doesn't have sufficient space to
store the new word.
Be clear though that the map only stores
the string values, not the actual
character data referenced by those
string values.
The string keys we're adding to the map
are all substrings of the string
allocated by the two lower call, so when
key stir is deallocated, that
effectively deallocates all of the
memory referenced by the key strings.
In this next example, the procedure
abbreviate converts a phrase to its
acronym or if you're a pedant, it's
initialism.
For example, the string as soon as
possible becomes ASAP.
Hyphens in the input string are treated
as word separators, so liquid crystal
display becomes LCD.
In the procedure, we use this pattern
string constant as a regular expression.
The backtick delimiters make this a raw
string literal, meaning it can directly
contain new lines and doesn't interpret
backslashes as escape sequences.
In this case a regular string literal
would work just as well, but raw string
literals are often useful for regex
patterns.
The create iterator procedure from the
regex package creates an iterator over
matches for the given phrase and
pattern.
In addition to the iterator, it returns
an error, but we safely discard the
error here because we can assume our
pattern is valid.
We also create a string builder so we
can append letters of the abbreviation
incrementally.
For both the iterator and the string
builder, we defer calls to their
destructors so that they both get
deallocated when execution leaves the
procedure.
In the for in loop, the in clause calls
the match iterator procedure, which
returns a capture, meaning a match, an
index of the capture, which we discard,
and a boolean that indicates whether
another match was found.
So, this loop iterates until match
iterator returns false, and the reason
the procedure requires a pointer to the
iterator is so that it can update the
iterator's internal cursor.
Inside the loop then, we get the first
letter of the first capture group, and
then write this letter to the string
buffer.
Once the loop is finished, we get the
string from the builder, get its
conversion to uppercase, then return.
Because the return string was newly
allocated by the two upper call, the
caller should eventually free the
returned string.
The next example defines a procedure
that returns the words from a list of
candidates that are anagrams of the
target word.
For example, for the target word solemn
and candidate words lemons, cherry,
melons, the procedure returns lemons and
melons because they are anagrams of the
target word solemn.
In the procedure, we first get the
lowercase conversion of the word, and
then pass the lowercase string to a
second procedure we define called
letters in order.
The letters in order procedure returns
the runes of the string in sorted order.
To store the anagrams we find, we create
a dynamic array of strings before
looping
For each candidate, we also get its
lowercase conversion and runes in sorted
order.
If the sorted runes of the candidate
match the sorted runes of the target
word, then they must be anagrams, and so
we append the candidate to the result
array.
As a special case though, if the
candidate exactly matches the target
word, we don't count that as an anagram.
After the loop, we return the full slice
of the dynamic array.
As usual, the caller should eventually
free any allocated memory returned by a
procedure,
but in this case, we get back a slice
rather than the original allocated
dynamic array.
That's not a problem though, because the
slice references the same memory as that
dynamic array, and so freeing the slice
frees that memory allocated for the
dynamic array.
The only questionable detail is that
unlike a dynamic array, a slice does not
retain a reference to its allocator. So,
a slice by itself does not indicate
where the memory was allocated from.
In this case though, the default
allocator was used to allocate the
dynamic array, so the caller can safely
free the slice from the default
allocator.
In the next sample, we have a union
called item, which is composed of two
variant types, I32 and a slice of the
union itself.
Effectively, an item represents a
recursive ordered hierarchy of other
items, where each item is an I32 or more
items.
The flatten procedure takes a single
item and flattens all I32s in its
hierarchy into a slice.
While such a problem can be solved by
defining the flatten procedure to be
recursive, instead here, the solution
uses a manually created stack, as this
is generally a more efficient way to
traverse a hierarchy.
First in the procedure, we allocate a
dynamic array that will store the output
I32s, and another dynamic array that
will act as a stack for storing items as
we traverse the hierarchy.
We start off by pushing the input item
to the stack before entering a loop that
keeps iterating until the stack is
empty.
In the loop, we pop the last item from
the stack using the built-in pop
procedure,
and then use a type switch to access the
I32 or item slice that it holds.
If the item holds an I32, we simply
append it to the output array, but if
the item holds an item slice, then we
append all of its elements to the stack
in reverse order.
We go in reverse because the stack is
consumed last in, first out.
Eventually, this loop exhausts all of
the items in the hierarchy, at which
point we return the full slice of the
output array.
Like usual, the caller of this procedure
should eventually deallocate the
returned slice.
Our next-to-last sample implements a
ring buffer of ints.
We define the ring buffer type as a
struct with four members, a slice of
ints to store the elements, an integer
representing the size, meaning the
number of slots that are currently
occupied,
an integer representing the index of the
current head, and a reference to the
allocator which was used to allocate the
slice.
In fact, in general, a struct that
contains allocated members should also
reference their allocators so that they
can be freed or reallocated later.
Now, some operations we'll define for
the ring buffer will potentially fail,
so we define a ring error enum type with
three values, none, meaning no error,
buffer empty, meaning the buffer was
empty when it wasn't expected to be, and
buffer full, meaning the buffer was full
when it wasn't expected to be.
So, for the first operation, we define a
procedure new buffer that returns a ring
buffer with a newly allocated slice of
elements.
The interesting feature here is that the
second parameter is given a default
value, the context allocator, and also
the type of this parameter is left
inferred from the assignment.
The subtle thing here is that a default
value expression is evaluated in the
scope of each call.
So, when we call this procedure with
only one argument, the allocator
defaults to whatever value
context.allocator has in that calling
scope.
Effectively, then, this procedure
defaults to whatever context.allocator
is active at the call site.
The second procedure, destroy buffer,
deallocates the buffer slice using the
referenced allocator.
As extra precautions, this procedure
also sets the size and head to zero to
help prevent misuse of the buffer struct
after it's been destroyed.
In fact, the other buffer procedures are
all defined to do nothing or return
errors when passed a zero value ring
buffer struct.
The third procedure, clear, simply sets
the head and size to zero, which
logically removes all values.
The fourth procedure, read, returns the
element at the head index and advances
the head to the next logical index.
For safety, we first check if the buffer
is empty, in which case we return the
buffer empty error.
Then we get the value at the head index
of the slice, and then increment the
head index by one, wrapping back to zero
if the head was already at the end of
the slice.
Because this operation removes an
element, we decrement the size by one
before returning the read value and
error value none.
The fifth procedure, write, appends an
element at the logical end of the
buffer, but first checks if the buffer
is already full, in which case it
returns the buffer full error.
Otherwise, we compute the index by
adding the current size to the current
head, again wrapping back around if this
exceeds the end of the slice.
We then write the value to this index,
increment the size by one, and return
error value none.
The sixth procedure, overwrite, also
appends an element at the logical end of
the buffer, but if the buffer is already
full, overwrite clobbers the current
head and advances the head to the next
index. So, in the body, we call write,
and if it returns the buffer full error,
we overwrite the head value and
increment the head index, wrapping if
necessary.
Our last example implements another
collection structure, a linked list.
Unlike our ring buffer, our linked list
is a para poly struct, aka a generic
struct, allowing us to create linked
lists of any element type.
As denoted by the dollar sign prefix,
the type ID parameter T of the list
struct requires a compile time argument.
T effectively then functions as a type
parameter, and in the struct we have
three members, a head and a tail node
plus a reference to the allocator of the
nodes.
The node type itself also has a dollar
sign T type ID parameter and has three
members,
two node pointers, previous and next,
plus the actual value stored in the
node.
So, for example, if we create a list
where T is a string, then the nodes of
this list will contain strings.
Because some operations on lists
potentially may fail, we also define an
enum error type with two values, none,
meaning no error, and empty list,
meaning the list was empty when it
wasn't expected to be.
The first procedure, new list, returns a
newly allocated list.
The first parameter expects a compile
time type ID, which determines the type
of list created.
The use of double dots on the last
parameter type makes this procedure
variadic, meaning it can be called with
any number of T values.
Concretely, this elements parameter is a
slice of T, and all the T arguments to a
call are collected into a slice that is
passed to elements.
In the body, we first create a list of T
struct, making sure to set the
allocator.
We then loop over every element along
with its index and create a node for
each.
The procedure new allocates memory for a
single value of the provided type, in
this case node of T, and returns a
pointer to that allocation.
We then set the value for the node and
set its previous pointer to the current
tail of the list, which is initially
null.
If this is the first node of the list,
we make the node the head, but otherwise
we update the tail to reference it as
the the node.
Lastly, we make the new node the tail of
the list.
Once a new node has been created for
each element, we return the list.
The second procedure, destroy list,
deallocates all of the nodes of the
list.
Notice the syntax for the list
parameter. This is saying that the
parameter is a pointer to a list of T,
where T can be any type.
To deallocate the nodes, we simply loop
through them, starting with the head and
follow every next pointer until it reach
a null pointer.
For the actual deallocation, we call the
built-in procedure free, which is the
counterpart of the new procedure.
The remaining procedures I'll just
summarize quickly. The third procedure,
unshift, inserts a value at the head of
the list. The fourth procedure, push,
adds a value to the tail of the list.
The fifth procedure, shift, removes and
returns the value at the head of the
list.
The sixth procedure, pop, removes and
returns the value at the tail of the
list.
The seventh procedure, reverse, reverses
the elements of the list without making
any new allocations.
The eighth procedure, count, returns the
number of elements in the list.
And the last procedure, remove first,
removes the first element from the list
which matches the provided value.
If there is no matching value, the list
is unchanged.
The implementations of these procedures
don't use any Odin features we haven't
seen already, but I will note one
interesting detail in the shift and pop
procedures.
These procedures free a node, but they
also read the value of that same node
when they return. So, to avoid a
read-after-free error, these procedures
defer the free calls so that they're
only freed after the return executes.
Now, in most cases, the read-after-free
wouldn't be an actual problem here
because we know for a fact that no new
memory is allocated between the free and
the return.
However, with some allocators, a free
call might immediately update the
program's page tables, in which case a
read-after-free trigger a page fault.
Hence, deferring these free calls is the
proper thing to do. Or alternatively, of
course, we could simply just copy the
return value to a local variable before
safely freeing without deferring.