Skip to content

Function signature

Masahiro Sakuta edited this page Jul 10, 2024 · 5 revisions

The function signature has a dedicated syntax that every language has slightly different opinion about it.

We want to define the specification before implementing the details.

Typed parameters

Currently we allow "untyped" parameters, but eventually we will require type declaration, because my observation is that type safety tend to crumble as soon as you allow any type in the function interface.

fn f(i: f64) -> f64 { ... }

Default argument

Similar to Python or C++, you can define a default parameter as part of the function signature. The default value shall be a constant expression; it cannot have a reference to a global variable, for example.

fn g(required: i32, optional: i32 = 123) { ... }

You can define a function like this:

fn add(a: i32 = 1, b: i32 = 2) -> i32 {
    a + b;
}

and you can call this function without explicit arguments:

print(add());

and it should print 3.

You can also partially provide an argument:

print(add(1000));

which should print 1002.

Evaluation timing

There are two religious factions about the timing of the default argument evaluation. C++ has the semantics that the default expression is evaluated at calling time, i.e. it is equivalent to inserting the expression on the calling site.

int c = 1;

int add(int a = c * 2, int b = c * 3) {
    return a + b;
}

c = 10;

std::cout << add(); // 50

On the other hand, Python evaluates the expression at definition time, which tends to cause surprising behavior to many people.

c = 1

def add(a = c * 2, b = c * 3):
    return a + b

c = 10

print(add()) # 5

In particular, if you have a mutable object as the default argument value, the reference will be kept among invocations.

def append(v, l = []):
    l.append(v)
    print(l)

append(1) # [1]
append(2) # [1, 2]

I suspect this behavior is decided for performance reasons, to prevent many repeated evaluations of the same value, in case the default argument expression contains some expensive logic.

def some_logic(value = some_complicated_computation()):
    do_some_logic(value)

However, it seems stupid enough that the same programmer will write a lot of other problematic code. The behavior of shared object will also likely to get the programmer into a pitfall.

In our language, we avoid the implication of the evaluation timing by only allowing constant expression in the default argument.

Named argument in function call

One thing that is nice about Python is that it can specify the argument by name, so that you won't mess up with the order of the parameters. However, I don't like param=value syntax, because = implies assignment.

g(optional: 42, required: 34);

It can be mixed with named and unnamed arguments, as long as the declared parameters are covered.

fn foo(a: i32, b: i32, c: i32) {
    a + b * 10. + c * 100.
}

print(foo(3., c: 1., b: 2.));

However, if the invocation is missing an argument and it is not declared with a default argument, it will be compile error.

fn foo(a: i32, b: i32, c: i32) {
    a + b * 10. + c * 100.
}

foo(a: 3., b: 2.);

A very nice thing about this feature is that the compiler can find out the order of arguments at compile time, so it can produce bytecode with name and order resolved. It has no overhead at runtime. Also, it can catch errors such as unmatched names at compile time. It reinforces the type safety. The compiler will produce an error below with the code above.

Error in compile(): "Named arguments does not cover all required args"

Variable number of arguments

Do we need it? If we support the list type, the user can construct a list literal and pass it to the function.

fn h(fixed: i32, variable: list) {}

h(42, list[36, "Some text", 1e5]);