Types and methods
Inko provides two kinds of types: classes and traits. In this chapter we'll take a look at how to define and use such types.
Classes
Classes store state and provide methods, and are created using the class
keyword:
class Person {
let @name: String
let @age: Int
}
Instances of classes are created using the class literal syntax:
Person { @name = 'Alice', @age = 42 }
When creating an instance, all fields must be assigned a value, and a field can't be assigned a value multiple times.
Classes don't support inheritance, and instead rely on traits to provide reusable behaviour.
Classes come in three forms: regular classes, enum classes, and async classes.
Enum classes are algebraic data types created using the class enum
syntax, and
its variants are specified using the case
keyword:
class enum Error {
case FileDoesntExit
case PermissionDenied
}
When pattern matching against enum classes, the compiler ensures the match is exhaustive.
Async classes are created using the class async
syntax and are used to define
and spawn processes. This is covered in greater detail in the
Concurrency chapter.
Methods
Methods may be defined when defining the class or when reopening it:
class Person {
let @name: String
let @age: Int
fn name -> String {
@name
}
}
impl Person {
fn age -> Int {
@age
}
}
The default return type of a method is Nil
. When the return type is Nil
, any
expression implicitly returned in the method is ignored:
fn example {
42
}
example # => nil
If a method is defined as returning Nil
, you can't explicitly return a value
that isn't Nil
:
fn example {
return 42 # => Compile-time error
}
For enum classes the compiler generates a static method for each variant, using the same name as the variant:
class enum Error {
case FileDoesntExit
case PermissionDenied
}
Error.FileDoesntExit # Same as Error.FileDoesntExit()
Fields
Fields are private by default. You can make them public using let pub
:
class Person {
let pub @name: String
let pub @age: Int
}
Fields are accessed using the same syntax as method calls, making it easier to replace them with methods, without having to change every line that uses the fields:
let alice = Person { @name = 'Alice', @age = 42 }
alice.name # => 'Alice'
alice.age # => 42
Enum classes can't define custom fields.
Traits
Traits are a sort of contract for classes to adhere to: a trait can specify one or more required methods as well as default methods. A class implementing a trait must implement the required methods, and is automatically given a copy of the default methods; unless the class overrides the implementation. Traits may also list other traits a class must implement.
A simple example of a trait is std.string.ToString
, defined as follows:
trait pub ToString {
fn pub to_string -> String
}
A class implementing this trait must provide a to_string
implementation
compatible with the one of the trait. Here's what such an implementation might
look like:
import std.string.ToString
class Person {
let @name: String
let @age: Int
}
impl ToString for Person {
fn pub to_string -> String {
@name
}
}
A class can only implement a trait once.
Visibility
Types, methods, and fields are private by default. When something is private,
it's only available to modules defined in the same root namespace. For example,
a private class defined in std.foo
is available to the modules std
,
std.bar
and std.foo.bar
(because their namespaces all start with std
), but
not to the module http
.
You can make something public using the pub
keyword. For example, a public
method is defined as follows:
fn pub foo {
# ...
}
A private type can't be used in the signature of a public method or field. Private types can define public methods, which in practise means they're the same as private methods. This is allowed so private types can implement traits that expose public methods, without requiring the type to also be public.
Core types
Inko provides various core types, such as String
, Int
, and Array
.
Some of these types are value types, which means that when they are moved a copy is created and then moved. This allows you to continue using the original value after moving it.
Array
Array
is a contiguous growable array type and can store any value, as long as
all values in the array are of the same type.
Bool
Inko's boolean type is Bool
. Instances of Bool
are created using true
and
false
.
Bool
is a value type.
ByteArray
ByteArray
is similar to Array
, except it's optimised for storing bytes. A
ByteArray
needs less memory compared to an Array
, but can only store Int
values in the range of 0 up to (and including) 255.
Channel
Channel
is used for sending values between processes, and allows multiple
processes to send and receive values concurrently.
Channel
is a value type.
Float
The Float
class is used for IEEE 754 double-precision floating point numbers.
Float
is a value type.
Int
The Int
class is used for integers. Integers are 64 bits signed integers.
Int
is a value type.
Map
Map
is a hash map and can store key-value pairs of any type, as long as the
keys implement the traits std.hash.Hash
and std.cmp.Equal
.
Nil
Nil
is Inko's unit type, and used to signal the complete lack of a value. The
difference with Option
is that a value of type Nil
can only ever be Nil
,
not something else. Nil
is used as the default return type of methods, and in
some cases can be used to explicitly ignore the result of an expression (e.g. in
pattern matching bodies).
Nil
is a value type.
Option
Option
is an algebraic data type/enum class used to represent an optional
value. It has two variants: Some(T)
and None
, with None
signalling the
lack of a value.
Result
Result
is an algebraic data type/enum class used for error handling. It has
two variants: Ok(T)
and Error(E)
. The Ok
variant signals the success of an
operation, while Error
signals an error occurred.
String
The String
class is used for strings. Strings are UTF-8 encoded immutable
strings. Internally strings are represented such that they can be efficiently
passed to C code, at the cost of one extra byte of overhead per string.
String
uses atomic reference counting when copying. This means that ten copies
of a 1 GiB String
only require 1 GiB of memory.
String
is a value type.
Never
Never
is a type that indicates something never happens. When used as a return
type, it means the method never returns. An example of this is
std.process.panic()
: this method panics and thus returns a Never
.
You'll likely never need to use this type directly.
Generic types
Types can be made generic, allowing them to operate on a wide range of types. For example, here's how you'd might define a generic linked list:
class Node[T] {
let @next: Option[Node[T]]
let @value: T
}
class List[T] {
let @head: Option[Node[T]]
let @tail: Option[mut Node[T]]
}
Classes, traits, methods and variants can all be made generic.
When defining type parameters, you can specify a set of traits that must be implemented for a type to be compatible with the type parameter. For example:
import std.string.ToString
class Container[T: ToString] {
# ...
}
Here only types that implement ToString
can be assigned to T
.
You can use the mut
requirement to limit the types to those that allow
mutations:
class Container[T: mut] {
let @value: T
}
# This is OK, because `Array[Int]` is mutable.
Container { @value = [10, 20] }
let nums = [10, 20]
# This isn't OK, because `ref Array[Int]` doesn't allow mutations.
Container { @value = ref nums }
If a type parameter doesn't specify the mut
requirement, you can't create
mutable references to values of the type:
class Container[T] {
let @value: T
fn mut mutate {
# This will produce a compile-time error, because `T` doesn't specify the
# `mut` requirement.
mut @value
}
}
When using type parameters, values with any ownership (ref
, mut
, etc) can be
assigned to the parameter, provided the type implements the necessary
requirements:
fn example[T](value: T) {}
# All of these calls are valid.
example([10])
example(ref [10])
example(mut [10])
example(recover [10])
You can restrict the ownership to owned values using the syntax move T
. The
move
annotation is only available to type parameters (e.g. move User
is a
compile-time error):
fn example[T](value: move T) {}
example([10]) # OK
example(ref [10]) # not OK
example(mut [10]) # not OK
example(recover [10]) # OK, as `uni` values can be moved into owned values
When used in a return type signature, move T
returns the owned equivalent of
the type assigned to T
:
fn example[T](value: T) -> move T { ... }
example(ref [10]) # The return type is inferred as `Array[Int]`.
An example of where move T
is useful is String.join
, defined using the
following signature:
fn pub static join[T: ToString, I: Iter[T]](
iter: move I,
with: String
) -> String
The implementation calls Iter.reduce
on the iter
variable. Without the
move
annotation it would be possible to pass a ref Iter
, which doesn't allow
the use of moving methods as one can't move out of a reference. Using move I
instead of I
solves this problem by restricting values passed to iter
to
owned values.
Type inference
Inko supports type inference, removing the need for type annotations in most
cases. For example, the type signature of an Array
can be inferred based on
its usage:
# Here the compiler infers `a` as `Array[Int]`, because of the `push` below.
let a = []
a.push(42)
This works for any type, including generic types such as the Option
type:
# `a` is inferred as `Option[Int]`.
let mut a = Option.None
a = Option.Some(42)
If a generic type can't be inferred, the compiler produces an error. In this case explicit type signatures are necessary:
let mut a: Option[Int] = Option.None
The prelude
Inko automatically imports certain symbols into your modules. These symbols are part of what is called "the prelude".
The prelude includes the following types and methods:
Symbol | Source module |
---|---|
Array |
std.array |
Boolean |
std.bool |
ByteArray |
std.byte_array |
Channel |
std.channel |
Float |
std.float |
Int |
std.int |
Map |
std.map |
Nil |
std.nil |
Option |
std.option |
Result |
std.result |
String |
std.string |
panic |
std.process |