I'd like to know how a composable event-driven design with callbacks can be used in Rust. From my existing experimentation, I have come to suspect that the ownership system in Rust is more suited to top-down procedural code and has problems with references to parent objects that are needed for callbacks in event-driven design.
Essentially, I would like to see the Rust equivalent for the following C++ code. The code implements an EventLoop which dispatches Timer events using a busy-loop, a Timer class with a timer_expired callback, and a User class that schedules a timer in intervals of 500ms.
#include <stdio.h>
#include <assert.h>
#include <list>
#include <chrono>
#include <algorithm>
using namespace std::chrono;
// Wrapping code in System class so we can implement all functions within classes declarations...
template <typename Dummy=void>
struct System {
class Timer;
class EventLoop {
friend class Timer;
private:
std::list<Timer *> m_running_timers;
bool m_iterating_timers;
typename std::list<Timer *>::iterator m_current_timer;
void unlink_timer (Timer *timer)
{
auto it = std::find(m_running_timers.begin(), m_running_timers.end(), timer);
assert(it != m_running_timers.end());
if (m_iterating_timers && it == m_current_timer) {
++m_current_timer;
}
m_running_timers.erase(it);
}
public:
EventLoop()
: m_iterating_timers(false)
{
}
milliseconds get_time()
{
return duration_cast<milliseconds>(system_clock::now().time_since_epoch());
}
void run()
{
while (true) {
milliseconds now = get_time();
m_iterating_timers = true;
m_current_timer = m_running_timers.begin();
while (m_current_timer != m_running_timers.end()) {
Timer *timer = *m_current_timer;
assert(timer->m_running);
if (now >= timer->m_expire_time) {
m_current_timer = m_running_timers.erase(m_current_timer);
timer->m_running = false;
timer->m_callback->timer_expired();
} else {
++m_current_timer;
}
}
m_iterating_timers = false;
}
}
};
struct TimerCallback {
virtual void timer_expired() = 0;
};
class Timer {
friend class EventLoop;
private:
EventLoop *m_loop;
TimerCallback *m_callback;
bool m_running;
milliseconds m_expire_time;
public:
Timer(EventLoop *loop, TimerCallback *callback)
: m_loop(loop), m_callback(callback), m_running(false)
{
}
~Timer()
{
if (m_running) {
m_loop->unlink_timer(this);
}
}
void start (milliseconds delay)
{
stop();
m_running = true;
m_expire_time = m_loop->get_time() + delay;
m_loop->m_running_timers.push_back(this);
}
void stop ()
{
if (m_running) {
m_loop->unlink_timer(this);
m_running = false;
}
}
};
class TimerUser : private TimerCallback {
private:
Timer m_timer;
public:
TimerUser(EventLoop *loop)
: m_timer(loop, this)
{
m_timer.start(milliseconds(500));
}
private:
void timer_expired() override
{
printf("Timer has expired!\n");
m_timer.start(milliseconds(500));
}
};
};
int main ()
{
System<>::EventLoop loop;
System<>::TimerUser user(&loop);
loop.run();
return 0;
}
The code works as standard C++14 and I believe is correct. Note, in a serious implementation I would make the running_timers an intrusive linked-list not a std::list for performance reasons.
Here are some properties of this solution which I need to see in a Rust equivalent:
- Timers can be added/removed without restrictions, there are no limitations on how/where a Timer is allocated. For example one can dynamically manage a list of classes each using their own timer.
- In a timer_callback, the class being called back has full freedom to access itself, and the timer it is being called from, e.g. to restart it.
- In a timer_callback, the class being called also has freedom to delete itself and the timer. The EventLoop understands this possibility.
I can show some things I've tried but I don't think it will be useful. The major pain point I'm having is satisfy the borrowing rules with all the references to parent objects involved for callback traits.
I suspect a RefCell or something similar might be part of a solution, possibly one or more special classes with internal unsafe parts, that allow gluing stuff together. Maybe some parts of reference safety typically provided by Rust could only be guaranteed at runtime by panicking.
Update
I have created a prototype implementation of this in Rust, but it is not safe. Specifically:
- Timers and the EventLoop must not be moved. If they are accidentally moves undefined behavior occurs due to use pointers to these. It is not possible to even detect this within Rust.
- The callback implementation is a hack but should work. Note that this allows the same object to receive callbacks from two or more Timers, something that is not possible if traits were used for callbacks instead.
- The callbacks are unsafe due to use of pointers that point to the object that is to receive the callback.
- It is theoretically possible for an object to delete itself, but this actually seems unsafe in Rust. Because if a Timer callback ends up deleting itself, there would be a &self reference at one point that is not valid. The source of this unsafety is the use of pointers for callbacks.