Demilade Sonuga's blog
All postsEvent Handling I
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:
- Functions that can manipulate their environments should be supported.
- Functions set as event handlers should be passed info relevant to the event when called.
- 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:
- In the game code, a
hook_event
function is called with a closure as an argument to tell theEventHooker
that the closure must be called when a keyboard event occurs. - The enter key is pressed.
- The keyboard interrupt sets off and its interrupt service routine is invoked.
- Inside this interrupt service routine, a
send_event
function is called to notify theEventHooker
that a keyboard event has occurred, along with the relevant information to identify the key that was pressed. - 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:
- Create a heap (without the heap, there can be no allocator).
- Create an allocator (without the allocator, there can be no
Box
orVec
). - Create a
Box
. - Create a
Vec
. - 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
- Closures: https://doc.rust-lang.org/book/ch13-01-closures.html