DEV Community

Cover image for Odin for Alchemists
Camilo
Camilo

Posted on • Edited on

Odin for Alchemists

Modules

Elixir

Elixir has the concept of modules. Which can be inside a single file or can be subdivided in multiple files. Directories are not important for the compiler since all the modules will only differentiate by their name My.Module.

directory/
- module_1.ex
- module_2.ex
- module_3_and_4.ex
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we have the concept of packages. A package is a directory that contains different files. Directories are important to Odin. Package names must be unique and be a valid identifier. Example My_Package.

package/
- file1.odin
- file2.odin
Enter fullscreen mode Exit fullscreen mode

Is like the compiler concatenates all the files in a single file for a package.

Similar to this:

$ cat cat/paws.odin cat/meows.odin > cat/amalgamated_package.odin
Enter fullscreen mode Exit fullscreen mode

All the files in the same package will share the package name in the file.

Example

  • cat/paws.odin: package Cat.
  • cat/meows.odin: package Cat.

For using the public procedures, constants, structs, unions from a package we have to import it. Keep in mind that we import the directory as a whole and not a single file.

import "lib/cat"
Enter fullscreen mode Exit fullscreen mode

We can make an alias by using another name before the route.

import Cat "../cat"
Enter fullscreen mode Exit fullscreen mode
Cat.meow()
Cat.sleep()
Enter fullscreen mode Exit fullscreen mode

Aliases are needed if your package has an invalid identifier. For example if you store a library inside a version number.

0.4/
  - mylib.odin
Enter fullscreen mode Exit fullscreen mode

If we would like to import it we need an alias.

import mylib "0.4"
Enter fullscreen mode Exit fullscreen mode

Collections

Odin has the concept of collections that are predefined paths that can be used in imports.

  • core: The most common collection that contains useful libraries from Odin core like fmt or strings.

You can define your own collection at build time

The following will define the collection project and put the path at the current directory.

$ odin run . -collection:project=.
Enter fullscreen mode Exit fullscreen mode

Public by Default

Odin Exported Names are public by default (anything you declare in a file is public and can be accesed by other packages). If you don't want to export something you have to use @(private) before declaration.

@(private)
my_variable: int // cannot be accessed outside this package
Enter fullscreen mode Exit fullscreen mode

@(private) is equivalent to @(private="package").

You can make the declarations private by file, only available within the file where was declared.

@(private="file")
my_variable: int // cannot be accessed outside this file
Enter fullscreen mode Exit fullscreen mode

Another option is using the #+private or #+private file general attributes before the package name. That makes all the file private by default and only available to the package itself.

private_by_default.odin

#+private
package Package1

// This procedure will only be available to the package
// impossible to make it public unless is stored in another file
// without the #+private directive
private_proc :: proc() -> int {
  return 42
}
Enter fullscreen mode Exit fullscreen mode

By using this alternative we can have a file that is only for storing private procedures and other data structures.

Being private by default is a benefit because it requires you to explicitly expose things to be part of your libraries API, improving the organization of your package.

my_package/
  - api.odin
  - api.priv.odin
Enter fullscreen mode Exit fullscreen mode

We can store all the private declarations in a single private file and not clutter with @(private) in every symbol.

Maps

Elixir

%{
 "this": "is my map",
 "answer": 42
}
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we have to be more specific with types. If we would like multiple types we may think is a good idea to use any.

map[string]any {
  "this" = "is my map", 
  "answer" = 42, 
}
Enter fullscreen mode Exit fullscreen mode

WARNING

This is just an example use of any type. Literally the use of any is dangerous. Is best to never use it unless you know EXACTLY how it works. The type any is just a pointer and a typeid. When you assign a value to an any you are in fact taking the address of the value. This means if you make a pointer to a variable inside a procedure, this will crash the program if you use it outside the procedure.

insert_values := proc(m: ^map[string]any) {
  a := "hello"
  b := 42

  m["a"] = a
  m["b"] = b
}

main :: proc() {
  m := make(map[string]any)
  defer delete(m)

  insert_values(&m)

  log.info(m["a"], m["b"]) // crashes here
}
Enter fullscreen mode Exit fullscreen mode

Unions

So how we can make a map that takes both ints and strings without the use of any. For more specific type validation Odin has Unions.

A union in Odin is a discriminated union, also known as a tagged union or sum type. The zero value of a union is nil.

AvailableMapValueTypes :: union {
  int,
  string,
}

map[string]AvailableMapValueTypes {
  "this" = "is my map", 
  "answer" = 42, 
}
Enter fullscreen mode Exit fullscreen mode

