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
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
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
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"
We can make an alias
by using another name before the route.
import Cat "../cat"
Cat.meow()
Cat.sleep()
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
If we would like to import it we need an alias
.
import mylib "0.4"
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 likefmt
orstrings
.
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=.
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
@(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
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
}
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
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
}
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,
}
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
}
So how we can make a map that takes both int
s and string
s 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,
}
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)
In memory, Unions are just a special variant of Structs.
struct {
payload: [size_of(Largest_Variant_Type)]byte,
tag: byte,
}
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]
Odin
In Odin we have to be more strict with the types.
Array of strings
[]string{"1", "2"}
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"}
Array of numbers
[]int{1, 2}
List of items
[]any{"1", 2}
Fixed size list of items
[2]any{"1", 2}
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}
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
Enum.map
Elixir
In Elixir we use Enum.map
.
# [2, 3, 4]
Enum.map([1, 2, 3], fn x -> x + 1 end)
Odin
In Odin we use slice.mapper
.
// [2, 3, 4]
slice.mapper([]int{1, 2, 3}, proc(element : int) -> int {
return element + 1
})
Enum.filter
Elixir
In Elixir we use Enum.filter
.
# [2]
Enum.filter([1, 2, 3], fn x -> rem(x, 2) == 0 end)
Odin
In Odin we use slice.filter
slice.filter([]int{1, 2, 3}, proc(element : int) -> bool {
return element % 2 == 0
})
Enum.reduce
Elixir
In Elixir we use Enum.reduce
# 24
Enum.reduce([1, 2, 3, 4], fn x, acc -> x * acc end)
Odin
In Odin we use slice.reduce
slice.reduce([]int{1, 2, 3, 4}, 1, proc(accumulator: int, element : int) -> int {
return element * accumulator
})
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 }
}
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"
Output
My String is good
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
Output
My String is good
String interpolation
Elixir
"Hello this is 4 + 2 = #{4 + 2}"
Output
Hello this is 4 + 2 = 6
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)
Output
Hello this is 4 + 2 = 6
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)
// Store it in a variable
result = fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2)
// Wrap in a procedure that does not throws error
my_print :: proc() -> string {
return fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2)
}
// print the result string
fmt.println(fmt.tprintf("Hello this is 4 + 2 = %d", 4 + 2))
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}!"
is equivalent to
concatenate(concatenate("Hello", to_string(name)) , "!")
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
Output:
"3 numbers"
"3 words"
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))
}
Output:
"3 numbers"
"3 words"
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)
Output
3
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))
}
Output
3
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)
Output
Camilo: 2
Odin
In Odin is supported named arguments.
hello := proc(name: string, shows: int) {
fmt.printfln("%s: %d", name, shows)
}
hello(name = "Camilo", shows = 2)
Output
Camilo: 2
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
}
Notice how we can pass proc
as parameters. It's the full declaration, but without its implementation.
callback: proc(element: T) -> bool
Usage
string slice
items := []string{"one", "two"}
filtered := filter(items, proc (element: string) -> bool {
return element == "two"
})
// ["two"]
fmt.println(filtered)
int slice
filtered_int := filter([]int{1, 2, 3}, proc (element: int) -> bool {
return element == 2
})
// [2]
fmt.println(filtered)
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
}
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)
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)
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
Output
{:ok, 3}
{:error, "Only positive results allowed"}
{:error, "Zero is neither positive or negative"}
{:error, "Params are not numbers"}
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
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.
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")
}
We could ommit the variable using the special _
character (rune).
// -3, false
result, _ := sum(1, -4)
fmt.printfln("%d", result)
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 {...}
// -3, false
result := sum(1, -4)
fmt.printfln("%d", result)
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)
}
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])
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))
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)
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)
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 😅
A silly mistake indeed. But thanks anyway for reading :D