Learning Resources
Implementing Actors
Part 2
Introduction
Last time, we learned about internal vs. external views to actors, objects lifetimes and explicit state management.
In this part, we extend our discussion on actor handles by introducing messaging interfaces for static type checking. Afterwards, we revisit the stateful actors API to implement state classes for statically typed actors.
Messaging Interfaces
Part 1 introduced the notion of inside and outside views to actors. A handle represents the outside view and it allows us to interact with actors only by sending messages to it as well as enables us to observe the lifetime of an actor.
All of our previous examples used dynamically typed actors. When implementing an
actor, we have stored our message handlers in a caf::behavior
. Thus, CAF
always returned a caf::actor
handle.
To enable CAF to statically type-check actor communication, we need to define a
messaging interface. In source code, we represent the interface simply as a
list of normalized function signatures. This simply states that we wrap the
result types in a caf::result
and that we write our function arguments without
any cv qualifiers. Here are a
couple of examples:
- Good:
caf::result<void>(int32_t)
caf::result<int32_t>(int32_t, int32_t)
caf::result<std::string, std::string>(my_class)
- Bad:
void(int32_t)
caf::result<std::string>(const my_class&)
Note that caf::result
allows any number of type parameters to naturally encode
multiple return types.
To declare a typed actor handle for a messaging interface, we wrap our messaging
interface in a trait class. Then, we can pass this trait class to the template
class typed_actor
to define a custom handle type.
The typed_actor
handle type uses set semantics to determine whether two
messaging interfaces are compatible. Two handle types are considered equal if
they contain the same signatures, regardless of ordering. If one handle type is
a subset of another handle, then assignment only works in one direction: from
the larger set to the smaller set.
As an example, let us define a typed interface for a cell actor that supports get and put operations:
struct cell_trait_1 {
using signatures = caf::type_list<
caf::result<int32_t>(caf::get_atom),
caf::result<void>(caf::put_atom, int32_t)
>;
};
using cell_actor_1 = caf::typed_actor<cell_trait_1>;
struct cell_trait_2 {
using signatures = caf::type_list<
caf::result<void>(caf::put_atom, int32_t),
caf::result<int32_t>(caf::get_atom)
>;
};
using cell_actor_2 = caf::typed_actor<cell_trait_2>;
static_assert(std::is_assignable_v<cell_actor_1, cell_actor_2>);
static_assert(std::is_assignable_v<cell_actor_2, cell_actor_1>);
The only difference between cell_actor_1
and cell_actor_2
is the order of
the message handlers. Hence, CAF considers these handle types equal and you can
assign one to the other.
If one handle type is a subset of another handle, then assignment only works in one direction: from the larger set to the smaller set. As an example, consider a simple math service. Workers have an interface that allows adding or subtracting two integers. The server interface has an additional handler that allows clients to request additional workers.
struct compute_unit_trait {
using signatures = caf::type_list<
caf::result<int32_t>(caf::add_atom, int32_t, int32_t),
caf::result<int32_t>(caf::sub_atom, int32_t, int32_t)
>;
};
using compute_unit_actor = caf::typed_actor<compute_unit_trait>;
struct compute_service_trait {
using signatures = compute_unit_trait::signatures::append<
caf::result<void>(caf::spawn_atom, int32_t)
>;
};
using compute_service_actor = caf::typed_actor<compute_service_trait>;
// compute_unit_actor hdl = compute_service_actor{...}; // ok: subset relation
static_assert(std::is_assignable_v<compute_unit_actor, compute_service_actor>);
The example above also illustrates one important aspect of messaging interfaces: there are no inheritance relations. We can extend actor interfaces with more message handlers simply by adding additional signatures to the messaging interface.
Implementing Statically Typed Actors
Implementing a statically typed actor for any given my_handle
type is very
similar to implementing dynamically typed actors. We only need to change two
things:
- Instead of returning a
caf::behavior
from the function, we return amy_handle::behavior_type
. - For the optional self pointer, we use the type
my_handle::pointer_view
instead ofcaf::event_based_actor*
.
With this in mind, we can implement a simple math service that supports adding and subtracting integers:
Source Code
struct math_service_trait {
using signatures = caf::type_list<
caf::result<int32_t>(caf::add_atom, int32_t, int32_t),
caf::result<int32_t>(caf::sub_atom, int32_t, int32_t)
>;
};
using math_service_actor = caf::typed_actor<math_service_trait>;
math_service_actor::behavior_type math_service_impl() {
return {
[](caf::add_atom, int32_t x, int32_t y) {
return x + y;
},
[](caf::sub_atom, int32_t x, int32_t y) {
return x - y;
},
};
}
void caf_main(caf::actor_system& sys) {
auto self = caf::scoped_actor{sys};
auto math = sys.spawn(math_service_impl);
sys.println("7 + 8 = {}",
self->mail(caf::add_atom_v, 7, 8).request(math, 1s).receive());
sys.println("7 - 8 = {}",
self->mail(caf::sub_atom_v, 7, 8).request(math, 1s).receive());
}
Output
7 + 8 = 15
7 - 8 = -1
Advantages of Statically Typed Actors
When comparing the statically typed actor implementation with our previous examples from part 1, we can see that they require additional boilerplate code. In exchange, we gain the following benefits:
- Message Type Checking: The compiler checks that senders and receivers of messages are compatible at compile time. Trying to send a message with unexpected types will result in a compile-time error.
- Message Handler Checking: The compiler checks that all message handlers
are implemented. If a message handler is missing, the compiler will issue an
error. When trying to implement a message handler that is not part of the
interface (except handler for special types such as
exit_msg
), the compiler will also issue an error. - Return Type Deduction: When sending a message, the compiler deduces the
return type of the message handler. In math service example, the compiler will
deduce the type
expected<int32_t>
for our request messages to the math actor when callingreceive()
. The same applies to event-based actors that usethen()
orawait()
: the compiler checks that the continuation function matches the return type of the message handler.
The additional type-checking matters most for actors that are part of a larger system. When working on a team, the statically typed actors help to ensure that all actors communicate correctly. This is especially important when refactoring code or when adding new features.
As a rule of thumb, actors that are visible in a large scope benefit most from the static type checking. For actors that are only used in a small scope, the additional boilerplate code might not be worth the effort. Actors that are only visible in a single compilation unit, for example, usually do not need static type checking.
Another type of actor that rarely benefits from static type checking are supervisors that only exist to manage the lifecycle of other actors (see Monitoring and Linking).
Implementing Statically Typed Actors with State
Statically typed actors can also have state. When implementing the state class
for a statically typed actor, we follow the same pattern as for dynamically
typed actors and replace the behavior type as well as the type for self
as we
have seen earlier.
Source Code
struct cell_trait {
using signatures = caf::type_list<
caf::result<int32_t>(caf::get_atom),
caf::result<void>(caf::put_atom, int32_t)
>;
};
using cell_actor = caf::typed_actor<cell_trait>;
struct cell_state {
cell_state(cell_actor::pointer_view ptr, int32_t init_value)
: self(ptr), value(init_value) {
// nop
}
cell_actor::behavior_type make_behavior() {
return {
[this](caf::get_atom) {
return value;
},
[this](caf::put_atom, int32_t new_value) {
self->println("cell changes its value from {} to {}", value, new_value);
value = new_value;
},
};
}
cell_actor::pointer_view self;
int32_t value = 0;
};
void client_impl(caf::event_based_actor* self, cell_actor cell) {
self->mail(caf::put_atom_v, int32_t{1}).send(cell);
self->mail(caf::put_atom_v, int32_t{2}).send(cell);
self->mail(caf::put_atom_v, int32_t{3}).send(cell);
self->mail(caf::get_atom_v).request(cell, 1s).then([self](int32_t value) {
self->println("client received: {}", value);
});
}
void caf_main(caf::actor_system& sys) {
auto cell = sys.spawn(caf::actor_from_state<cell_state>, -1);
sys.spawn(client_impl, cell);
}
Output
cell changes its value from -1 to 1
cell changes its value from 1 to 2
cell changes its value from 2 to 3
client received: 3
By returning cell_actor::behavior_type
from cell_state::make_behavior()
, we
provide CAF with the type information it needs to infer the correct handle type
when using caf::actor_from_state<cell_state>
. Thus, instead of returning
caf::actor
from spawn
, CAF returns a cell_actor
handle that we can pass to
our client.
Conclusion
In this part, we learned how to define messaging interfaces for statically typed actors and how to implement statically typed actors with and without state. Combined with first part of this article, we hope this leaves you with a solid grasp of the actor model implementation in CAF and how to implement actors in your own projects.