We can make it a little bit shorter. Also ensure that we manage the memory allocated with maps.

mymap := map[string](union{int, string}) {
    "this" = "is my map", 
    "answer" = 42, 
}

defer delete(mymap)
Enter fullscreen mode Exit fullscreen mode

In memory, Unions are just a special variant of Structs.

struct {
    payload: [size_of(Largest_Variant_Type)]byte,
    tag: byte,
}
Enter fullscreen mode Exit fullscreen mode

They store exactly one of their possibly variants at any given moment, and the tag is used to know which one is currently being stored at any given moment. They can also be empty, in which case the tag == 0, and the_union_value == nil.

Lists

Elixir

["1", 2]
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we have to be more strict with the types.

Array of strings

[]string{"1", "2"}
Enter fullscreen mode Exit fullscreen mode

Can also be added dynamic to be more explicit. Be careful because comparations will notice the type difference between [] and [dynamic]. Normally [dynamic] are called slices.

[dynamic]string{"1", "2"}
Enter fullscreen mode Exit fullscreen mode

Array of numbers

[]int{1, 2}
Enter fullscreen mode Exit fullscreen mode

List of items

[]any{"1", 2}
Enter fullscreen mode Exit fullscreen mode

Fixed size list of items

[2]any{"1", 2}
Enter fullscreen mode Exit fullscreen mode

Automatic fixed size list of items
This will calculate and replace ? with 2
at compilation time.

[?]any{"1", 2}
// same as [2]any{"1", 2}
Enter fullscreen mode Exit fullscreen mode

Array Programming

Odin’s fixed length arrays support array programming.

Vector3 :: [3]f32
a := Vector3{1, 4, 9}
b := Vector3{2, 4, 8}
c := a + b  // {3, 8, 17}
d := a * b  // {2, 16, 72}
e := c != d // true
Enter fullscreen mode Exit fullscreen mode

Enum.map

Elixir

In Elixir we use Enum.map.

# [2, 3, 4]
Enum.map([1, 2, 3], fn x -> x + 1  end)
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we use slice.mapper.

// [2, 3, 4]
slice.mapper([]int{1, 2, 3}, proc(element : int) -> int {
  return element + 1
})
Enter fullscreen mode Exit fullscreen mode

Enum.filter

Elixir

In Elixir we use Enum.filter.

# [2]
Enum.filter([1, 2, 3], fn x -> rem(x, 2) == 0 end)
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we use slice.filter

slice.filter([]int{1, 2, 3}, proc(element : int) -> bool {
    return element % 2 == 0
})
Enter fullscreen mode Exit fullscreen mode

Enum.reduce

Elixir

In Elixir we use Enum.reduce

# 24
Enum.reduce([1, 2, 3, 4], fn x, acc -> x * acc end)
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we use slice.reduce

slice.reduce([]int{1, 2, 3, 4}, 1, proc(accumulator: int, element : int) -> int {
    return element * accumulator
})
Enter fullscreen mode Exit fullscreen mode

About Map, Reduce, Filter and other "functional" like procedures

Keep in mind that procedures like map, filter and reduce are super handy and work superb with functional languages such as Elixir. In procedural languages, and specially in system level languages such as Odin, they need to be picked like a grain of salt.

Consider the procedural approach when traversing an array. Maybe it would make more sense to the reader.

items := []string{"one", "two"}
for item in items {
    if item != "two" { continue }
}
Enter fullscreen mode Exit fullscreen mode

When it comes to real code, though, don't overcomplicate it. Odin doesn't play well with typical functional approaches to problems, nor does it intend to, so try not to lean too heavily on them. Also, as a point of design, if you feel friction, it's Odin telling you "No".

Let procedural languages be procedural languages. And let functional languages be functional languages. Stop trying to force incompatible ideas into the other paradigm.
--- GingerBill

String concatenation

Elixir

"My String" <> " is good"
Enter fullscreen mode Exit fullscreen mode

Output

My String is good
Enter fullscreen mode Exit fullscreen mode

Odin

import "core:strings"

// items := []string{"My String", " is good"}
// strings.concatenate(items)
strings.concatenate({"My String", " is good"}) 
// Odin can infer the argument type
Enter fullscreen mode Exit fullscreen mode

Output

My String is good
Enter fullscreen mode Exit fullscreen mode

String interpolation

Elixir

"Hello this is 4 + 2 = #{4 + 2}"
Enter fullscreen mode Exit fullscreen mode

Output

