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 a my_handle::behavior_type.
  • For the optional self pointer, we use the type my_handle::pointer_view instead of caf::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 calling receive(). The same applies to event-based actors that use then() or await(): 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.