Procedure Overloading and Runic Magic

Let’s say you need a count procedure that you pass an array and returns “numbers” or “words” depending on the input type.

Input

count({1, 2, 3})
count({"one", "two", "three"})

Output:

"3 numbers"
"3 words"

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))
}

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)

String concatenation

Let’s use procedure overloading and reimplement strings.concatenate

import "core:strings"

// My String is good
strings.concatenate({"My String", " is good"}) 

The declaration of strings.concatenate is the following:

concatenate :: proc(a: []string, allocator := context.allocator, loc := #caller_location) -> (res: string, err: mem.Allocator_Error) #optional_allocator_error {…}

The declaration of fmt.tprintf is the following:

tprintf :: proc(fmt: string, args: ..any, newline := false) -> string{...}

As you can see, the main problem with strings.concatenate is that requries an array of strings as the first param. We can’t use variadic arguments like fmt.tprintf because it would require to use named arguments to pass the allocator, which will be odd.

We can see this problem in fmt.aprintf which uses variadic arguments and an allocator param.

fmt.aprintf("%s%s", strings.to_string(buf), text, allocator = allocator)

Can we have a strings.concatenate procedure that accepts variadic params and allocators without passing an array and named params?.

String.concatenate("My String", " is good", context.allocator)

Yes we can, but it would require Procedure Overloading and a bit of “Runic Magic”.

Runic Magic

I will call this technique as “Runic Magic” (Maybe it has another official name in other contexts or languages, please comment).

The main idea is that we can create different procedures depending on the number of params, is evident that these cannot be “infinite”, a limit must be put in place. I think 16 params are enough for most uses cases (A 16 param procedure maybe doing too much), for more amount is best to just use strings.concatenate way of implementation or variadic args + named params as fmt.aprintf did.

import "core:strings"

concatenate_2_strings :: proc(a: string, b: string, allocator := context.temp_allocator, loc := #caller_location) -> (res: string, err: mem.Allocator_Error) #optional_allocator_error {
    return strings.concatenate({a, b}, allocator, loc)
}
concatenate_3_strings :: proc(a: string, b: string, c: string, allocator := context.temp_allocator, loc := #caller_location) -> (res: string, err: mem.Allocator_Error) #optional_allocator_error {
    return strings.concatenate({a, b, c}, allocator, loc)
}

concatenate_4_strings :: proc(a: string, b: string, c: string, d: string, allocator := context.temp_allocator, loc := #caller_location) -> (res: string, err: mem.Allocator_Error) #optional_allocator_error {
    return strings.concatenate({a, b, c, d}, allocator, loc)
}

concatenate_5_strings :: proc(a: string, b: string, c: string, d: string, e: string, allocator := context.temp_allocator, loc := #caller_location) -> (res: string, err: mem.Allocator_Error) #optional_allocator_error {
    return strings.concatenate({a, b, c, d, e}, allocator, loc)
}

concatenate_default :: proc(a: []string, allocator := context.temp_allocator, loc := #caller_location) -> (res: string, err: mem.Allocator_Error) #optional_allocator_error {
    return strings.concatenate(a, allocator, loc)
}

// concatenate_n_strings :: proc()
// ...
// concatenate_16_strings :: proc()

concatenate :: proc {
    concatenate_2_strings,
    concatenate_3_strings,
    concatenate_4_strings,
    concatenate_5_strings,
    // ...
    // concatenate_16_strings,
    concatenate_default,
}

// This is a good string
fmt.println(concatenate("This", " is ", "a", " good ", "string"))

Why?

Runic Magic enables another way of accepting params in procedures. That can be used for more consistent apis. For example we have fmt.aprintf that accepts var args and named arg context and strings.concatenate that accepts an array and context. With this technique both can be used in the same way.

Consistency is key to achieve robust systems, and contribute the glorious goal of “joy of programming” and the design goals of Odin of explicitness and simplicity.

3 Likes