Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Functions

A function is a named block of code that takes parameters and can return a value. Functions declared at the top level of a file are free functions; functions declared inside a struct are methods, covered in Structs.

Declaring a function

Write the name, the parameters in parentheses as name: Type, and the return type after a colon:

area(width: int, height: int): int {
    return width * height;
}

Pepper also accepts a C-style form, with the return type first and each parameter’s type before its name:

int perimeter(int width, int height) {
    return 2 * (width + height);
}

Both forms declare the same kind of function; pick one style and use it consistently. The first form is the one used throughout this book.

# Declaring and calling functions.

import async;

# Parameters are written 'name: Type'; the return type follows the ')'.
area(width: int, height: int): int {
    return width * height;
}

# The C-style form puts the return type first and the type before each name.
int perimeter(int width, int height) {
    return 2 * (width + height);
}

# A function that returns nothing leaves out the return type
# (or writes ': void', or 'void' in front).
# A module-scope counter: const, and changed through an Atomic.
const log_count = async.Atomic<int>(0);
log(message: String) {
    log_count.fetch_add(1);
    print("log: " + message);
}

# Functions can call themselves, and can be declared in any order.
factorial(n: int): long {
    if (n <= 1) { return 1; }
    return n * factorial(n - 1);
}

main(): int {
    assert(area(3, 4) == 12, "3 x 4");
    assert(perimeter(3, 4) == 14, "2 x (3 + 4)");
    assert(factorial(20) == 2432902008176640000, "20!");
    assert(is_even(10), "declared after main, still callable");
    # An argument converts to its parameter's type as assignment does:
    # a double passed to an int parameter truncates toward zero.
    assert(area(2.9, 3) == 6, "2.9 becomes 2");

    log("starting");
    log("done");
    assert(log_count.load() == 2, "logged twice");
    return 0;
}

is_even(n: int): bool {
    return n % 2 == 0;
}
  • Order does not matter. A function can call functions declared later in the file, and can call itself.

  • The program starts at main. main(): int returns the process’s exit code; return 0 for success. To receive the command-line arguments, declare it main(args: String[]): int instead. See Command-line arguments and exit.

  • Calls must match the declaration. Passing the wrong number of arguments is a compile error:

    main.pep:3:12: error: too many arguments to 'add': expected 2, have 3
        3 |     return add(1, 2, 3);
          |            ^~~
    

Functions that return nothing

A function that returns no value leaves out the return type. You can also write : void, or void in front in the C-style form:

log(message: String) { print(message); }
log_line(message: String): void { print(message); }
void log_c(String message) { print(message); }

Such a function ends when it reaches the closing brace, or earlier at a bare return;:

log_positive(n: int) {
    if n <= 0 {
        return;
    }
    print($"{n}");
}

A call to such a function has no value, so it cannot be stored, passed, returned, or used as an operand or condition: var x = log("hi"); reports cannot use a value of type 'void' as variable 'x'.

In a function that returns a value, return; is an error:

main.pep:2:5: error: non-void function 'f' should return a value
    2 |     return;
      |     ^~~~~~

Inferred return types

A function without a return type gets one from its return statements. It returns void when no return has a value, and otherwise the type of the first return that has one, so half(x: double) { return x / 2; } returns a double. A generic function’s return type is inferred for each specialization, so first<T>(a: T, b: T) { return a; } returns an int for first<int>. main with no return type returns an int, and reaching its closing brace exits with 0.

Writing the return type is still clearer for a function that returns a value, and it lets the compiler check each return against it.

Parameters

Parameters are passed by value. For numbers and bool, the function receives its own copy. For a String, an Array or any other struct, the value is a reference, so the function receives another reference to the same object: it can change the object’s contents, and the caller sees the change. Assigning a new object to the parameter does not affect the caller’s variable. Memory and reference counting describes exactly how references are counted when you pass them.

Arguments convert to the parameter’s type the same way assignment does, so you can pass an int where a long or double is expected, a double where an int is expected (it truncates toward zero, like int n = 2.7;), and a String where a struct with an [Implicit] constructor from String (such as io.Path) is expected.

Default parameter values

A parameter can have a default value, written = value after its type. A call that leaves the argument out gets the default. Put parameters with defaults at the end of the list, since arguments are matched from the left.

# Default parameter values.

# Parameters with defaults come last; a call may leave them out.
greet(name: String, greeting: String = "Hello", punctuation: String = "!"): String {
    return greeting + ", " + name + punctuation;
}

# Defaults work with the C-style form too.
int clamp(int value, int low = 0, int high = 100) {
    if (value < low) { return low; }
    if (value > high) { return high; }
    return value;
}

