Functions

Phonometrica’s scripting language is an object-oriented programming language. Unlike most other object-oriented programming languages, however, Phonometrica is based on the notion of multiple dispatch: when a function is called and there are several functions with the same name, Phonometrica will decide which function to call based on the type of its arguments. This page explains what multiple dispatch is and how to use functions efficiently in Phonometrica.

Basics

A Function is a special construct that represents a reusable block of code. Functions are created using the keyword function. Here is an example of a function that prints the area of a rectangle. It has two parameters (x and y), which correspond to the rectangle’s height and width.

function area(x, y)
    print "The area of the rectangle is ",  x * y
end

We can then call the function with specific values (called arguments) for x and y, using parentheses after the name of the function and passing the arguments to the function by putting them inside the parentheses:

area(100, 30) # prints 3000

In addition to executing statements, functions can also send a value back to the caller. This is achieved with the keyword return followed by the expression we want to send back to the caller. Let’s rewrite the above code in a slightly different way:

function area(x, y)
    return x * y
end

a = area(100, 30)
print "The area of the rectangle is ", a

In this new example, the function area is only responsible for computing the area and returning the value. All the printing is done outside the function.

Note: Functions are first class values in Phonometrica, which means that they can be assigned to variables, passed as function arguments to other functions, and even used as a return value inside a function.

Function parameters

Our function area takes 2 parameters, x and y, which we expect to be numbers. But what happens if we inadvertantly pass a value that has a different type?

function area(x, y)
    return x * y
end

let a = area(100, "30")

Phonometrica will throw an error because it can’t apply the math operator * to a number and a string. But we might not always be that lucky, and we might accidently introduce subtle and hard-to-find bugs if we pass the wrong type of argument and Phonometrica proceeds with it without detecting that there is a problem.

Fortunately, Phonometrica allows us to minimize this kind of problem by specifying a type for each parameter. If we don’t specify a type for a parameter, Phonometrica will implicitly assign it the type Object, which is the base type for all Phonometrica types. Our function could have equivalently been written as follows:

function area(x as Object, y as Object)
    return x * y
end

The two forms are strictly equivalent: the former is shorter to type, the latter is more explicit. When the type of a parameter is Object, any value can be passed because all types inherit from Object, directly or indirectly. To make our code more robust, we could limit the types of the parameters to numbers. This is done as follows:

function area(x as Number, y as Number)
    return x * y
end

If we now try to call the function with a number and a string:

a = area(100, "30")

Phonometrica will not even try to execute the function; it will give us a clear error message:

Line 5: Cannot resolve call to function 'area' with the following argument types: (Integer, String).
Candidates are:
area(Number, Number)

Type information is optional: if a parameter can accept any value, you can simply omit the type (or declare the type as Object to make your intent clearer). Omitting type information can also save you some typing for small scripts. For scripts that you intend to redistribute, however, we strongly encourage you to add type information because it will make your code more robust and will clarify the intended use of your functions.

Function overloading

Suppose that we want to create a function to concatenate two values. We want it to work with either two strings or two lists. One approach would be to create a function that accepts two objects, and then decides what to do depending on the type of the objects:

function concat(x, y)
    if type(x) == String and type(y) == String then
        return x & y
    elsif type(x) == List and type(y) == List then
        let result = []
        foreach v in x do
            append(result, v)
        end
        foreach v in y do
            append(result, v)
        end

        return result
    else
        throw "Invalid types in concat()"
    end
end

This approach works, but it is really tedious. Phonometrica offers a cleaner and more robust alternative: function overloading. Phonometrica lets you create functions with the same name, in the same scope, as long as they have a different number of parameters and/or the type of the parameters are different. We can thus rewrite our big function as two smaller functions:

function concat(x as String, y as String)
    return x & y
end

function concat(x as List, y as List)
    let result = []
    foreach v in x do
        append(result, v)
    end
    foreach v in y do
        append(result, v)
    end

    return result
end

We no longer need to take care of the error case ourselves, because Phonometrica will do it automatically for us. For example, if we try to call concat with two integers:

concat(3, 5)

We will get the following error:

Line 17: Cannot resolve call to function 'concat' with the following argument types: (Integer, Integer).
Candidates are:
concat(String, String)
concat(List, List)

To find out which function it should call, Phonometrica considers all functions that have the same name and number of pareameters as in the function call, and calculates for each of them a cost based on the type of the corresponding argument in the call. The cost of a function is calculated as the sum of the inheritance distances between each argument’s type and the expected parameter type. For instance, if a function’s parameter’s type is Object and the function is called with a Float as an argument, the cost will be 2 because Float inherits from Number, which inherits from Object. If an argument doesn’t inherit from the corresponding function parameter, the function is discarded as a potential candidate. The function that is called is the one with the lowest cost.

There are two special cases to be aware of. First, when an argument is null, Phonometrica will assign a cost of 0 for this argument no matter what the type of the parameter is. This makes it possible to pass null values to functions to signal that the value is invalid. Secondly, it is sometimes the case that two or more functions are equally good candidates. Consider the following example:

function test(x as Integer, y as Number)
    pass
end

function test(x as Number, y as Integer)
    pass
end

test(1, 2)

This chunk of code produces the following error, because the call is ambiguous:

Line 9: [Runtime error] Cannot resolve ambiguity in call to function 'test' with the following argument types: (Integer, Integer).
Candidates are:
test(Integer, Number)
test(Number, Integer)