Hello this is 4 + 2 = 6
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we currently don't have string interpolation. But we can use the sprintf C style using fmt.tprintf procedure.

import "core:fmt"

fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2)
Enter fullscreen mode Exit fullscreen mode

Output

Hello this is 4 + 2 = 6
Enter fullscreen mode Exit fullscreen mode

Note

If you call fmt.tprintf without using the result, the compiler will be mad with Error: 'fmt.tprintf' requires that its results must be handled.

This means we must handle the result in someway.

// Do nothing with the result
_ = fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2)
Enter fullscreen mode Exit fullscreen mode
// Store it in a variable
result = fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2)
Enter fullscreen mode Exit fullscreen mode
// Wrap in a procedure that does not throws error
my_print :: proc() -> string {
  return fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2)
}
Enter fullscreen mode Exit fullscreen mode
// print the result string
fmt.println(fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2))
Enter fullscreen mode Exit fullscreen mode

So called interpolated strings are really just expressions in disguise, consisting of string concatenation of the various parts of the string content, alternating string literal fragments with interpolated subexpressions converted to string values.

The interpolated string

"Hello #{name}!"
Enter fullscreen mode Exit fullscreen mode

is equivalent to

concatenate(concatenate("Hello", to_string(name)) , "!")
Enter fullscreen mode Exit fullscreen mode

Function Overloading

Let's say you need a count procedure that you pass a list and returns "numbers" or "words" depending on the input type.

Elixir

This is the example code in Elixir to achieve this.

defmodule Counter do
  def count([first | _rest] = items) when is_number(first), do: "#{Enum.count(items)} numbers"
  def count(items), do: "#{Enum.count(items)} words"
end

Counter.count([1, 2, 3])
|> IO.inspect

Counter.count(["one", "two", "three"])
|> IO.inspect
Enter fullscreen mode Exit fullscreen mode

Output:

"3 numbers"
"3 words"
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we have Explicit Procedure Overloading. The design goals of Odin were explicitness and simplicity.

// odin run counter.odin -file
package Counter

import "core:fmt"

count_numbers :: proc(items: []int) -> string {
  return fmt.tprintf("%d numbers", len(items))
}

count_words :: proc(items: []string) -> string {
  return fmt.tprintf("%d words", len(items))
}

// Explicit Procedure Overloading
// Notice this is a procedure without parenthesis after `proc`
count :: proc {
  count_words,
  count_numbers
}

main :: proc() {
  numbers := []int{1, 2, 3}
  fmt.println(count(numbers))

  words := []string{"one", "two", "three"}
  fmt.println(count(words))
}
Enter fullscreen mode Exit fullscreen mode

Output:

"3 numbers"
"3 words"
Enter fullscreen mode Exit fullscreen mode

Explicit overloading has many advantages:

  • Explicitness of what is overloaded
  • Able to refer to the specific procedure if needed
  • Clear which scope the entity name belongs to
  • Ability to specialize parametric polymorphic procedures if necessary, which have the same parameter but different bounds (see where clauses, where clauses are similar to Elixir Guards)

Anonymous Functions

Elixir

In Elixir we can use Anonymous Functions, also called Lambdas or Fat Arrow functions in other programming languages.

add = fn a, b -> a + b end
add.(1, 2)
Enter fullscreen mode Exit fullscreen mode

Output

3
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we store a procedure pointer and pass it around. Odin can define procedures within procedures.

subtract :: proc(a: int, b: int) -> int {
  return a - b
}

main :: proc() {
  add := proc(a: int, b: int) -> int {
        return a + b
  }

  subtract_pointer := subtract

  subtract_pointer(6,add(1, 2))
}
Enter fullscreen mode Exit fullscreen mode

Output

3
Enter fullscreen mode Exit fullscreen mode

Named Arguments

Elixir

In Elixir we use Keyword lists to have named arguments

defmodule Hello do
  def get([name: name, shows: shows]) do
    IO.inspect shows, label: name
  end
end

Hello.get(name: "Camilo", shows: 2)
Enter fullscreen mode Exit fullscreen mode

Output

Camilo: 2
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin is supported named arguments.

hello := proc(name: string, shows: int) {
    fmt.printfln("%s: %d", name, shows)
}

hello(name = "Camilo", shows = 2)
Enter fullscreen mode Exit fullscreen mode

Output

Camilo: 2
Enter fullscreen mode Exit fullscreen mode

Parametric polymorphism

Elixir

In Elixir since you can pass any type as param (unless properly restricted and pattern matched), there is inherent parametric polymorphism.

Odin

