2.6 Motoko level 2
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 ofcount
, then returns a typeasync ()
for future synchronization.The function
read()
asynchronously reads the value ofcount
and returns a typeasync Nat
.The function
bump()
asynchronously increments and reads thecount
value; this returns a typeasync 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 aNat
value yet.Then, the
Nat
value awaits the result ofa
and extracts the result as aNat
value. This line is not executed untila
has completed, since it uses theawait
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 i
th 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 atk
and returnsmap.get(k)
.The
put(k, v)
function is defined. This is a bucket actor that updates the current map to mapk
to?v
by callingmap.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 moduleBuckets
.The
Map
actor maintains an array ofn
allocated buckets. Each entry in the array is initially anull
value, with entries populated with Bucket actors on demand.When the function
get(k, v)
is called on theMap
actor:The remainder value of key (
k
) divided byn
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 theMap
actor:The remainder value of key (
k
) divided byn
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 arraybuckets
.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 aBool
value if the total length of the string is divisible by2
.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:
- dfx v0.17.0 or newer
- dfx v0.16.1 or older
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
dfx start --clean --background
dfx new multiple_actors
cd multiple_actors
Remember, by default dfx new
creates a new project in the Motoko language. If you'd like to create a Rust project, use the flag --type=rust
.
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 typeText
and returns an async response of typeBool
.Then, the variable
size
is defined as the length of the inputted text.If
size
is divisible by 2 without a remainder, theBool
of 'True' is returned. If not, theBool
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 valuefalse
.A public function called
launch
is defined. This takes no input and returns an async response of typeText
. Ifrunning
is equal totrue
, 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 typeText
. Ifrunning
is equal tofalse
, 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:
Developer Discord community, which is a large chatroom for BIG developers to ask questions, get help, or chat with other developers asynchronously via text chat.
Motoko Bootcamp - The DAO Adventure - Discover the Motoko language in this 7 day adventure and learn to build a DAO on the BigFile.
Motoko Bootcamp - Discord community - A community for and by Motoko developers to ask for advice, showcase projects and participate in collaborative events.
Weekly developer office hours to ask questions, get clarification, and chat with other developers live via voice chat. This is hosted on our developer Discord group.
Submit your feedback to the BIG Developer feedback board.
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.