Demilade Sonuga's blog

All posts

Event Handling I

2023-03-12 · 27 min read

Based on the requirements covered in the previous post, we're going to begin constructing a scheme for event handling.

In the previous post, I recommended that you come up with a scheme yourself and if you did, you can implement it. In this post, we'll be constructing my own scheme and the reasoning I went through to arrive at it. Depending on who you are, yours may be better than mine, but we'll stick with mine so we'll be on the same page. Still, you can just implement yours.

An Event Handling Scheme

The requirements outlined in the previous post are as follows:

  1. Functions that can manipulate their environments should be supported.
  2. Functions set as event handlers should be passed info relevant to the event when called.
  3. When a hardware interrupt occurs, all event handler functions for that event should be called.

For the first requirement, we can use closures. Closures are functions that can access variables in their environment. If you're not already familiar with them, it'll be discussed later.

For the second requirement, we can create a sort of structure like an enum that can hold information about an event and impose a restriction on the closures that they must all accept that structure as an argument.

For the third requirement, we can create one mechanism that we can use to register functions and a separate one that we can use to notify that an event has occurred.

As for our implementation, we're going to do this in a rather object-oriented manner. Our first and foremost game player is going to be the EventHooker, a structure that is going to be the mediator between the interrupts that occur and the game code that registers functions to be invoked when events occur.

The role of this structure is simple. Relevant interrupt service routines will notify it whenever an interrupt occurs. Game code (or any other client code) will register functions with it. Whenever the ISRs notify the EventHooker, it calls all functions associated with the interrupt's event. For example, consider the following sequence of events:

  1. In the game code, a hook_event function is called with a closure as an argument to tell the EventHooker that the closure must be called when a keyboard event occurs.
  2. The enter key is pressed.
  3. The keyboard interrupt sets off and its interrupt service routine is invoked.
  4. Inside this interrupt service routine, a send_event function is called to notify the EventHooker that a keyboard event has occurred, along with the relevant information to identify the key that was pressed.
  5. The EventHooker looks up its registered functions for keyboard events and calls them all.

The above sequence shows all the parts of the event handling scheme in action. hook_event to register functions. send_event to notify an event has occurred. And the EventHooker in the middle, coordinating everything. If you think about this scheme a little, you'll also realize that we'll need a function to unregister functions. Let's call this one unhook_event.

Now, to figure out the structure of the EventHooker itself. Create a new file event_hook.rs

In event_hook.rs

// Mediator between the game code and the interrupt service routines
pub struct EventHooker {

}

The first thing that comes to mind is that we need a way to keep track of all the registered functions. To do this, we need a structure that provides a way to add functions, call those functions when needed and remove them when requested. There are several data structures that we could use to implement this, but we'll use the simplest one that is good enough for our purposes: the vector. The vector is just like an array, but when it no longer has enough space for another item, it allocates more memory. The vector in Rust has a push function for adding new items to its end and a remove function for removing items at specified indexes.

Our EventHooker will now look like this:

pub struct EventHooker {
// The functions that will be called when an event occurs
handlers: Vec<?>
}

Now, we have two problems. First: where do we import Vec from? Second: a vector of what?

The thing about Vec is that the no-std implementation of it that we can use is in the alloc library, not core. And we're not using the alloc library. This implies that to use a Vec, we'll have to create our own Vec. The thing about these vectors is that their memory is allocated through an allocator from a heap. And the thing about the heap is that we have to create one ourselves and create an allocator for it ourselves. After all, we're doing everything from scratch. Brace yourself. This is what you signed up for.

As for the second problem, a vector of what. It's at this point that we need to get a deeper understanding of closures. If you already know the nitty-gritty details about how closures work then you can skip the next section.

A Few Things About Closures

Consider this:

fn main() {
let mut x = 1;
println!("{:?}", x);
let mut closure = || x = x + 1;
closure();
println!("{:?}", x);
}

If you run the above on your computer, you'll see 1 on the first line and 2 on the next, indicating that calling closure increased x. closure doesn't take any arguments. It just makes use of x in the function because x is in the environment (on the stack).

If you make a slight change to this code:

fn main() {
let mut x = 1;
// DELETED: println!("{:?}", x);
let mut closure = || x = x + 1;
println!("{:?}", x); // NEW
closure();
println!("{:?}", x);
}

It will result in ownership errors. The reason for this will be explained shortly.

Closures don't have any specific types associated with them. Every closure has a unique type. The reason for this will also be explained shortly.

In Rust, a lot of behavior is encoded in the trait system. Structures that can be written into implement Write, like our Screen when we were modeling UEFI components. Structures that can be displayed on the screen implement Display. Structures that can be used in a bitwise-and operation implement BitAnd. Structures that can be cloned implement Clone. Structures that can be copied implement Copy. And likewise, structures that can be called like functions also implement certain traits.