Commonly referred to as “generics”, allow the user to create a procedure or data that can be written generically so it can handle values in the same manner.

https://odin-lang.org/docs/overview/#parametric-polymorphism

Let's do a little exercise trying to implement filter and map for slices. These are already implemented in the core at slice.filter and slice.mapper.

The following code is just an example of parametric polymorphism. A better implementation is available with slice.filter and slice.mapper.

The idea is to have a type definition with $ that can be used instead of a specific type like string. Normally is defined as $T, but can be named anything like $MyGenericType. Once is defined it can be used for any other param or declaration in the procedure scope. You can reuse a type in multiple places as a way of saying that they're the same.

filter

Filters the enumerable, i.e. returns only those elements for which proc returns a truthy value.

filter :: proc(enumerable: []$T, callback: proc(element: T) -> bool) -> [dynamic]T {
    results : [dynamic]T
    for item in enumerable {
        if (callback(item)) {
            append(&results, item)
        }
    }
    return results
}
Enter fullscreen mode Exit fullscreen mode

Notice how we can pass proc as parameters. It's the full declaration, but without its implementation.

callback: proc(element: T) -> bool
Enter fullscreen mode Exit fullscreen mode

Usage

string slice

items := []string{"one", "two"}

filtered := filter(items, proc (element: string) -> bool {
    return element == "two"
})
// ["two"]
fmt.println(filtered)
Enter fullscreen mode Exit fullscreen mode

int slice

filtered_int := filter([]int{1, 2, 3}, proc (element: int) -> bool {
        return element == 2
})

// [2]
fmt.println(filtered)
Enter fullscreen mode Exit fullscreen mode

map (mapper)

Returns a slice where each element is the result of invoking proc on each corresponding element of enumerable.

mapper :: proc(enumerable: []$T, callback: proc(element: T) -> T) -> [dynamic]T {
    results : [dynamic]T
    for item in enumerable {
        append(&results, callback(item))
    }
    return results
}
Enter fullscreen mode Exit fullscreen mode

Usage

string slice

items := []string{"one", "two"}

mapped := mapper(items, proc(element: string) -> string {
   if element == "one" {
      return "three"
   }

   return "four"
})

// ["three", "four"]
fmt.println(mapped)
Enter fullscreen mode Exit fullscreen mode

int slice

items := []int{1, 2, 3}

mapped_int := mapper(items, proc(element: int) -> int {
   if element == 1 {
      return 2
   }

   return 4
})

// [2, 4, 4]
fmt.println(mapped_int)
Enter fullscreen mode Exit fullscreen mode

Error Handling

Elixir

In Elixir we can handle errors by returning a tuple {:error, reason} and {:ok, result} if the result is ok. Also we can have exceptions (try, catch, rescue).

defmodule PositiveSum do
  def sum(a, b) when is_number(a) and is_number(b) do
     case (a + b) do
          result when result > 0 -> {:ok, result}
          result when result < 0 -> {:error, "Only positive results allowed"}
          _ -> {:error, "Zero is neither positive or negative"}
     end
  end
end

PositiveSum.sum(1, 2)
|> IO.inspect

PositiveSum.sum(1, -4)
|> IO.inspect

PositiveSum.sum(0, 0)
|> IO.inspect

try do
  PositiveSum.sum(1, "b")
rescue
  _error -> {:error,"Params are not numbers"}
end
|> IO.inspect
Enter fullscreen mode Exit fullscreen mode

Output

{:ok, 3}
{:error, "Only positive results allowed"}
{:error, "Zero is neither positive or negative"}
{:error, "Params are not numbers"}
Enter fullscreen mode Exit fullscreen mode

Odin

In Odin we can handle the errors using different strategies by returning multiple results from a procedure. Since Odin is a typed language, is a lot harder to send wrong typed params to a procedure. It won't compile.

Strategy 1: ok booleans

The first strategy is to return a bool at the last return parameter. This strategy will only give a ok or !ok status.

sum := proc(a : int, b : int) -> (result: int, ok: bool) {
    result = a + b
    if result > 0 {
        return result, true
    }

    if result < 0 {
        return result, false
    }
    return result, false
}

fmt.println(sum(1, 2)) // 3, true
fmt.println(sum(1, -4)) // -3, false
fmt.println(sum(0, 0)) // 0, false

fmt.println(sum(0, "b")) // won't compile
Enter fullscreen mode Exit fullscreen mode

But if we use the result, we would need to store it in several variables. Or we would get a compilation error similar to: Error: Assignment count mismatch '1' = '2'.

result := sum(1, -4) // Won' compile.
Enter fullscreen mode Exit fullscreen mode

