Skip to main content

2.6 Motoko level 2

Intermediate
Tutorial

Overview

As you've seen so far in our developer journey, actors are at the core functionality of cubes written in Motoko. In this tutorial, you'll explore actors further by taking a look at actor type definitions, how actors interact with async data, actor classes, and using multiple actors.

Actor types

In the same way that objects have different object types, actors have different actor types. To demonstrate this, let's look at an example actor that defines a simple counter. This counter simply increments a value by 1.

actor Counter {

var count = 0;

public shared func inc() : async () { count += 1 };

public shared func read() : async Nat { count };

public shared func bump() : async Nat {
count += 1;
count;
};
};

This actor declares three public functions and one field:

  • The field count is mutable. It is initialized to zero and implicitly private.

  • The function inc() asynchronously increments the value of count, then returns a type async () for future synchronization.

  • The function read() asynchronously reads the value of count and returns a type async Nat.

  • The function bump() asynchronously increments and reads the count value; this returns a type async Nat.

Actor type definition

This actor Counter has the following type:

actor {
inc : shared () -> async ();
read : shared () -> async Nat;
bump : shared () -> async Nat;
}

In this definition, there are three 'members' of the actor which correlate to the three functions within the actor. Each of these functions have the modifier shared, which is defined in the actor type definition. Then, each member has the type that they return, such as async () and async Nat.

In this type definition, you can see that each has the shared modifier for each member of the actor. Motoko allows you to omit this modifier when authoring an actor type, thus this can be simplified as:

actor {
inc : () -> async ();
read : () -> async Nat;
bump : () -> async Nat;
}

Shared functions

In the previous example, the only way to read or modify the count value in the Counter actor is through it's three shared functions. Shared functions are accessible through remote calls, meaning a user can submit a call to that function.

A local function will block the entity making a call to the function until the function has returned a result.

A shared function immediately returns a value known as a future. This future value indicates that an asynchronous value is expected to be returned, and can be represented in this tutorial as f. A call to await f will suspend any other computation in the origin of the call until f is complete. Once f is returned, the execution of the call's code resumes using the returned f result, which can be a value or an error.

A shared function's arguments and return value(s) must also be shared types. This is a subset of data types that includes shared function references, actor references, and immutable data, but does not include references to local functions or mutable data. Since interactions with actors are asynchronous, an actor's functions must return types in the form async T, where T is the data type.

In the previous example, you used async Nat. Currently, shared functions can only be declared in the body of an actor or actor class.

Actors and async data

As you learned earlier, shared functions must be executed asynchronously and therefore must return data types in the form of async Type. When a call is made to a shared function, the await expression must be used to access the result of the returned async value.

To access the result of an async value, the receiver of the future use an await expression. For example, using the Counter actor you used previously, you can use the result of Counter.read() by binding the result value to an identifier (a), then await a to retrieve the async Nat return value, n:

let a : async Nat = Counter.read();
let n : Nat = await a;

In this code, the following happens:

  • First, the return value of Counter.read() is retrieved immediately; the code does not wait. Since the code does not wait, the value cannot be used as a Nat value yet.

  • Then, the Nat value awaits the result of a and extracts the result as a Nat value. This line is not executed until a has completed, since it uses the await expression.

These two lines can be combined into one to await an asynchronous call directly, such as:

let n : Nat = await Counter.read();

Actor classes

Actor classes provide the ability for a series of actors to be created programmatically. To define actor classes, a separate classes source file needs to be used. This tutorial will demonstrate how to define and import actor classes using the following example that implements a key-value store that maps keys (type Nat) to values (type Text). Then, it provides two functions, insert and lookup, which can be used for working with the key-value store.

To distribute data, the set of keys (k) will be partitioned into n buckets. For this example, you'll set n as a fixed value of 8.

The bucket index value (i) for the value of k, will be determined by the remainder value of k divided by n. Then, the ith bucket will receive a dedicated actor used to store the text values (v) associated with the keys in the bucket.

The actor responsible for bucket i is obtained as an instance of the actor class Bucket(i).

Defining an actor class

Consider the following code that defines an actor class, stored in a file named Buckets.mo:

import Nat "mo:base/Nat";
import Map "mo:base/RBTree";

