Sometimes processes are a useful tool to work around the strict definition of a system and introduce logic in a different way, usually without resorting to the introduction of other components.
EnTT
offers a minimal support to this paradigm by introducing a few classes
that users can use to define and execute cooperative processes.
A typical process must inherit from the process
class template that stays true
to the CRTP idiom. Moreover, derived classes must specify what's the intended
type for elapsed times.
A process should expose publicly the following member functions whether required (note that it isn't required to define a function unless the derived class wants to override the default behavior):
void update(Delta, void *);
It's invoked once per tick until a process is explicitly aborted or it
terminates either with or without errors. Even though it's not mandatory to
declare this member function, as a rule of thumb each process should at
least define it to work properly. The void *
parameter is an opaque pointer
to user data (if any) forwarded directly to the process during an update.
void init();
It's invoked when the process joins the running queue of a scheduler. This happens as soon as it's attached to the scheduler if the process is a top level one, otherwise when it replaces its parent if the process is a continuation.
void succeeded();
It's invoked in case of success, immediately after an update and during the same tick.
void failed();
It's invoked in case of errors, immediately after an update and during the same tick.
void aborted();
It's invoked only if a process is explicitly aborted. There is no guarantee that it executes in the same tick, this depends solely on whether the process is aborted immediately or not.
Derived classes can also change the internal state of a process by invoking
succeed
and fail
, as well as pause
and unpause
the process itself. All
these are protected member functions made available to be able to manage the
life cycle of a process from a derived class.
Here is a minimal example for the sake of curiosity:
struct my_process: entt::process<my_process, std::uint32_t> {
using delta_type = std::uint32_t;
void update(delta_type delta, void *) {
remaining -= std::min(remaining, delta);
// ...
if(!remaining) {
succeed();
}
}
private:
delta_type remaining{1000u};
};
Lambdas and functors can't be used directly with a scheduler for they are not
properly defined processes with managed life cycles.
This class helps in filling the gap and turning lambdas and functors into
full featured processes usable by a scheduler.
The function call operator has a signature similar to the one of the update
function of a process but for the fact that it receives two extra arguments to
call whenever a process is terminated with success or with an error:
void(Delta delta, void *data, auto succeed, auto fail);
Parameters have the following meaning:
delta
is the elapsed time.data
is an opaque pointer to user data if any, nullptr
otherwise.succeed
is a function to call when a process terminates with success.fail
is a function to call when a process terminates with errors.Both succeed
and fail
accept no parameters at all.
Note that usually users shouldn't worry about creating adaptors at all. A scheduler creates them internally each and every time a lambda or a functor is used as a process.
A cooperative scheduler runs different processes and helps managing their life cycles.
Each process is invoked once per tick. If it terminates, it's removed
automatically from the scheduler and it's never invoked again. Otherwise it's
a good candidate to run one more time the next tick.
A process can also have a child. In this case, the parent process is replaced
with its child when it terminates and only if it returns with success. In case
of errors, both the parent process and its child are discarded. This way, it's
easy to create chain of processes to run sequentially.
Using a scheduler is straightforward. To create it, users must provide only the type for the elapsed times and no arguments at all:
entt::scheduler<std::uint32_t> scheduler;
It has member functions to query its internal data structures, like empty
or
size
, as well as a clear
utility to reset it to a clean state:
// checks if there are processes still running
const auto empty = scheduler.empty();
// gets the number of processes still running
entt::scheduler<std::uint32_t>::size_type size = scheduler.size();
// resets the scheduler to its initial state and discards all the processes
scheduler.clear();
To attach a process to a scheduler there are mainly two ways:
If the process inherits from the process
class template, it's enough to
indicate its type and submit all the parameters required to construct it to
the attach
member function:
scheduler.attach<my_process>("foobar");
Otherwise, in case of a lambda or a functor, it's enough to provide an
instance of the class to the attach
member function:
scheduler.attach([](auto...){ /* ... */ });
In both cases, the return value is an opaque object that offers a then
member
function to use to create chains of processes to run sequentially.
As a minimal example of use:
// schedules a task in the form of a lambda function
scheduler.attach([](auto delta, void *, auto succeed, auto fail) {
// ...
})
// appends a child in the form of another lambda function
.then([](auto delta, void *, auto succeed, auto fail) {
// ...
})
// appends a child in the form of a process class
.then<my_process>();
To update a scheduler and therefore all its processes, the update
member
function is the way to go:
// updates all the processes, no user data are provided
scheduler.update(delta);
// updates all the processes and provides them with custom data
scheduler.update(delta, &data);
In addition to these functions, the scheduler offers an abort
member function
that can be used to discard all the running processes at once:
// aborts all the processes abruptly ...
scheduler.abort(true);
// ... or gracefully during the next tick
scheduler.abort();