Methods and closures
In previous tutorials we've seen expressions such as Stdout.new
and
out.print(...)
. In such expressions, new
and print
are method calls. But
what are methods? Well, they are functions that are bound to some object. In
case of print
, the method is bound to an instance of Stdout
. This means you
first have to create an instance of Stdout
before you can call print
.
Methods are defined using the fn
keyword. An example of this which we've seen
so far is the main
method defined like so:
class async Main {
fn async main {
}
}
We can also define methods outside of classes like so:
fn example {
}
class async Main {
fn async main {
example
}
}
Here we define the method example
, then call it in the main
method. The
example
method known as a "module method" because it's defined at the
top-level scope, which is a module (more on this later).
Methods and classes
Within a class, we can define two types of methods: static methods, and instance methods. Instance methods are defined as follows:
class Person {
fn name {
}
}
Meanwhile, static methods are defined using fn static
as follows:
class Person {
fn static new {
}
}
The difference is that static methods don't require an instance of the class
they are defined in, while instance methods do. This means that to call new
in
the above example, you'd write Person.new
, while calling name
would require
you to create an instance of the class, then use person.name
where person
is
a variable storing the instance of the class.
When a class is defined using class async
, you can also define methods using
fn async
. These methods are the messages you can send to a process. It's a
compile-time error to define an fn async
method on a regular class.
Arguments
Methods can specify one or more arguments they require:
fn person(name: String, age: Int) {
}
This method requires two arguments: name
which is typed as String
, and age
typed as Int
. Inko is statically typed and requires explicit types when
defining method arguments and return types.
Methods with arguments are culled using positional arguments, named arguments, or a mix of both:
person('Alice', 42)
person('Alice', age: 42)
person(name: 'Alice', age: 42)
When mixing both positional and named arguments, the positional arguments must come first. This means the following is invalid:
person(name: 'Alice', 42)
Named arguments are useful when the purpose/meaning of an argument is unclear. Take this call for example:
person('Alice', 42)
While we might be able to derive that "Alice" is the name of the person, it's not clear what 42 refers to here. Using named arguments makes this more clear:
person('Alice', age: 42)
Return types and values
If a method doesn't specify a return type, the compiler infers it as Nil
. In
this case, the value returned is ignored. A custom return type is specified as
follows:
fn person(name: String, age: Int) -> String {
}
Here -> String
tells the compiler this method returns a value of type
String
.
A value is returned using either the return
keyword, or by making it the last
expression in a method or scope (known as an "implicit return"):
fn person(name: String, age: Int) -> String {
name
}
Here name
is the last expression, so it's the return value. This is what it
looks like when using explicit returns:
fn person(name: String, age: Int) -> String {
return name
}
Explicit returns are meant for returning early, for everything else you should use implicit returns. For example:
fn person(name: String, age: Int) -> String {
if name == 'Bob' {
return 'Go away!'
}
name
}
When a method has an explicit return type, the value returned must be compatible
with that type. In case of our person
method this means we must return a
String
, and returning something else produces a compile-time error:
fn person(name: String, age: Int) -> String {
age
}
class async Main {
fn async main {
}
}
If we try to run this program, we're presented with the following compile-time error:
test.inko:2:3 error(invalid-type): expected a value of type 'String', found 'Int'
Mutability
Trait and class instance methods are immutable by default, preventing them from mutating the data stored in their receivers. For example:
class Person {
let @name: String
fn change_name(name: String) {
@name = name
}
}
This definition of change_name
is invalid, as field assignments are mutations,
and change_name
is immutable. To allow mutations, use the mut
keyword like
so:
class Person {
let @name: String
fn mut change_name(name: String) {
@name = name
}
}
Module and static methods don't support the mut
keyword. Closures don't need
it, as the ability to mutate captured variables depends on their reference types
(e.g. captured ref
values can't be mutated).
Taking ownership
Regular instance methods can take ownership of their receivers by defining them
using fn move
:
class Person {
let @name: String
fn move into_name -> String {
@name
}
}
Methods defined using fn move
are only available to owned and unique
references, not (im)mutable borrows. The move
keyword isn't available to
fn async
methods.
Closures
Inko supports
closures:
anonymous functions that (optionally) capture data, and can be moved around as
values. Closures are defined using the fn
keyword while leaving out a name:
import std.stdio (Stdout)
class async Main {
fn async main {
let out = Stdout.new
fn { out.print('Hello!') }.call
}
}
Running this program results in "Hello!" being written to the terminal. Like regular methods, closures can also define arguments. Unlike regular methods, the argument types and the return type are inferred:
import std.stdio (Stdout)
class async Main {
fn async main {
let out = Stdout.new
fn (message) { out.print(message) }.call('Hello!')
}
}
The compiler might not always be able to infer the types though, in which case explicit type signatures are necessary:
import std.stdio (Stdout)
class async Main {
fn async main {
let out = Stdout.new
fn (message: String) -> Int {
out.print(message)
42
}.call('Hello!')
}
}
Capturing variables
By default, closures capture borrows of variables by value. What this means is that the variable can still be used outside of the closure, but assignments to the variable inside the closure aren't available outside of it. To prevent this from causing bugs, the compiler produces a compile-time error when you try to assign captured variables a new value:
class async Main {
fn async main {
let mut a = 10
fn { a = 20 }.call
}
}
This produces:
test.inko:5:10 error(invalid-assign): variables captured by non-moving closures can't be assigned new values
To resolve this, we have to move the captured variable into the closure. This
is done by using the fn move
keyword. When using fn move
, you can assign
the captured variable a new value:
class async Main {
fn async main {
let mut nums = [10]
fn move { nums = [20] }.call
}
}
Because nums
is moved into the closure, you can no longer use it outside of
it.
A common pattern is to loop over some data using an iterator and assign a variable a new value for each iteration:
let mut number = 0
[10, 20, 30].iter.each(fn move (num) {
number += num
})
# `number` is still zero because the assignment is local to the closure. We
# can also still use it because `Int` is a value type.
number
Because of how capturing works, such patterns won't work in Inko. Instead, use
methods such as Iter.reduce
like so:
let number = [10, 20, 30].iter.reduce(0, fn (total, num) { total + num })