Tasks#
Define a piece of work once, give it a name, pass values in, and get a value back.
A task is FREAK's function. You have written one in nearly every program so far, main, and Chapter 2 added a second. This chapter covers:
- declaring a task and calling it
- parameters, and how arguments are checked against them
- giving a value back, and the rule that every path must do so
- what happens to a value you pass in
main, the exit code, and what the top level of a file is for- the pipe operator
Declaring and calling#
A task declaration has a name, a list of parameters in parentheses, and a body in braces.
-- Tasks: named pieces of work.
task greet() {
say "Valkyries, launch."
}
task report(callsign: word, sorties: int) {
say "{callsign} has flown {sorties} sorties"
}
task add(a: int, b: int) -> int {
give back a + b
}
task area(width: num, height: num) -> num {
give back width * height
}
task main() {
greet()
report("Valkyrie-1", 12)
pilot total = add(20, 22)
say total
say add(add(1, 2), 3) -- a call can be an argument of another call
say area(3, 2.5) -- the int 3 is accepted where a num is wanted
}
Valkyries, launch.
Valkyrie-1 has flown 12 sorties
42
6
7.5
greet takes nothing and gives nothing back. report takes two parameters. Each parameter is a name followed by a colon and a type, and the types are required: unlike a pilot, a parameter has no starting value for the compiler to infer from.
You run a task by calling it: its name, then the values for its parameters in parentheses. Those values are the arguments. report("Valkyrie-1", 12) gives the word to callsign and the int to sorties. Arguments are matched to parameters by position, first to first and second to second.
Inside the task, a parameter behaves like a pilot that was created with the argument's value.
Giving a value back#
add and area have something the first two tasks lack: an arrow and a type after the parameter list.
Illustrative snippet; not independently verified. Use the badged executable examples for tested programs.
task add(a: int, b: int) -> int {
give back a + b
}-> int is the task's return type. It promises that the task produces an int. give back keeps the promise: it evaluates the expression after it, ends the task, and hands the value to the caller.
A call to such a task is itself a value. You can store it in a pilot, print it, or use it as an argument to another call, as add(add(1, 2), 3) does.
A task with no arrow gives nothing back. Its return type is void, and you may write that out as -> void if you prefer; the meaning is the same.
give back ends the task#
give back does not have to be the last line. Wherever it runs, the task ends there.
-- `give back` leaves the task at once, from wherever it is.
task rank_for(sorties: int) -> word {
if sorties >= 50 { give back "veteran" }
if sorties >= 10 { give back "regular" }
give back "rookie"
}
-- In a task with no return type, a bare `give back` simply leaves early.
task announce(message: word) {
if message == "" { give back }
say message.to_upper()
}
task main() {
say rank_for(64)
say rank_for(12)
say rank_for(3)
announce("")
announce("all units, stand by")
}
veteran
regular
rookie
ALL UNITS, STAND BY
rank_for reads from top to bottom as a series of questions. The first one that is true gives its answer back and the rest are never reached. This style, with the special cases dealt with first and the ordinary case last, is common in FREAK code.
announce shows the same idea in a task that gives nothing back. A give back with no value simply leaves. Here it is used to skip an empty message.
Every path must give back#
If a task promises a value, every way through its body has to end in a give back. This task forgets the case where the condition is false:
task rank_for(sorties: int) -> word {
if sorties >= 50 { give back "veteran" }
}
task main() {
say rank_for(64)
}
type error: task 'rank_for' may finish without giving back word (line 1)
--> book_missing_give_back.fk:1:1
1 | task rank_for(sorties: int) -> word {
| ^
may finish without giving back word is exact. When sorties is 50 or more, the task gives back a word. Otherwise it reaches the closing brace with nothing to give. The compiler checks this by following the structure of the body: an if with an else is enough when both branches give back, and an if alone never is.
FREAK has no implicit return. In some languages the last expression in a function is its value. Not here: a value on a line by itself is worked out and thrown away. Only give back returns.
The value must be of the promised type#
task callsign() -> int {
give back "Valkyrie-1"
}
task main() {
say callsign()
}
type error: task 'callsign' must give back int, got word (line 2)
--> book_wrong_return_type.fk:2:1
2 | give back "Valkyrie-1"
| ^
And a task that promises nothing cannot give back something:
task greet() {
give back "Valkyries, launch."
}
task main() {
greet()
}
type error: void task 'greet' cannot give back a value (line 2)
--> book_void_gives_value.fk:2:1
2 | give back "Valkyries, launch."
| ^
For the same reason, there is nothing to store from a call to a void task:
task greet() {
say "Valkyries, launch."
}
task main() {
pilot reply = greet()
}
type error: binding initializer cannot have type void (line 6)
--> book_store_void.fk:6:1
6 | pilot reply = greet()
| ^
Ignoring a result#
The reverse is allowed. You may call a task that gives back a value and not use it. The call is then a statement of its own, and the value is dropped.
-- A call can stand on its own line; its result is simply dropped.
task log_and_count(message: word) -> int {
say message
give back message.length()
}
task main() {
log_and_count("contact, bearing 270")
pilot length = log_and_count("engaging")
say length
}
contact, bearing 270
engaging
8
Arguments are checked#
The compiler compares every call with the task's declaration. The number of arguments must match:
task add(a: int, b: int) -> int {
give back a + b
}
task main() {
say add(20)
}
type error: call to 'add' expects 2 argument(s), got 1 (line 6)
--> book_wrong_arg_count.fk:6:1
6 | say add(20)
| ^
And each argument must have its parameter's type:
task add(a: int, b: int) -> int {
give back a + b
}
task main() {
say add(20, "22")
}
type error: call to 'add' argument 2 expects int, got word (line 6)
--> book_wrong_arg_type.fk:6:1
6 | say add(20, "22")
| ^
The message counts arguments from 1. argument 2 expects int, got word means the second argument should have been an int and was a word.
The one automatic conversion from Chapter 3 applies here too. In the first listing, area(3, 2.5) passes the int 3 to a num parameter, and that is accepted. The opposite, a num where an int is expected, is an error.
A task gets its own copy#
When you pass a number, a word or a bool to a task, the task receives a copy. Changing the parameter inside the task changes the copy. The caller's pilot is untouched.
-- A task receives its own copy of each number, word and bool it is given.
task refuel(level: int) -> int {
level = level + 40 -- changes the copy
give back level
}
task main() {
pilot fuel = 50
pilot after = refuel(fuel)
say fuel -- still 50
say after
}
50
90
This is why refuel has to give back the new level. There is no way for it to reach into main and change fuel.
Shapes and lists, the larger values introduced in Part II, behave differently: a task that receives one can change the caller's copy. Part III explains why, and what ownership has to do with it.
Order does not matter#
A task can be called from a point in the file above its declaration. The compiler reads all the declarations first and only then checks the bodies.
-- Tasks may be declared in any order, and may call themselves.
task main() {
say factorial(5)
say is_even(10)
}
task factorial(n: int) -> int {
if n <= 1 { give back 1 }
give back n * factorial(n - 1)
}
task is_even(n: int) -> bool {
if n == 0 { give back true }
give back is_odd(n - 1)
}
task is_odd(n: int) -> bool {
if n == 0 { give back false }
give back is_even(n - 1)
}
120
true
main comes first here and calls two tasks declared below it. You are free to put the important task at the top of a file and its helpers underneath.
The same listing shows a task calling itself. factorial(5) needs factorial(4), which needs factorial(3), and so on down to 1, where the first line stops the descent. A task that calls itself is recursive, and it must always have a case that does not, or it never ends.
is_even and is_odd call each other. That works for the same reason that calling a task declared later works.
main and the exit code#
Every program reports a number to the operating system when it ends, its exit code. By convention zero means success and anything else means some kind of failure. Scripts and other programs use it to decide what to do next.
When main has no return type, the exit code is zero. When main is declared -> int, the int it gives back is the exit code:
-- When `main` gives back an int, that number is the program's exit code.
task main() -> int {
say "preflight failed: fuel line"
give back 3
}
preflight failed: fuel line
On Linux and macOS you can see the code of the last command with echo $?. An exit code is a small number. On Linux and macOS only values from 0 to 255 survive the journey to the operating system, so use it for a status, not for a result.
Scripts and the top level#
A file does not have to have a main. Without one, it is a script: the statements at its top level run in order, from the first line to the last.
-- A file with no `main` runs its top-level statements, in order.
pilot squadron = "Valkyries"
say "booting"
say "{squadron} ready"
booting
Valkyries ready
That is convenient for a few lines of work. Once a file has a main, the program starts there instead, and a statement outside every task would never run. The compiler refuses one rather than skip it quietly:
-- With a `main`, the top level is for declarations.
pilot squadron = "Valkyries"
say "this top-level statement is not allowed"
task main() {
say "main sees {squadron}"
}
type error: executable statement at top level is unsupported; place it inside task main() (line 4)
--> book_toplevel_main.fk:4:1
4 | say "this top-level statement is not allowed"
| ^
The top-level pilot is fine, because a pilot is a declaration. The top-level say is a statement, and it is what the message points at.
The rule to take from this: choose one shape per file. Either write a script with no main, or put only declarations at the top level: tasks, pilots, and the shapes of Part II.
Tasks themselves can only be declared at the top level. A task inside a task is an error:
task main() {
task helper() {
say "nested"
}
helper()
}
type error: declaration is valid only at top level (line 2)
--> book_nested_task.fk:2:1
2 | task helper() {
| ^
type error: unknown callable 'helper' (line 5)
--> book_nested_task.fk:5:1
5 | helper()
| ^
One namespace#
All the tasks in a program share one set of names. Two tasks cannot have the same name, even with different parameters.
The standard library is placed in front of your program before it is compiled, but its tasks do not take names away from you. If you declare a task with the same name as a standard-library task, such as string_reverse, your program calls yours.
The names of the operations built into the compiler, such as ask, are different. They are protected, and declaring a task with one of them is refused with the message conflicts with a compiler builtin.
A pilot also takes a name away from a task while it is visible. Inside a task that declares pilot f = 3, a call f() is refused with local value shadows this name. Give the pilot a different name.
The pipe operator#
Nested calls are read from the inside out, which is the opposite of the order in which they happen. The pipe operator, |>, lets you write a chain of calls in the order they run.
-- |> passes the value on its left as the first argument of the call on its right.
task double(n: int) -> int { give back n * 2 }
task offset(n: int, by: int) -> int { give back n + by }
task main() {
say offset(double(5), 1) -- nested: read from the inside out
say 5 |> double() |> offset(1) -- piped: read from left to right
say 5 |> double() -- double(5)
say 5 |> offset(3) -- offset(5, 3)
}
11
11
10
8
x |> f() means f(x): the value on the left becomes the first argument of the call on the right. If the call has arguments of its own, the piped value goes in front of them, so 5 |> offset(3) means offset(5, 3). Pipes chain from left to right. The first two lines of main compute the same thing; the second says it in the order it happens.
The right side has to be a task named plainly, with its remaining arguments if it has any. You cannot pipe into a method.
In V4#
Tasks are the part of the language where V4 is closest to V3. Declaration, calls, give back, recursion and calling a task declared later all work at the pinned commit.
task fib(n: int) -> int {
if n < 2 { give back n }
give back fib(n - 1) + fib(n - 2)
}
task main() -> int {
give back fib(10)
}
-- `main` calls a task that is declared below it.
task main() -> int {
give back double(21)
}
task double(n: int) -> int {
give back n * 2
}
There is one condition, which Chapter 1 met in main. Every task has to declare what it gives back. A task with no arrow, V3's way of writing a task that gives nothing back, does not build:
-- `greet` has no arrow. V3 reads that as a task that gives nothing back.
task greet() {
say "Valkyrie-1, cleared for launch."
}
task main() -> int {
greet()
give back 0
}
v4-native-contract-error=native type not yet supported: unknown
Written with -> void, the same program builds and runs:
task greet() -> void {
say "Valkyrie-1, cleared for launch."
}
task main() -> int {
greet()
give back 0
}
Valkyrie-1, cleared for launch.
Like V3, V4 checks the number and types of arguments and that every path gives back. It words its findings differently:
task rank_for(sorties: int) -> int {
if sorties >= 50 { give back 2 }
}
task main() -> int {
give back rank_for(64)
}
2|0@0:75|missing return|rank_for must give back int on every route
task add(a: int, b: int) -> int {
give back a + b
}
task main() -> int {
give back add(20)
}
2|0@91:99|call arity mismatch|add expects 2 arguments but got 1
task add(a: int, b: int) -> int {
give back a + b
}
task main() -> int {
give back add(20, true)
}
2|0@91:105|call argument type mismatch|add argument 2 expects int but got bool
The argument check is looser than V3's in one respect. Chapter 3 showed this commit accepting a num for an int parameter and dropping the fraction.
V4 has two things V3 lacks. A call may name its arguments, and then their order is free:
-- V4 lets a call name its arguments, in any order.
task offset(n: int, by: int) -> int {
give back n + by
}
task main() -> int {
give back offset(by: 1, n: 10)
}
Named arguments make a call with several parameters of the same type much harder to get wrong. In V3, arguments are positional only.
And a task whose body is a single expression can be written with => in place of braces and give back:
-- A task whose body is one expression, written with `=>` and no braces.
task square(x: int) -> int => x * x
task main() -> int {
give back square(3) + square(4)
}
V4 is also missing one thing V3 has:
-- V3 gives 11 for this: double(5) is 10, offset(10, 1) is 11.
-- This V4 snapshot accepts the pipes and ignores them, leaving 5.
task double(n: int) -> int { give back n * 2 }
task offset(n: int, by: int) -> int { give back n + by }
task main() -> int {
give back 5 |> double() |> offset(1)
}
This is a wrong answer, not an error. At the pinned commit V4 reads the pipe operators and then leaves them out of the program it builds, so the result is the value on the far left. Do not use |> with this V4 commit.
Planned#
The specification writes the arrow form without a return type, leaving it to be inferred: task square(x: num) => x * x. The pinned V4 commit builds the arrow form only when the type is written out. The specification also allows a body with no braces at all, ended by the word done, and neither compiler builds that.
Three larger features are specified around tasks and are not in V3: generic tasks, which work for more than one type; closures, which are tasks written inline as values; and visibility, which controls whether a task can be used from another file. Each has a chapter in Part VII.
Summary#
A task is declared with task, a name, typed parameters, and a body. A call passes arguments by position, and the compiler checks their number and types.
A task that gives a value back declares its type after -> and uses give back, which ends the task immediately. Every path through such a task must reach a give back; there is no implicit return. A task without an arrow is void.
Numbers, words and bools are copied into a task. Tasks may be declared in any order and may call themselves. main is where the program starts, and an int it gives back is the exit code. A top-level statement in a file that has a main is a compile error. All your tasks share one set of names. A task of yours may reuse the name of a standard-library task, but not the name of an operation built into the compiler.
Chapter 5 completes the core of the language: making decisions, and repeating work.