13. Parameters and compile-time programming

13.1. Compile-time execution of code

The programs we write are compiled by the compiler and then an executable file is created out of it. This executable file is later run on an operating system. So the code we write is executed when we run the program. So if we write a loop, then that loop gets executed when the program is run. Let’s imagine that we need to execute a function for which the inputs are well known already at the time of writing the code. Such a function can be executed at compile-time, and the results kept in the executable file. This means that when the program is run, it just takes the pre-calculated value, saving valuable CPU time during the execution.

Unlike many mainstream languages, Mojo allows execution of code at compile-time. Mojo even has very few restrictions on what kind of code can be executed at compile-time.

We already saw the use of alias to store a constant. When we assign a function’s value to an alias constant, Mojo executes that function at compile-time and assigns the result as a constant value to the alias.

For example see the following code listing.

fn add(a: Int, b: Int) -> Int: 
    return a + b
alias added = add(1, 2)

Usage:

    print(added)

13.2. Conditional execution at compile-time

We can conditionally call a function, or define a constant. That is, the if statement also can be used at compile time. Mojo allows us to pass an environment variable at compile time, which we can access and conditionally compile our code. To pass an environment variable to the compiler, you can use the option -D <var>=<val>. For example, mojo -D add_it=True filename.mojo.

fn add(a: Int, b: Int) -> Int: 
    return a + b
# Execute with "mojo -D add_it=True <filename>" for a non-zero value
alias added_conditionally = add(1, 2) if is_defined["add_it"]() else 0

Usage:

    print(added_conditionally)

13.3. Parameters in functions

We saw that we can use alias to execute functions at compile time. What if we do not want to execute the function at compile-time, but just "parameterize" the function at compile-time, so that when it is finally run during the program execution, it uses that passed-in parameters? Mojo provides a solution to that by providing capability for compile-time parameterization. To achieve this, we pass parameters within square brackets. For example: add[my_param: Int](). So in short, when we want to pass to a function values at runtime, we pass it within parenthesis, and if we want to pass to a function values at compile-time, we pass it within square brackets. Note that we are distinguishing between parameters and arguments. In Mojo parameters are compile-time values and arguments are runtime values.

fn add[cond: Bool](a: Int, b: Int) -> Int: 
    return a + b if cond else 0

Usage:

    # Execute with "mojo -D add_it=True <filename>" for a non-zero value
    print(add[is_defined["add_it"]()](3, 4))

In the previous example, we are passing a boolean value as a parameter to the add function. The value of the boolean is taken from a compile-time command line argument add_it using the Mojo option -D. The compiler will hardcode True in the parameter cond of the function add if the add_it was set (the value of the add_it is irrelevant here). Irrespective of where and how the compiled file is executed, the cond will have a constant value set at the compile-time.

In addition to values being passed as parameters to functions, we can also pass types themselves. If a concrete struct is expected in the parameter, then Mojo expects a value to be passed at the function call site. However, if a trait is expected in the parameter, then Mojo expects a type to be passed at the function call site.

In the previous example we saw that add expected a value of type Bool. In the next example, the add_ints expects a trait Intable as a parameter. That trait is then later used to declare the types of the function arguments. At the function call site, you can see that Int is being passed as parameter.

fn add_ints[IntType: Intable](a: IntType, b: IntType) -> Int: 
    return int(a) + int(b)

Usage:

    print(add_ints[Int](3, 4))

13.4. Keyword parameters

So far we have seen how we can pass parameters by position. Similar to the keyword arguments that we pass to functions, we can pass parameter values using the name of the parameter. The rules of the keyword parameters are the same as the keyword arguments for functions.

fn div_compile_time[a: Int, b: Int]() -> Float64: 
    return a / b

Usage:

    print(div_compile_time[b=3, a=4]())

The previous example shows that we can pass parameter values with the param names, in which case the order of the parameters is not relevant.

13.5. Inferred-only parameters

Mojo allows parameter types to depend on other parameter types. For example, suppose we have two structs, Scheme and Location. We could define Location as a struct that takes a Scheme value as its input parameter, as in Location[scheme: Scheme]. This means that Location depends on Scheme.

struct Scheme:

    alias HTTP = Scheme("http")
    alias FTP = Scheme("ftp")

    var scheme: String

    fn __init__(inout self, scheme: String):
        self.scheme = scheme

    fn __str__(self) -> String:
        return self.scheme