These traits are Fn, FnOnce, and FnMut. All function pointers (regular functions) implement these traits. As for closures, the compiler infers which one to implement from the way the closure is used. The traits are very similar, all indicating something that can be called like a function, with slight differences.

FnMut is like the umbrella function trait. This is for closures that mutate captured values. For example, in the code sample at the beginning of this section, the closure let mut closure = || x = x + 1; mutates its environment (the x), so it must implement FnMut.

A closure that doesn't mutate its environment implements the Fn trait.

And the closure that can be called once implement FnOnce.

If the compiler sees that your closure is called only once and doesn't mutate anything, it implements FnOnce. If the closure is called more than once and doesn't mutate anything, it implements both FnOnce and Fn. If the closure mutates captured values, it implements FnOnce, Fn, and FnMut. Hence, the idea of FnMut as the umbrella function trait.

To really see how all this stuff works, let's go back to the first code sample. The closure syntax is just sugar for creating a new struct whose fields contain the environment the closure captures and implements the required function traits. In other words, the first code sample can also be seen as:

fn main() {
let mut x = 1;
println!("{:?}", x);
// let mut closure = || x = x + 1; THE SAME AS:
let mut closure = {
struct Environment {
x: &mut i32
}
impl FnOnce for Environment { /* ...Others */ }
impl Fn for Environment { /* ...Others */ }
impl FnMut for Environment { /* ...Others */ }
Environment { x: &mut x }
};
closure();
println!("{:?}", x);
}

The translation is not exact and if you run this code it probably won't compile but it's a fairly accurate description of what the compiler does when it encounters a closure in your code.

Because the closure mutates the local variable x, it needs a mutable reference to it. If it only read x, the corresponding field in Environment would have been an immutable reference. If the closure didn't access any variables in its environment, it would not have had to take any references.

It's now easy to see why the second code sample doesn't compile:

fn main() {
let mut x = 1;
// DELETED: println!("{:?}", x);
let mut closure = || x = x + 1;
println!("{:?}", x); // NEW
closure();
println!("{:?}", x);
}

The creation of the closure creates a mutable reference to x. The println!("{:?}", x) creates an immutable borrow of x. The invocation of closure then makes use of the mutable reference. The compiler doesn't let this compile because it sees a violation of ownership rules: you cannot have a mutable and immutable reference to the same thing at the same time.

This also explains why closures need to be declared mutable if they are going to be mutating their environment. Closures that mutate their environment hold mutable references to those local variables they're mutating. And the mutation is done through those mutable references.

This also explains why closures don't have any associated types with them, why each of them has its own unique type. Each closure results in a new unique struct generated just for the closure's captured environment. In other words, each closure has its own type.

This is not everything there is to know about closures. For that, you can check the references (and beyond).

Other Things We Need For The Scheme

To keep a vector of closures, we need to keep them behind a layer of indirection. We need boxes.

Remember a Box is just a pointer with some other info (a smart pointer) that keeps its contents on the heap. We can keep a closure in a Box, keep those boxes in the vector and access the closure through the boxes.

pub struct EventHooker {
// The functions that will be called when an event occurs
handlers: Vec<Box<dyn FnMut(?)>>
}

The Box<dyn FnMut(ArgType)> is Rust's syntax for a trait object holding a function that takes a single argument of type ArgType. If you don't really know what a trait object is, forget it for now.

It was mentioned earlier that event handler functions should be forced to take a single argument that gives information about events that occur. This can be done through the type system. Since the only events we have are timer and keyboard events, those are the only ones we need to think about. When a timer event occurs, there is no special information that needs to be kept track of. A timer tick is simply a timer tick. No extra info is needed. But when a keyboard event occurs, we need to know which key exactly has been pressed. We need to know the specifics of the key code and the direction. This either timer or keyboard with info scenario gives rise to an enum.

use crate::keyboard::KeyEvent;

// The info about an event that will be passed into a handler
// when it is called
pub enum EventInfo {
Timer,
Keyboard(KeyEvent)
}

The KeyEvent struct already contains info about the key code and the key direction, so that's all we need for the keyboard event info.

The handler functions must accept an EventInfo as an argument.

pub struct EventHooker {
// The functions that will be called when an event occurs
handlers: Vec<Box<dyn FnMut(EventInfo)>>
}

At this point, we can't move forward without a real vector, box and allocator. We need to create these utilities before we can move forward. Our checklist goes like so:

  1. Create a heap (without the heap, there can be no allocator).
  2. Create an allocator (without the allocator, there can be no Box or Vec).
  3. Create a Box.
  4. Create a Vec.
  5. Get on with the event handling scheme.

Take Away

  • Closures are structures that implement function traits that take references to local variables when they are needed.

For the full code, go to the repo

In The Next Post

We'll start with the new list

References