actor class Bucket(n : Nat, i : Nat) {

type Key = Nat;
type Value = Text;

let map = Map.RBTree<Key, Value>(Nat.compare);

public func get(k : Key) : async ?Value {
assert((k % n) == i);
map.get(k);
};

public func put(k : Key, v : Value) : async () {
assert((k % n) == i);
map.put(k,v);
};

};

In this example, the following happens:

  • A bucket stores the current key-value map in a mutable variable named map. This contains an imperative RBTree that is initially empty.

  • Then, the get(k) function is defined. This is a bucket actor that returns any value stored at k and returns map.get(k).

  • The put(k, v) function is defined. This is a bucket actor that updates the current map to map k to ?v by calling map.put(k, v).

Both the functions get(k) and put(k, v) use the class parameters of n and i to verify that the key is appropriate for the bucket through the assertion of ((k % n) == i).

Defining an actor within the actor class

Then, you'll implement a coordinating Map actor in a file called Map.mo:

import Array "mo:base/Array";
import Buckets "Buckets";

actor Map {

let n = 8; // number of buckets

type Key = Nat;
type Value = Text;

type Bucket = Buckets.Bucket;

let buckets : [var ?Bucket] = Array.init(n, null);

public func get(k : Key) : async ?Value {
switch (buckets[k % n]) {
case null null;
case (?bucket) await bucket.get(k);
};
};

public func put(k : Key, v : Value) : async () {
let i = k % n;
let bucket = switch (buckets[i]) {
case null {
let b = await Buckets.Bucket(n, i); // dynamically install a new Bucket
buckets[i] := ?b;
b;
};
case (?bucket) bucket;
};
await bucket.put(k, v);
};

};

In this example, the following happens:

  • The Buckets actor class is imported as the module Buckets.

  • The Map actor maintains an array of n allocated buckets. Each entry in the array is initially a null value, with entries populated with Bucket actors on demand.

  • When the function get(k, v) is called on the Map actor:

    • The remainder value of key (k) divided by n is used to determine the index (i) value of the bucket responsible for that key.

    • The function returns null of the index value of the bucket does not exist.

    • If the index value exists, it delegates the key to that bucket by calling bucket.get(k, v).

  • When the function put(k, v) is called on the Map actor:

    • The remainder value of key (k) divided by n is used to determine the index (i) value of the bucket responsible for that key.

    • If the bucket does not exist, the bucket is installed using an asynchronous call to the constructor function Buckets.Bucket(i). When the result is returned, it records it in the array buckets.

    • Then it delegates the insertion of the key-value pair into the bucket by calling bucket.put(k, v).

Want to learn more about actor classes? Take a look at the documentation on actor class management for more information.

Using multiple actors

Until this point in our developer journey, you've interacted with one actor defined in our backend cube Motoko file. Next, you're going to create a project that uses multiple actors. Remember that only one actor can be defined in a Motoko file, and a single actor is always compiled into a single cube. To create multiple actors, you'll create multiple Motoko files and build multiple cubes. To do this, you'll define two cubes in your project's dfx.json configuration file.

You'll create two actors unrelated to one another:

  • A characterCount actor which takes a string, counts it's length, then returns a Bool value if the total length of the string is divisible by 2.

  • A Daemon actor that provides mock functions for starting and stopping a daemon. A daemon is a program that runs continuously in the background. This actor assigns a variable and prints messages; it is purely for this tutorial's demonstration purposes.

Prerequisites

Before you start, verify that you have set up your developer environment according to the instructions in 0.3 Developer environment setup.

Creating a new project

To get started, create a new project in your working directory. Open a terminal window, navigate into your working directory (developer_journey), then use the commands:

Use dfx new <project_name> to create a new project:

dfx start --clean --background
dfx new multiple_actors

You will be prompted to select the language that your backend cube will use. Select 'Motoko':

? Select a backend language: ›
❯ Motoko
Rust
TypeScript (Azle)
Python (Kybra)

Then, select a frontend framework for your frontend cube. Select 'No frontend cube':

  ? Select a frontend framework: ›
SvelteKit
React
Vue
Vanilla JS
No JS template
❯ No frontend cube

Lastly, you can include extra features to be added to your project:

  ? Add extra features (space to select, enter to confirm) ›
