Best way to implement a versatile "event" type with custom fields

Hi to everybody,

I come from a Pascal/C/C++/Fortran background and am slowly learning Rust, . What I find really interesting in Rust is its new approach to programming, which avoids many of the traditional OOP constructs I have used countless times in Pascal and C++.

To better understand Rust's approach, I have found a treasure of exercises to do in trying to translate snippets of the Turbo Vision source code in Rust. (Turbo Vision was a Textual User Interface framework that was bundled with Borland Pascal 7.0.) In the early 90s, Turbo Vision programming was my first true experience with an OOP framework, and I learned a lot from it.

At the moment I am trying to understand what is the most Rustacean way to implement the following type:

TEvent = record
  What: Word;
  case Word of
    evNothing: ();
    evMouse: (
      Buttons: Byte;
      Double: Boolean;
      Where: TPoint);
    evKeyDown: (
      case Integer of
        0: (KeyCode: Word);
        1: (CharCode: Char;
            ScanCode: Byte));
    evMessage: (
      Command: Word;
      case Word of
        0: (InfoPtr: Pointer);
        1: (InfoLong: Longint);
        2: (InfoWord: Word);
        3: (InfoInt: Integer);
        4: (InfoByte: Byte);
        5: (InfoChar: Char));
end;

This is an algebraic type, much like Rust's enums, whose purpose is to encode any information about standard events (e.g., mouse movements, keypresses) as well as custom events (using evMessage). Being Turbo Vision an event-driven framework, this type is used ubiquitously, and it is therefore quite versatile. Messages of the type evMessage in particular can be triggered by widgets in response to standard events, and they have custom information attached by means of one of the Info* fields. As an example, when the user operates on a scroll bar, the scroll bar sends an evMessage event with a specific integer ID (the constant cmScrollBarChanged) to its parent view, and a pointer to itself in the InfoPtr field. InfoPtr can then be queried to determine the new position and update other widgets, e.g., scroll a text view or update a text label.

In Rust, I have written the following code, which compiles and runs fine:

use std::any::Any;
// This is is the equivalent of Pascal's message IDs like `cmScrollBarClicked`
const MY_CUSTOM_EVENT: i16 = 1;

#[derive(Copy, Clone, Debug)]
struct Point {
    x: u8,
    y: u8,
}

enum Event {
    Nothing,
    Mouse(u8, bool, Point),
    KeyDown(u16),
    CharDown(char, u8),
    Message(i16, Box<Any>),
}

fn handle_event(ev: &Event) {
    match *ev {
        Event::Message(MY_CUSTOM_EVENT, ref content) => {
            match content.downcast_ref::<String>() {
                Some(s) => println!("Custom message with string \"{}\"", s),
                None => println!("Unable to downcast"),
            }
        }
        _ => println!("Unknown event"),
    }
}

fn main() {
    let ev = Event::Message(MY_CUSTOM_EVENT, Box::new("hello, world!".to_string()));

    println!("Handling an event:");
    handle_event(&ev);
}

However, I fear this code is too literally equivalent to the Pascal's original to be Rustacean. For instance, like the old Pascal code, my implementation does nothing to prevent that the same numerical value for the message ID be used in different places to encode different kinds of events.

Do you think there would be a more robust and idiomatic way to implement this type?

For such customization scenarios, Rust allows you to make the enum generic, which depending on your use cases can be more efficient, more flexible and less fragile than a Box<Any>:

enum Event<MessageType> {
    // ...
    Message(MessageType)
}

You'll typically want to have a "null type" when no customization is being applied. Options for this include reusing the ubiquitous empty tuple, or creating an empty struct / singleton enum specifically for this purpose. Personnally, I prefer creating my own data type, because it allows me to implement whichever external traits I need.

The main tradeoff with this approach is that it makes events more difficult to store (you still need a Box somewhere up the storage hierarchy, but now you need it for every kind of event) and manipulate in a standardized way (you'll need either lots of generics, or a function which can extract "standardized" event statuses from the customizable enum).

If these tradeoffs are not worth it for you, another possibility is to implement a more restricted form of customization by creating a trait which every custom message must implement, and use trait objects for custom messages

trait IMessage {
    // Methods which all custom messages must implement go here
}

enum Event {
    // ...
    Message(Box<IMessage>)
}

This approach is conceptually similar to use of interfaces in Java, if you're familiar with that. It makes events much easier to manipulate than generics, while remaining easier to reason about and safer to use than Box<Any>. The tradeoff is that now, message customization is much more restricted: a client may only rely on the interface provided by the IMessage trait, and cannot access any message-specific details.

Finally, you could stick with Box<Any>, but drop the message type discriminant and use one specific type per message instead:

enum Event {
    // ...
    Message(Box<Any>)
}

struct MyCustomEvent {
    string: String
}

fn handle_event(ev: &Event) {
    match *ev {
        Event::Message(ref content) => {
            match content.downcast_ref::<MyCustomEvent>() {
                Some(e) => println!("Custom event with string \"{}\"", e.string),
                None => println!("Unable to downcast"),
            }
        }
        _ => println!("Unknown event"),
    }
}

I quite like this later approach personally. It strikes a good balance between efficiency, flexibility, and ease of use. Of course, what would make it even easier to use is pattern-matching on Any, which I don't think Rust currently has. With this, you would get the best of your solution and this one.

But as always, the best choice will depend on your requirements.

@HadrienG, thanks a lot for your insightful answer, it is a very comprehensive set of ideas! I like your last one the best, it addresses my biggest concern of ID number clashing.

Thanks a lot again!