This means we must store the success (ok) status somewhere.

// -3, false
result, ok := sum(1, -4)
if !ok {
  fmt.printfln("%d, %s", result, "There were problems in the Sum")
}
Enter fullscreen mode Exit fullscreen mode

We could ommit the variable using the special _ character (rune).

// -3, false
result, _ := sum(1, -4)
fmt.printfln("%d", result)
Enter fullscreen mode Exit fullscreen mode

We can add #optional_ok tag to the procedure declaration so we can omit the final boolean.

Important

#optional_ok requires exactly 2 return params. Only accepts the last param as a boolean.

sum := proc(a : int, b : int) -> (result: int, ok: bool) #optional_ok {...}
Enter fullscreen mode Exit fullscreen mode
// -3, false
result := sum(1, -4)
fmt.printfln("%d", result)
Enter fullscreen mode Exit fullscreen mode

Strategy 2: Error messages

We can return the message. However there is no way to tag the declaration to be optional the same way a boolean can.

sum :: proc(a : int, b : int) -> (result: int, err: string) {
    result = a + b
    if result > 0 {
        return result, ""
    }

    if result < 0 {
        return result, "Only positive results allowed"
    }

    return result, "Zero is neither positive or negative"
}

result, err := sum(5, -6)
if err != "" {
    fmt.printfln("%d, %s", result, err)
}
Enter fullscreen mode Exit fullscreen mode

Strategy 3: Int returns

This can be used when dealing with C libraries or system processes that returns a number to indicate status. We have to combine them with arrays so we can have an error message.

error_strings := [?]string{
  "ok", 
  "Only positive results allowed", 
  "Zero is neither positive or negative"
}

sum :: proc(a : int, b : int) -> (result: int, err: int) {
    result = a + b
    if result > 0 {
        return result, 0
    }

    if result < 0 {
        return result, 1
    }

    return result, 2
}

result, status := sum(5, -6)
fmt.printfln("%d, %s", result, error_strings[status])
Enter fullscreen mode Exit fullscreen mode

Strategy 4: Enum Errors

This strategy provides a little more standarization of error codes and messages, by using enum.

PositiveSumError :: enum {
    None,
    Negative_Result,
    Zero_Result,
}

positive_sum_error_message :: proc(err : PositiveSumError) -> (message: string) {
    switch err {
    case .None:
        message = ""
    case .Negative_Result:
        message = "Only positive results allowed"
    case .Zero_Result:
        message = "Zero is neither positive or negative"
    }
    return message
}

sum :: proc(a : int, b : int) -> (result: int, err: PositiveSumError) {
    result = a + b
    if result > 0 {
        return result, .None
    }

    if result < 0 {
        return result, .Negative_Result
    }

    return result, .Zero_Result
}

result, status := sum(5, -6)

fmt.printfln("%d, %v", result, positive_sum_error_message(status))
Enter fullscreen mode Exit fullscreen mode

Strategy 5: Struct Errors

In this strategy we use structs to save the message. Optionally we combine it with enums for easier comparison later. This is the most similar to Elixir.

PositiveSumErrorCode :: enum {
    None,
    Negative_Result,
    Zero_Result,
}

PositiveSumError :: struct {
    message: string,
    code : PositiveSumErrorCode,
}

sum :: proc(a : int, b : int) -> (result: int, err: PositiveSumError) {
    result = a + b
    if result > 0 {
        return result, PositiveSumError{code = .None}
    }

    if result < 0 {
        return result,  PositiveSumError{
            message = "Only positive results allowed",
            code = .Negative_Result,
        }
    }

    return result,  PositiveSumError{
        message = "Zero is neither positive or negative",
        code = .Zero_Result,
    }
}

result, err := sum(5, 6)
fmt.printfln("%d, %s", result, err.message)
Enter fullscreen mode Exit fullscreen mode

Final thoughts

It is OK to make stuff just for the sake of it, to explore how stuff works. Just be careful not to overcomplicate things.

Thanks to Odin forum members Barinzaya, Tetralux, Vicix, Jesse and GingerBill for the guidance and corrections.

Image from: https://commons.wikimedia.org/wiki/File:Celum_philosophorum_1527_Title_page_AQ8_(detail).jpg

Top comments (2)

Collapse
 
jesse profile image
jesse

Hey just a heads up, you have credited the wrong jesse in the “Thanks to…” section. It was not me who provided you guidance and corrections 😅

Collapse
 
clsource profile image
Camilo

A silly mistake indeed. But thanks anyway for reading :D