struct Location[scheme: Scheme]:
    
    var location: String

    fn __init__(inout self, location: String):
        self.location = location
    
    fn __str__(self) -> String:
        return str(scheme) + "://" + self.location

Suppose that we now define a function that uses Location. We now need to also declare Scheme parameter as otherwise the compiler does not know what the Location input parameter scheme means.

fn print_location[scheme: Scheme, location: Location[scheme]]():
    print(str(location))

This has an unfortunate impact on the ergonomics of the usage of the function, as now the caller has to specify both the Scheme and Location with again the same Scheme value. This is an unnecessary duplication.

    print_location[Scheme.FTP, Location[Scheme.FTP]("r.net")]()

Mojo provides a solution for this. Similar to declaration of positional-only and keyword-only function arguments, Mojo provides a syntax for "inferred-only" parameters using // as the delimiter. All the parameters that are expected to be inferred will appear before the // delimiter. Those parameters are not to be passed by the caller, instead they would be automatically inferred by the compiler based on their usage in the following parameters.

fn print_location2[scheme: Scheme, //, location: Location[scheme]]():
    print(str(location))

Usage:

    print_location2[Location[Scheme.FTP]("r.net")]()

Here we have to provide Scheme.FTP only once as the parameter scheme: Scheme will get automatically inferred.

It is also possible to have just inferred-only parameters, while the inference is happening within the function arguments.

fn print_location3[scheme: Scheme, //](location: Location[scheme]):
    print(str(location))

Usage:

    print_location3(Location[Scheme.FTP]("r.net"))

13.6. Variadic parameters

Sometimes we want to be able to pass any number of parameters, without being restricted to a particular number of parameters. When we prefix a parameter with *, Mojo allows us to pass any number of values to it.

The following is an example of positional variadic parameters:

fn add_all[*a: Int]() -> Int: 
    var result: Int = 0
    for i in VariadicList(a):
        result+= i
    return result

Usage:

    print(add_all[1, 2, 3]())

In the above example, we can see that at the function call site, we can pass any number of parameters since the function definition prefixes its parameter a with a *. The function definition then iterates over the a after wrapping it in a VariadicList and calculates the sum.

13.7. Default values in parameters

Mojo allows default values to be used for parameters.

fn sub[cond: Bool = False](a: Int, b: Int) -> Int: 
    return a + b if cond else 0

Usage:

    print(sub(3, 4)) # Default value is taken
    print(sub[True](3, 4)) # Override the default value

In the example, we have assigned a default value for parameter cond, which allow us to make the call to sub without passing any value to the parameter cond, effectively making the parameter cond an optional parameter. This makes for ergonomic APIs using sensible defaults wherever it is possible.

13.8. Parameters in structs, traits

Similar to functions, we can also pass compile-time parameters to structs, traits.

struct MyStruct[T: Intable, cond: Bool]:
    var value: Int

    fn __init__(inout self, value: T):
        self.value = int(value)

    fn get_value(self) -> Int:
        return self.value if cond else 0

Usage:

    print(MyStruct[Int, True](10).get_value())
    print(MyStruct[Float16, False](11.5).get_value())

We can also have keyword parameters for structs, and traits.

    print(MyStruct[cond=True, T=Float32](2.5).get_value())

In the previous example, the parameters were passed and processed similar to how we did in functions. Basically, what we can do with parameters for functions, we can do the same for structs, and traits.

13.9. Custom compile-time checks

When we develop a program, we often make assumptions about the arguments we receive or the context in which we execute a function and so on. We can use if statements to validate those assumptions, but it is possible that there is a performance cost to such validations. Many programming languages provide a facility known as assertion, to validate those assumptions with minimal impact to the runtime performance of the code.

Mojo goes one step further by providing compile-time assertions with the function constrained.

fn print_times[times: Int]():
    constrained[times > 0, "times must be greater than zero"]()
    for i in range(times):
        print(i)

fn main():
    print_times[2]()
    print_times[0]()

If you try to compile the code listed above, you would get a compile time error, with the message: times must be greater than zero.

This happens because in our function print_times we have an assertion using constrained function that checks that our compile-time parameter times is greater than zero. If we call the function with a value for times that is greater than zero, then the code compiles without any errors. However, if we pass a value that is less than or equal to zero, it will produce the same compile time error message as the one that is passed as the second parameter of the constrained function call.

The constrained function is quite useful to validate our assumptions about given parameters at compile-time. If during the compile-time the constrained function executes successfully, then that piece of validation code does not even have to appear in the final binary, resulting in zero performance impact at runtime.