main(): int {
    assert(greet("Ada") == "Hello, Ada!", "both defaults");
    assert(greet("Ada", "Hi") == "Hi, Ada!", "one default");
    assert(greet("Ada", "Hi", ".") == "Hi, Ada.", "no defaults");

    assert(clamp(150) == 100, "clamped to the default high");
    assert(clamp(-5) == 0, "clamped to the default low");
    assert(clamp(-5, -10) == -5, "low given");
    return 0;
}

Defaults work the same way for methods and constructors. To give a later parameter a value without passing every parameter before it, name it (see Named arguments).

Named arguments

A call can name its arguments, name = value, so it reads clearly and can set one defaulted parameter without passing the ones before it:

# Named arguments: `name = value` after the positional arguments.
window(title: String, width: int = 800, height: int = 600, resizable: bool = true): String {
    return $"{title} {width}x{height} resizable={resizable}";
}

struct Point {
    var x: int;
    var y: int;

    construct(this.x = 0, this.y = 0) {}

    moved(dx: int = 0, dy: int = 0): Point {
        return Point(x + dx, y + dy);
    }
}

# 'size = ...' works as well as 'count = ...'; 'n = ...' still works, with a
# deprecation warning.
[Alias("size", "count")]
[Rename("n", "count")]
repeat(text: String, count: int = 1): String {
    var result = "";
    for (var i in 0..count) {
        result = result + text;
    }
    return result;
}

main(): int {
    assert(window("Editor") == "Editor 800x600 resizable=true", "all defaults");
    # Skip width and height.
    assert(window("Editor", resizable = false) == "Editor 800x600 resizable=false", "skip");
    # Named arguments in any order.
    assert(window("Editor", height = 400, width = 1200) == "Editor 1200x400 resizable=true", "any order");
    # Every argument may be named.
    assert(window(title = "Editor", width = 1024) == "Editor 1024x600 resizable=true", "all named");

    # Constructors and methods take them too.
    var p = Point(y = 5);
    assert(p.moved(dy = 2).y == 7, "method");

    assert(repeat("ab", size = 3) == "ababab", "alias");
    return 0;
}
  • Positional first. Positional arguments come first and match parameters from the left; named arguments follow, in any order. A positional argument after a named one is an error: positional argument cannot follow a named argument.
  • Each parameter once. Naming a parameter that already has an argument is an error: argument for 'width' given twice.
  • Every required parameter. A parameter without a default must get an argument: missing argument for parameter 'title' in call to 'window'. A name the function does not have is an error, with a suggestion when one is close: no parameter named 'widht' in 'window'; did you mean 'width'?.
  • Order of evaluation. Arguments are evaluated in the order written, then passed to their parameters.
  • Everywhere a call is. Free functions, methods, static methods, constructors (a this.x parameter is named x), extension methods, enum methods, generic functions, and calls through a trait value, which use the trait’s parameter names. A method that implements a trait should use the trait’s names, so a call names the same arguments either way: a different name is a warning (parameter 'factor' of 'area' is named 'scale' in trait 'Shape') whose fix renames the parameter.
  • Overloads. A member of an overload set without a parameter of a given name is not a candidate, so names can pick between overloads.
  • Not through function values. A function type carries no parameter names, so a call through a function value is positional only: cannot name arguments in a call through a function value of type '(int, int) => int'.
  • Not to extern functions. Their parameter names are copied from C headers and stay free to change: cannot name arguments in a call to extern function 'CreateFileW'. Wrap the C function in a Pepper function to give it named arguments.

Other names for a parameter

Two attributes on a function give its parameters more names. In both, the declared parameter’s name comes second:

  • [Alias("size", "count")] lets a call name parameter count as size too, permanently.
  • [Rename("n", "count")] keeps the former name n working after the parameter was renamed to count. Each use warns, like a deprecated declaration, with a fix that renames it.

The first name must not already be a parameter or another name, and the second must be a parameter of the function. A call that names one parameter by two of its names gets argument for 'count' given twice.

Returning several values

A function returns several values as a tuple, and the caller destructures it:

divide(a: int, b: int): (int, int) {
    return (a / b, a % b);
}

var (quotient, remainder) = divide(17, 5);

Overloading

Functions can share a name when their parameter lists differ, in one file, across a module’s files, or across the modules a file imports, and each call picks the best fit for its arguments. See Overloading.

Generic functions

A function can take type parameters in angle brackets after its name, such as first<T>(items: T[]): T. Generics covers them.

Printing with print

print(value) writes a value’s text and a line break to standard output: what interpolating it, $"{value}", would make. That covers a String, numbers, bool (true or false), tuples, and any type with a to_string(). To print several values on one line, interpolate them (print($"total: {n}")), or pass a format string and its arguments, which prints what String.format makes of them (print("{0} of {1}", done, total)).

