Skip to content

Working with objects

The examples on this page come from the shipped example packages, calculators, lifecycle and fibonacci. Where a snippet is written out rather than transcluded, it uses my_package.MyClass from Create your first package.

Implementing your objects

Initial values

A property staged in your constructor with setNext...() is published by the first commit, which happens before the first cycle begins. That makes the constructor a place to set initial values in code, as an alternative to setting them in the YAML configuration.

Registration

If you need to do some work after construction, but just before your object starts to get called, you can overwrite the registered() function. For example:

MyClass registration
void registered(sen::kernel::RegistrationApi& api) override
{
  // do something once (and maybe use the api)
}

The registration API lets you obtain sources and register for discovering other objects that you might be interested in.

registered() runs when your object is added to a bus. Everything the kernel builds from a configuration file is added, so for those objects it always runs, and whatever you acquire there is in place for every later call, including the destructor. An object you create in code and never add to a bus is never registered, and anything registered() would have given it stays empty.

Apart from this, you can also implement a function that will get called when the object gets unregistered:

MyClass de-registration
void unregistered(sen::kernel::RegistrationApi& api) override
{
  // do something once (and maybe use the api)
}

Updates

If your object is published to a bus, your update() is called once per cycle, after drainInputs(). This allows you to perform periodic updates to your internal state and trigger your internal logic. For example:

my_class.cpp
void MyClassImpl::update(sen::kernel::RunApi& runApi)
{
  // change prop2 with some dummy values
  StructOfInts val = getProp2();
  val.field1 += 1;
  val.field2 += 2;
  setNextProp2(val);
}

The sen::kernel::RunApi, as the name suggests, is the runtime API that allows your component to interact with Sen. It only has a few (but powerful) methods.

Advanced callbacks

If you need finer control over what happens during the drain-update-commit cycle, you can run code just before the drain and just before the commit.

needsPreDrainOrPreCommit() returns false by default, and the two hooks are only called when it returns true. Overriding the hooks alone does nothing at all, silently, which is the easiest mistake to make here.

The code below is from the lifecycle example package, which builds as part of the examples. Its interface is small: it counts the phases and publishes what it saw:

package lifecycle;

// Records which phases of the execution cycle ran on this object, so the
// ordering the kernel guarantees can be observed rather than described.
class PhaseRecorder
{
  // Cycles completed since this object was registered.
  var cycles : i32;

  // How the phases ran in the last completed cycle, staged from preCommit().
  // A string is unbounded, and Sen refuses to carry an unbounded property over a
  // best-effort transport, so this one has to be confirmed.
  var lastCycle : string [confirmed];

  // The same summary on demand, without waiting for the next commit.
  fn report() -> string [const];
}
  /// Opt in to preDrain() and preCommit(). Without this they never run.
  [[nodiscard]] bool needsPreDrainOrPreCommit() const noexcept override { return true; }

  void registered(sen::kernel::RegistrationApi& api) override;
  void unregistered(sen::kernel::RegistrationApi& api) override;

  /// Runs before the component drains its inputs, so the object sees the world
  /// as it was at the end of the previous cycle.
  void preDrain() override;

  void update(sen::kernel::RunApi& runApi) override;

  /// Runs immediately before the commit, which makes it the last point at which
  /// a property staged with setNext...() still lands in this cycle.
  void preCommit() override;

preCommit() is the more useful of the two, because it is the last point at which a property staged with setNext...() still reaches the commit at the end of that same cycle. That lets an object publish something derived from everything that happened during the cycle:

void PhaseRecorderImpl::preCommit()
{
  ++preCommits_;

  // Staging here still reaches the commit that follows, which is what makes
  // preCommit() useful: the object can publish something derived from
  // everything that happened during the cycle.
  setNextCycles(getCycles() + 1);
  setNextLastCycle(reportImpl());
}

Running that example shows the ordering. The first commit happens before any drain or update, because the kernel commits the initial state of your objects before the first cycle begins. So preCommit() is called once more than the other two.

cycles=1  lastCycle="preDrain=0 update=0 preCommit=1"
cycles=2  lastCycle="preDrain=1 update=1 preCommit=2"