To understand why this call is ambiguous, let’s calculate the cost for each overload. In the first function, we pass an Integer as the first argument and expect an Integer, so the cost for x is 0, and the second argument is an Integer and we expect a Number, so the cost is 1. The cost for this function is therfore 0 + 1 = 1. Following the same reasoning, the cost for the second overload would be 1 + 0 = 1. Since both functions have the same cost and there is no function with a lower cost, Phonometrica throws an error. To solve this problem, we would need to either modify one of the overloads, or add a new one that is more specific. Here, we could simply add a third overload:

function test(x as Integer, y as Integer)
    print "Now this works!"
end

Value and reference parameters

Clonable types in Phonometrica have value semantics, which means that assigning a variable to another one copies its value. Value semantics extends to function parameters: by default, function parameters are passed by value. Suppose we want to create a function that appends an element to a list but ensures that the element is not null. We could write it like that:

function append_item(list as List, item as Object)
    if item == null then
        throw "Cannot append a null item"
    end
    append(list, item)
end

However, if we try to use it, it will not work as expected:

lst = [1, 2, 3, 4]
append_item(lst, 5)
print lst # prints [1, 2, 3, 4]

Since the List type has value semantics, a copy of lst will be passed to append_item, and this copy (list) will be modified but the original value will be unaffected. For our function to be able to work as intended, we need the first argument to be passed by reference. This is achieved by adding the keyword ref before the corresponding parameter:

function append_item(ref list as List, item as Object)
    if item == null then
        throw "Cannot append a null item"
    end
    append(list, item)
end

let lst = [1, 2, 3, 4]
append_item(lst, 5)
print lst # prints [1, 2, 3, 4, 5]

Closures

Functions can be defined inside other functions. Such nested functions have access to their enclosing scope(s): as a result, they can capture variables in their environment (non-local variables) and keep a reference to them, even if they go out of scope. Such functions are called closures. Consider the following example:

function make_counter()
    let x = 0
    function inner()
        x += 1
        return x
    end

    return inner
end

counter1 = make_counter()
counter2 = make_counter()
print counter1() # prints 1
print counter1() # prints 2
print counter1() # prints 3
print counter2() # prints 1

Let’s go through the above code chunk to understand what it does. When we create counter1, we execute the function make_counter, which first creates a variable named x and then creates a function named inner, which captures make_counter’s local variable x. Finally, make_counter returns the function inner. This means that counter1 is now a function (the function inner). When we initialize counter2, we call make_counter again: it will create a new variable named x and a new function named inner, which it will return. As a result, counter1 and counter2 each have their own “version” of inner and x. Each time a counter is called, it will call its own version of inner, which will increment its own version of x. Functions which can capture non-local variables, such as inner in this example, are called closures.

Closures are a powerful construct that allows us to create stateful functions, that is functions that can retain state across calls. In the above example, the state is the counter represented by the variable x. In the above example, a closure was used to create a generator, i.e. a function that generates a new value every time it is called, depending on its internal state. Here is another example of a closure which generates the next number in the Fibonacci sequence every time it is called.

function fibonacci()
    let first = 0
    let second = 0

    function fib()
        if first == 0 then
            first = 1
            second = 1
            return 0
        else
            let current = first
            let tmp = second
            second = first + second
            first = tmp

            return current
        end
    end

    return fib
end

let f = fibonacci()

for i = 1 to 10 do
    print f()
end

Function expressions

Another way to use functions is to create a function expression. Function expressions are anonymous functions which can be used like any other expression. As an example, the following function:

function area(x as Number, y as Number)
    return x * y
end

could be written equivalently as:

area = function(x as Number, y as Number)
    return x * y
end

The advantage of function expressions is that you can use them wherever you can use an expression, for instance as the return value of another function:

function make_counter(start as Integer)
    return function()
        let n = start
        start += 1
        return n
    end
end

counter = make_counter(10)
print counter() # prints 10
print counter() # prints 11

As you can see, in this example, we create a closure that captures the non-local variable start, but this closure is an anonymous function expression, which we can return directly.

Note: creating a named function and assigning a function expression to a variable are strictly equivalent in the top-level scope, but they are slightly different when they are created in an embedded scope: variables are always global, unless they are declared with the keyword let, whereas functions are local to the scope, whether they are declared as local or not. Consider the following example:

# This function is global
function outer1()
    print "I'm a global function"

    # This function is local
    function inner1()
        print "I'm a local function only visible in outer1"
    end

    inner1()
end

# This function is local to the top-level scope (it won't be visible after the script has run)
local function outer2()
    print "I'm a local function in the top level scope()"

    # The keyword 'local' is unnecessary, the behaviour is the same as inner1
    local function inner2()
        print "I'm a local function only visible in outer2"
    end
end

Implicit return values

As we saw above, we can explicitly return a value from a function using the keyword return. In addition, there are two scenarios in which Phonometrica will implicitly return a value. First, all functions that don’t explicitly return a value implicitly return the value null. For instance, the following piece of code is valid:

function do_nothing()
    pass
end

x = do_nothing()
assert x == null

Secondly, if you compute an expression and don’t assign its result, Phonometrica will use it as the function’s return value. (If you compute several expressions in the function, Phonometrica will use the result of the last one.) The two functions below are equivalent:

function test1()
    return 3
end

function test2()
    3
end

assert test1() == test2()

Implicit return values can be used in function expressions: this offers a compact way to create short anonymous functions:

function modify(ref strings as List, f as Function)
    foreach i, ref s in strings do
        s = f(s)
    end
end

names = ["toto", "tata", "titi"]
modify(names, function(x) x & ".txt" end)
print names # prints ["toto.txt", "tata.txt", "titi.txt"]