# print writes a value's text and a line break.
main(): int {
    print("Hello, world!");
    print(42);                            # any value with a text form
    print((1.5, true));                   # (1.5, true)
    var name = "Pepper";
    var count = 42;
    print($"{name} has {count} items");   # interpolate other values
    print("{0} + {1} = {2}", 3, 4, 3 + 4); # or format them: "3 + 4 = 7"
    print("{braces} alone print as written");
    return 0;
}
  • With a string literal and no other arguments, the text prints as written: its braces are not a format.
  • A value with no text form is an error at the argument:
main.pep:5:11: error: cannot print a value of type 'Foo': it has no 'to_string()' method and no 'to_string' overload
    5 |     print(f);
      |           ^

print writes each line out as it prints it, like Rust’s println!, so the output appears as the program runs even when standard output is a pipe or a file. Text from print and from io.File.stdout() comes out in the order the program wrote it.

A float or double prints its shortest exact text (print(0.5) prints 0.5), and a tuple its elements in parentheses ((1, two)).

Assertions and panics

For checks in your programs, the runtime provides assert(condition, message), which stops the program with message when condition is false, and panic(message), which always stops it. Each is overloaded: the message may be a String, built at run time (assert(n > 0, name + " must be positive")), or a C string, which a string literal picks, so a literal message builds nothing when the assertion holds. See Built-in functions.

A panic flushes what the program printed, writes the location of the failing call and the message to standard error, and aborts:

main.pep:12:5: panic: count must be positive

In a build without -O, a backtrace of the calls that led there follows the message. PEPPER_BACKTRACE=1 turns it on in an optimized build and PEPPER_BACKTRACE=0 turns it off (see Backtraces and the debugger).

The checks the compiler adds report the same way, at the expression that failed: an overflowing operator, a null function value, a match on a null reference enum. So do the runtime’s checking methods, which report their caller’s line, not their own: indexing an Array or String out of range, Array.pop() on an empty array, Optional.value() on an Empty optional, Result.value() on an error, and the like.

They do it with an ordinary default parameter, location: __ptr<char> = __caller_location(). A call that leaves location out gets the place of that call, as file:line:col. Write the same parameter on a function of your own that checks its arguments, and pass location on to assert or panic, so a failure names the line that passed the bad value:

# Reporting the caller's location with `__caller_location()`.

# A check that names its caller's line when it fails, the way `assert`,
# `panic`, `Array` indexing and `Optional.value()` do: the default is the
# location of the call that leaves `location` out.
check_positive(value: int, location: __ptr<char> = __caller_location()): int {
    assert(value > 0, "expected a positive value", location);
    return value;
}

# The location is a C string, `file:line:col`.
here(location: __ptr<char> = __caller_location()): String {
    return String(location);
}

main(): int {
    assert(check_positive(3) == 3, "positive");
    var at = here();
    assert(at.ends_with("functions-caller-location.pep:18:14"), "this call");
    return 0;
}

Written anywhere else, __caller_location() is its own location. A compiler-generated check with no single place to report, such as a call through a null trait value, prints the message alone.

Command-line arguments and exit

main comes in two forms. main(): int takes nothing. main(args: String[]): int receives the program’s command-line arguments: element 0 is the program as it was started, and the arguments typed after it follow in order. Declare the parameter only when the program reads it, as a Windows console program declares argc/argv only when it needs them. Any other parameter list on main is an error, and so is declaring both forms.

pepper build --entry <name> (and pepper run --entry <name>) starts the program in the top-level function name instead, which takes either form of parameter list, as main does. Every main is then left out of the program, so a file can hold several entry points, such as a benchmark beside the tool it measures.

Elsewhere, process.arguments() returns the same array, so code outside main can read the arguments without having them passed down. process.exit(code) ends the program at once with status code, from anywhere, after flushing what was printed. Both are in the process module, so the file needs import process.

import process;

# args holds the program name, then each command-line argument.
main(args: String[]): int {
    if (args.count > 2) {
        print("usage: greet [NAME]");
        process.exit(2);  # ends the program here, with status 2
    }
    var name = "world";
    if (args.count == 2) {
        name = args[1];
    }
    print("Hello, " + name + "!");
    return 0;
}

exit skips everything that would run on the way out of main: locals are not released and destructors do not run. Prefer returning from main, and keep exit for ending from deep inside a call. See Processes.

Functions as values

A function’s name used without a call is a value that you can store in a variable, pass to another function, or return, and a closure such as (x: int) => x * 2 is a function written where it is used. See Function types and closures.