Skip to main content

Implicits

Implicits are arguments that are automatically filled in by the compiler. Implicits are implemented using a pair of keywords:

  • provide puts an implicit value in the current context, and
  • using takes an implicit value from the current context.

Using and providing implicits

Implicit arguments to functions appear in (using ...) and are given to a function after the normal argument list.

import std/io
import std/str

type Emphasis {
Normal
Strong
}

def exclaim(message: String)(using emphasis: Emphasis) {
match emphasis {
Normal => io::prinln(message + "!")
Strong => io::prinln(str::upper(message) + "!!!")
}
}

When the exclaim function is called, such as exclaim("Welcome"), an error will be generated because the compiler cannot find a provision for Emphasis value in scope. This can be resolved by defining one with a provision using the provide keyword, to tell the compiler it is allowed to use this value for implicit resolution.

// The name given to this provision does not matter for
// automatic implicit resolution by the compiler; it is
// only for the programmer's reference.
provide emphasis: Emphasis = Normal

def main() {
assert_eq(exclaim("Welcome"), "Welcome!")
}

Provisions can appear in any scope and override previous provisions in the same scope and parent scopes.

provide Emphasis = Normal
def main() {
provide Emphasis = Strong
assert_eq(exclaim("Welcome"), "WELCOME!!!") // Uses Strong.
}

Provisions can themselves accept implicit arguments. In this case, the provision is treated like a function whose evaluation is delayed until it is resolved into an implicit argument list.

type A(Int)
provide a_value: A = A(2)

type B(Int)
provide b_value(using a: A): B = B(10 * a.0)

def use_b()(using b: B) -> Int {
b.0
}

// Here the compiler does the following:
// 1. Finds `b_value` as the implicit argument to insert into `use_b()`.
// 2. `b_value` needs a value of type `A`, and `a_value = A(2)` is found.
// 3. `b_value` is evaluated using `a_value` to get `B(a_value.0 * 10) = B(20)`.
// 4. The resulting `b_value` is inserted into `use_b()`.
assert_eq(use_b(), 20)

When there are only implicit arguments and no normal arguments to a function, the empty () for the normal argument list can be elided. This is true for both provide and let statements. However, the () must be provided to call the function (unless an implicit argument list is provided).

There are several forms of provisions, as shown in the following example. The most concise way is preferred. The type must always be specified, but the name is optional. If a name is not given, the compiler will decide on a sensible one. For example, an anonymous provision of Option[MyType] would be named provide_option_my_type. No attempts are made by the compiler to eliminate name conflicts.

// Values.
provide Emphasis = Strong
provide emphasis: Emphasis = Strong

// Provisions using implicit arguments.
provide (using a: A): B = B(10 * a.0)
provide b_value(using a: A): B = B(10 * a.0)

Functions with implicit arguments

Implicit arguments can be used by functions defined via let and fn.

def add_things(x: a, y: a)(using add: Add[a]) -> a { x + y }
fn(x: a, y: a)(using add: Add[a]) -> a { x + y }

Names of implicit arguments can be omitted when they are not directly used.

def add_many(x: a, y: z, z: a)(using Add[a]) -> a { x + y + z }
assert_eq(add_many(1, 2, 3), 6)

Implicit arguments can be passed explicitly. Resolution of implicit arguments is type-directed; thus, the order does not matter.

type Data[a](a)
def read_data(using data: Data[a]) -> a {
data.0
}
assert_eq(read_data()(using Data(123)), 123)

def square_data(using mul: Mul[a], data: Data[a]) -> a {
data.0 * data.0
}
// Specify the data and let the compiler select the multiplication stategy.
assert_eq(square_data()(using Data(4)), 16)

When calling a function with implicit arguments that has no normal arguments, the empty () can be elided as it was in its declaration.

def read_data(using data: Data) -> a {
data.0
}
assert_eq(read_data(using Data(123)), 123) // Works!

A higher order function (i.e., a function passed as an argument to another function) can also use implicit arguments. In this case, the context comes from where the function is applied, not where it is passed.

type Context(String)

def apply_with_context(f: fn(using Context)) {
provide Context = Context("Hello")
f() // The context at this point is used.
}

def use_context(using context: Context) {
assert_eq(context.0, "Hello")
}

provide Context = Context("Welcome")
apply_with_context(use_context)

Note that named provisions cannot be used in the same way as regular bindings. For example, let x = 5 creates a binding to x that can be used anywhere it is in scope. On the contrary, provide y: Int = 5 cannot be used anywhere, and the binding y can only appear as an argument in a using list.

To get the value of a provision in a given context, it can be accessed via the provision() function. This function does not do anything magical, and in fact, is defined exactly as below.

def provision(using x: a) -> a {
x
}

The provision() function can then be used to pull a value from context.

import std/io
import std/string

type Emphasis {
Normal
Strong
}

def exclaim(message: String)(using Emphasis) {
// Note that the Emphasis argument in the using list is anonymous,
// so the provision function pulls its value to match on.
match provision() {
Normal => io::prinln(message + "!")
Strong => io::prinln(str::upper(message) + "!!!")
}
}

Provision search locations

Implicit arguments are resolved by finding the nearest and then the most specific value. The compiler searches in order:

  1. Local scope.
  2. Parent scope, then grandparent scope, and so on.
  3. Module scope.
  4. Provisions imported by name.
  5. Provisions imported by type.
  6. Provisions imported by using *.
  7. Implicit prelude inserted by the compiler.

If the currently searched scope is function-level, then all implicit arguments to that function are included in the search. In all scopes except the module-level scope, the most tightly scoped provisions take precedence (i.e., provisions overwrite earlier ones in the case of conflict).

The search stops as soon as at least one compatible implicit is found in a level. If exactly one is found, it is used. If two or more are found, the most specific one is used (e.g., Add[Int] is more specific than Add[a]). If there is a conflict where the leading candidates are of equal specificity, a compile-time error is issued.