Object naming convention

Sen supports all special characters in published object names, with the single exception of literal space characters (" "), which are restricted.

Interacting with objects

Calling methods

Sen is fully asynchronous, which means that calls to methods do not block. This is easy when your method does not return any value, but if it does (and you are interested in it), then you need to provide a callback. For example:

calculators/src/client.cpp
  void useCalculatorImpl() override
  {
    if (const auto& list = calculators_->list.getObjects(); !list.empty())  // (4)!
    {
      auto handleResult = [](sen::MethodResult<float32_t> result)  // (6)!
      {
        if (result.isOk())
        {
          std::cout << "add(3, 4) = " << std::to_string(result.getValue()) << std::endl;
        }
        else
        {
          std::cout << "add failed" << std::endl;
        }
      };

      // Call add(3, 4) and handle the result
      list.front()->add(3.0, 4.0, {this, handleResult});  // (5)!
    }
  1. Subscriptions are set up in registered(), earlier in the same file.
  2. The subscription is held in a member so that it outlives the call.
  3. It matches every object implementing the generated interface.
  4. Always guard against an empty list. Objects can disappear between cycles.
  5. list.front() gives a reference to the first match, valid for this whole update cycle because it was frozen during drain.
  6. The callback receives a MethodResult, holding either the value or an exception. float32_t is what f32 becomes in the generated header, so the types match exactly. It fires during the drain stage of a later cycle, once the calculator has committed the result.

That is the whole of a real client: find a calculator, call add on it, and handle the answer when it arrives. The {this, handleResult} pair is the callback. this is what ties the callback to your object's lifetime, so it stops being invoked if your object goes away.

Handling responses and errors

Methods may or may not return values. If you want to obtain the returned value, you can use the result argument. It's a MethodResult<T> from which you can extract the value (using getValue()) or the error (using getError()).

Note that if methods do not return any value, you can still register a callback and react to the fact that the method execution is finished (or detect any errors).

if (result.isOk())
{
  // it worked, do something with the return value
  std::cout << result.getValue() << std::endl;
}
else
{
  // Something went wrong - try to find out what.
  // The error is an `std::exception_ptr` that we can re-throw
  // when interested in knowing more about it.
  try
  {
    std::rethrow_exception(result.getError());
  }
  catch(const std::exception& err)
  {
    // do something with err
    std::cout << err.what() << std::endl;
  }
}

Choose what you catch. std::exception covers everything a call can deliver; a narrower type covers only what you name, and what it does not match escapes the callback. Within one process the error arrives as the type the method threw. Across a process boundary that type cannot travel, so what reaches you is a std::logic_error, a std::invalid_argument, a std::runtime_error or a bare std::exception. Threading and object lifetime has the mapping.

If you don't install any callback, you will be effectively ignoring the result of the method call. This includes any potential errors signaled by the method.

Deferred methods

A method normally returns its value, and Sen delivers it to the caller. Sometimes you cannot answer straight away: the work is slow, or it has to be handed to someone else. Marking a method deferred changes the generated implementation side: you receive a std::promise and fulfill it whenever you are ready. Callers see no difference.

A method can be marked deferred directly in the STL, with the [deferred] attribute:

fn computeFibonacci(n: u32) -> u64 [deferred];

It can also be marked in the code generation settings, which keeps the STL free of implementation choices, so the same interface can be implemented with or without deferral. That is what the fibonacci example does, so it is the one shown here. Its STL declares an ordinary method:

package fibonacci;

// Example class that does some work. In this case, we
// illustrate it by computing the fibonacci sequence.
class Worker
{
  // Returns the Fibonacci number of the element "n" of the Fibonacci sequence.
  //
  // @param n index of the Fibonacci sequence.
  fn computeFibonacci(n: u32) -> u64;
}


// A special kind of worker that forwards the
// incoming work to other workers.
class Manager : extends Worker
{
  // Where to look for the workers.
  var workersBus : string [static];
}

and the settings file names the ones to defer:

{
  "classes": {
    "fibonacci.Worker": {
      "deferredMethods": [
        "computeFibonacci"
      ]
    }
  }
}

which the package passes with CODEGEN_SETTINGS. See CMake integration. See also Customizing the generated code, which covers the other settings this file can carry.

The generated declaration is then computeFibonacciImpl, taking the promise by rvalue reference:

virtual void computeFibonacciImpl(u32 n, std::promise<u64>&& promise) = 0;

Note the name. Callers still use computeFibonacci(); the generated base turns that into a computeFibonacciImpl() on your class, and that is the one you implement.

The fibonacci example ships two implementations. The first does the work and fulfills the promise when it is done:

doing the work
class FibonacciWorker: public WorkerBase
{
public:
  SEN_NOCOPY_NOMOVE(FibonacciWorker)

public:
  using WorkerBase::WorkerBase;
  ~FibonacciWorker() override = default;

public:
  void computeFibonacciImpl(uint32_t n, std::promise<uint64_t>&& promise) override
  {
    std::this_thread::sleep_for(std::chrono::seconds(5));  // simulate heavy workload
    promise.set_value(::fibonacci::computeFibonacci(n));
  }
};

The second hands the call to another object and passes the promise into the callback, so whoever answers fulfills the original caller's promise:

forwarding the call
  /// Forwards the call to a random worker, or does the work if none is found.
  void computeFibonacciImpl(uint32_t n, std::promise<uint64_t>&& promise) override
  {
    if (workers_->list.getObjects().empty())
    {
      std::cout << "No workers available, manager is performing the task" << std::endl;
      std::this_thread::sleep_for(std::chrono::seconds(5));  // simulate heavy workload
      promise.set_value(::fibonacci::computeFibonacci(n));
    }
    else
    {
      auto& selectedWorker = selectRandomWorker();
      std::cout << "Sending task to " << selectedWorker.asObject().getName() << std::endl;
      selectedWorker.computeFibonacci(
        n, {this, [p = std::move(promise)](const auto& result) mutable { p.set_value(result.getValue()); }});
    }
  }

The lambda must be mutable: it owns the promise, and set_value() is not const. Note also that a promise cannot be copied, so it cannot be stored in a std::function, which requires a copy-constructible target. Sen's own callback type accepts move-only targets, which is why passing the promise into the callback above works.

Reacting to property changes

All Sen objects allow hooks that you can use to react to property changes. The generated function will be named as onXChanged(callback), where X would be the name of the property.

For example:

Reacting to property changes
auto cb = [myObject]() { std::cout << "position changed: " << myObject->getPosition() << "\n"; };
myObject->onPositionChanged({this, std::move(cb)}).keep();

The lambda captures myObject by value. A kept connection outlives the scope that created it, so a reference capture would dangle. The this in {this, std::move(cb)} does a different job. It names the object that owns the registration, and the lambda never reads it.

These methods register the callback and return a ConnectionGuard that owns the registration.

NOTE: The call to .keep() keeps the connection established, even after the ConnectionGuard object was destroyed. What ends it then is the object named in the token, the this above: while that object is alive the callback keeps being invoked, and once it is destroyed the callback stops. Threading and object lifetime covers what that guarantees.

If you do not have the generated code, and find yourself working with generic proxies, you can use the onPropertyChangedUntyped method, which is also available in all objects, but works with Variants.

Reacting to events

In the same way as with properties, you can register as interested in an event by using the corresponding generated function.

Listening to an event
auto cb = [](int32_t arg) { std::cout << "something happened " << arg << "\n"; };
myObject->onSomethingHappened({this, std::move(cb)}).keep();

In this case, the callback is expected to receive the same arguments as the event generates.

As with properties, there's a Variant-based option in case you don't have the generated code: onEventUntyped().

Runtime API

Some of what you need comes from outside your own objects: from the kernel, and from other components.

The kernel can give you the following information:

  • Your application name via KernelApi::getAppName().
  • The known types via KernelApi::getTypes() (You won't normally need to use this, as it is aimed at tooling).
  • The network addresses and ports the process has bound, via KernelApi::getRuntimeNetworkFootprint(). See the runtime network footprint.
  • The configuration passed by the user via ConfigGetter::getConfig().
  • Whether you are required to stop, via RunApi::stopRequested().
  • The current (virtualized) time via RunApi::getTime().
  • The time the component's objects started from, via RunApi::getStartTime().
  • The configured cycle time via RunApi::getTargetCycleTime(), which is set once execLoop is running and empty for a component that drives its own loop.

The execution model explains what that time is, how it moves in each run mode and how a model uses it.

You can also ask the kernel:

  • To stop, via KernelApi::requestKernelStop(int exitCode).
  • To process incoming information, via RunApi::drainInputs().
  • To flush outgoing information, via RunApi::commit().
  • To give you pointers to objects published by other components, and to let you publish your own objects.

The last point is the most interesting for this section because it is where the largest part of the communication happens.

In the Sen API, Object Sources are entities that you can use to publish your own objects and be informed about other objects. This is implemented in the sen::ObjectSource class.

You can see that it has the methods add(Object) and remove(Object), and it inherits from sen::ObjectFilter, which contains the addSubscriber and removeSubscriber methods, that you can use to discover objects based on some criteria that you can define.

Object Sources can be created by calling the method KernelApi::getSource(). This function takes a string as an argument, and it expects it to be in the format <session>.<bus>. For example, the kernel always publishes some objects in the "local.kernel" bus.

Finally, you need somewhere to put those discovered objects. The sen::ObjectList class serves this purpose.

Subscribe to the generated <Class>Interface type, never to an implementation class. Sen generates an interface for every class you declare in STL, and that is what other objects hold: a remote object has no implementation on your side to name. The generated code covers the pair.

Discovering objects on a bus then looks like this:

calculators/src/client.cpp
  void registered(sen::kernel::RegistrationApi& api) override  // (1)!
  {
    calculators_ = api.selectAllFrom<CalculatorInterface>(getCalcBus());  // (3)!
  }
  1. registered() runs once, after the object joins the bus: the point at which the API is available and subscriptions can be set up.
  2. The subscription is kept in a member variable so it outlives this call. If it went out of scope the list would be destroyed and the callbacks would stop.
  3. selectAllFrom<CalculatorInterface>(getCalcBus()) matches every object implementing the generated interface, which is why you subscribe to CalculatorInterface and not to an implementation class.

The member it assigns to is declared as std::shared_ptr<sen::Subscription<CalculatorInterface>> calculators_;, and reading it is calculators_->list.getObjects(). Tutorial 2 shows the same file in full.

selectAllFrom also takes an optional callback, invoked during drain with the objects that have just appeared, which is useful when you want to react to a discovery instead of polling the list.

NOTE: Keep in mind the subscription and the registered callbacks are tied to the lifetime of the subscription object, so when you want to keep a subscription alive you have to store it for the required time. For example, when the subscription should be alive as long as your component is running, you can store it as a member of your component and initialize it in the registered() function of your object.

This calculators_->list container is a sen::ObjectList<T> which acts like an enhanced std::vector<T> where T can be the specific type of the objects that you are interested in.

The same discovery is available one level down, where you build the interest yourself and own the container. selectAllFrom is this with the lifetime taken care of for you, so reach for the lower level when you need to build the query at run time, or when you want to own the list.

test/util/query_test/src/component.cpp
    const auto bus = api.getSource("se.env");
    bus->add(obj_);

    std::ignore = enumSub_.list.onAdded([this](const auto& /*iterators*/) { matchCount_++; });

    const auto interest = sen::Interest::make(
      R"(SELECT query_test.QueryTestClass FROM se.env WHERE currentStatus = "error")", api.getTypes());
    enumSub_.attachTo(bus, interest, true);

The list it subscribes is declared as sen::ObjectList<query_test::QueryTestClassInterface> objectsInError_;, a member. addSubscriber keeps the address of your container rather than a copy of it, so the container has to outlive the subscription. That is the rule stated above, with nothing holding the list on your behalf. The source can stay a local, as it is here: the kernel owns the bus, and you only need to keep the handle if you use it again later, as the school example does to remove its objects.

onAdded on the list is the same idea as the callback selectAllFrom takes, and the query shows a WHERE clause narrowing the interest to objects whose property has a particular value.