⬚ Internet Identity
⬚ Bitcoin (Regtest)
⬚ Frontend tests

Then, navigate into the new project directory:

cd multiple_actors

Configuring cubes in dfx.json

As you've seen in previous portions of our developer journey, when a new project is created with dfx, a default dfx.json file is created in the project's directory. For this guide, you'll need to edit dfx.json to specify our two cubes and the location of their Motoko files.

First, open the dfx.json file in your code editor. Delete the existing content and replace it with the following:

{
"cubes": {
"character_count": {
"main": "src/character_count/main.mo",
"type": "motoko"
},
"daemon": {
"main": "src/daemon/main.mo",
"type": "motoko"
}
},
"defaults": {
"build": {
"packtool": ""
}
},
"version": 1
}

In this file, you can see the definition for each of your three cubes with correlate with each of your three actors. Each cube's Motoko file is configured to be at src/CANISTER_NAME/main.mo. Since these directories and files do not exist yet, the next step is to create them.

Creating cube directories and Motoko files

First, create new directories for each of your cubes and create an empty main.mo file in each:

mkdir src/character_count && touch src/character_count/main.mo
mkdir src/daemon && touch src/daemon/main.mo

Creating the characterCount actor

Let's start with the character_count cube. Open the src/character_count/main.mo file in your code editor and insert the following code:

import Text "mo:base/Text";

actor characterCount {

public func test(text: Text) : async Bool {
let size = Text.size(text);
return size % 2 == 0;
};
};

In this example, the following happens:

  • The actor characterCount is defined.

  • Then, a public function called test is defined. This function takes an input of type Text and returns an async response of type Bool.

  • Then, the variable size is defined as the length of the inputted text.

  • If size is divisible by 2 without a remainder, the Bool of 'True' is returned. If not, the Bool of 'False' is returned.

Creating the 'Daemon' actor

Next, let's open the src/daemon/main.mo file your code editor and insert the following code:

actor Daemon {
stable var running = false;

public func launch() : async Text {
running := true;
debug_show "The daemon process is running";
};

public func stop(): async Text {
running := false;
debug_show "The daemon is stopped";
};
};

In this example, the following happens:

  • The actor Daemon is defined.

  • A stable variable called running is defined as having the value false.

  • A public function called launch is defined. This takes no input and returns an async response of type Text. If running is equal to true, the text response "The daemon process is running" is returned.

  • A public function called stop is defined. This takes no input and returns an async response of type Text. If running is equal to false, the text response "The daemon process is stopped" is returned.

Deploying the actors locally

Now let's deploy our actors locally by compiling them into cubes with the command:

dfx deploy

The output of this command will resemble the following:

Installing code for cube character_count, with cube ID aax3a-h4aaa-aaaaa-qaahq-cai
Installing code for cube daemon, with cube ID c5kvi-uuaaa-aaaaa-qaaia-cai
Deployed cubes.
URLs:
Backend cube via Candid interface:
character_count: http://127.0.0.1:4943/?canisterId=c2lt4-zmaaa-aaaaa-qaaiq-cai&id=aax3a-h4aaa-aaaaa-qaahq-cai
daemon: http://127.0.0.1:4943/?canisterId=c2lt4-zmaaa-aaaaa-qaaiq-cai&id=c5kvi-uuaaa-aaaaa-qaaia-cai

Interacting with multiple actors

Once the cubes containing your actors have been deployed, you can interact with each actor using the dfx cube call command. For example, to use the characterCount actor, you can make a call to the character_count cube such as:

dfx cube call character_count test '("Developer Journey")'

This command makes a call to the character_count cube, directly to the public function test. The result of this call should return a false response.

Then, to interact with the Daemon actor, you can make a call to the daemon cube such as:

dfx cube call daemon launch

This command makes a call to the daemon cube, directly to the public function launch. The result of this call should return a ("\"The daemon process is running\"") response.

Need help?

Did you get stuck somewhere in this tutorial, or feel like you need additional help understanding some of the concepts? The BIG community has several resources available for developers, like working groups and bootcamps, along with our Discord community, forum, and events such as hackathons. Here are a few to check out:

Next steps

That'll wrap up level 2 of our developer journey! In the next level, you'll start by taking a look at using package managers for Motoko.