You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This poll is about the redesign of the event handling API. There are some big issues with the current API design, and I will get into more details about them further down.
In my opinion, this redesign is absolutely necessary, and since other APIs might depend on it, this should be prioritized. So I don't plan on doing anything other than the event handling API. We really need a finalized design that's future proof and don't come back all the time to haunt us (like it currently feels like).
I know, that this is kinda of the fourth or fifth time the event API gets redesigned, but that's why we need a final design and that's why I want to get this right by maximizing the amount of community input and feedback on this. In the end, it's you, the users, who will be using this API.
So please feel free to share your thoughts and opinions, even if they differ from the options I listed here. If you have any other ideas, please share them as well.
Thank you in advance for your input and feedback!
So I thought about this and came up with some options.
For some the options, I already did some work, but stopped as soon as I realized that this might not be the best approach. So that's why I came up with some more options to explore and to get some feedback on them before committing more work to any of them.
All of those options can be categorized into four groups:
The "high performance" options
All of the options in this category are somewhat closer to what SDL does on the native side just translated into C#. Therefore they're all very high performance, but they come with the tradeoff of potential misuse resulting in worse performance or even crashes.
How it is currently implemented (no changes). This option is not recommended. The current implementation is garbage. And I can say that, I wrote it.
I won't even provide an example for this option, as it's essentially the same as the example for option 2. If you want to see how it currently looks like, you can check the example in option 2.
Pros:
No work for me.
Cons:
It's garbage code with a garbage API.
This will certainly haunt us in the future.
How it is currently implemented, but with all the event queue stuff moved from the Sdl class to either static methods on the Event struct or to a standalone static EventQueue class in the Sdl3Sharp.Events namespace. Still less work to do; still not recommended.
This one moves all the event queue related stuff from the Sdl class to a dedicated place. This is not only more logical, but it plays well with the idea to create less dependencies on the Sdl class in the future. It only cleans up the API a bit, but it doesn't help with the main issue which is safety and ergonomics. Because it's a good idea to move the event queue related stuff to a dedicated place anyway, all following options will include this change as well, so I won't mention it the next times.
For the sake of completeness, I will provide an example of how to use it, just like for all the other options:
protectedoverrideAppResultOnEvent(Sdlsdl,refEvent@event){if(@event.TryAs<KeyboardEvent>(outvarkeyboardEventRef)){switch(keyboardEventRef.Target){case{Type:EventType.KeyDown,Keycode:Keycode.X,IsRepeat:false}:// Do somethingbreak;case{Keycode:Keycode.Y}:// Do something elsebreak;case{Type:EventType.KeyDown,Keycode:Keycode.Z,IsRepeat:true}:// Do something elsebreak;}}elseif(@event.TryAs<MouseButtonEvent>(outvarmouseButtonEventRef)){switch(mouseButtonEventRef.Target){case{Type:EventType.MouseButtonDown,Button:MouseButton.Left}:// Do somethingbreak;case{Type:EventType.MouseButtonDown,Button:MouseButton.Right}:// Do something elsebreak;}}elseif(@event.TryAs<QuitEvent>(out_)){returnSuccess;}returnContinue;}
Pros:
It's a bit cleaner than the current implementation.
Less work to do.
Cons:
It's still garbage code with a garbage API.
This will still haunt us in the future.
The new API
This is actually what I've been working on for a while now, and I got pretty far with it. You can check it out in the event-API-redesign branch. I stopped working on it when I realized that it might not be the best path forward, and I wanted to explore other options and hear your opinion on them before I commit more work to it.
The central idea is to have a two ref struct pairs called EventRef/EventRefReadOnly and EventRef<TEventData>/EventRefReadOnly<TEventData>. They shall replace the raw refs when handling events, as well as replace the otherwise unused NullableRef<T> and NullableRefReadOnly<T> types by merging their functionality into themselves. This should prevent misuse and accidentally copying large structs (Event is 128 bytes!) for the mose part. Another idea is to have *EventData structs for each event type instead of dedicated structs. Together with the basic Event type and the more specialized Event<TEventData> type as well as making use of C#14's extension members, this mirrors the API design we already have for the Renderer/Renderer<TDriver>, Window/Window<TDriver>, etc. APIs pretty well. Speaking of extension members, EventRef<TEventData> and EventRefReadOnly<TEventData> will have exentsion members based on their type of TEventData argument, that will allow for ergonomic access to the underlying event data without the need to touch their Target property. With the appropriate checks in place, this should guarantee for a safe API.
That's how it's meant to be used:
protectedoverrideAppResultOnEvent(Sdlsdl,EventRef@event){if(@event.TryAs<KeyboardEventData>(outvarkeyboardEvent)){switch(keyboardEvent){case{Type:EventType.KeyDown,Keycode:Keycode.X,IsRepeat:false}:// Do somethingbreak;case{Keycode:Keycode.Y}:// Do something elsebreak;case{Type:EventType.KeyDown,Keycode:Keycode.Z,IsRepeat:true}:// Do something elsebreak;}}elseif(@event.TryAs<MouseButtonEventData>(outvarmouseButtonEvent)){switch(mouseButtonEvent){case{Type:EventType.MouseButtonDown,Button:MouseButton.Left}:// Do somethingbreak;case{Type:EventType.MouseButtonDown,Button:MouseButton.Right}:// Do something elsebreak;}}elseif(@event.TryAs<QuitEventData>(out_)){returnSuccess;}returnContinue;}
(The example might look very similar, but if you look closely, you'll notice that we never left the realm of ref structs, never touched an actual Event, Event<TEventData>, or *EventData struct, while still having a less verbose and more ergonomic API.)
Pros:
It's a bit cleaner and more ergonomic API.
It's a bit safer API by preventing misuse and accidentally copying large structs for the most part.
It's still pretty high performance. We don't introduce any new allocations and no other overhead and we pretty much just wrap SDL's meaning into C#'s type system.
Cons:
A separation between Event/Event<TEventData> and *EventData might not be SDL's original intent, so it's pretty unfaithful to the underlying API.
It's still not as ergonomic as I'd like it to be, and all the different types and their uses might be a bit confusing for users at first.
It's a lot of work to implement. And I mean a lot. Especially mirroring the members of the *EventData structs as extension members on the EventRef<TEventData> and EventRefReadOnly<TEventData> types is kinda tedious.
The "how it's meant to be from an abstraction point of view" options
The main issue with safeness is that we need to prevent the user from accidentally dereferencing an Event pointer into a memory offset that's not meant to be used by its actual event type. Of course, we can hide this pointer behind a type and use pointers to more specialized event types, hidden behind more specialized types, when the user does the appropriate checks. Does this sound familiar? Yes, this actually a good candidate for a OOP design. Although SDL uses its Event type like a discriminated union, that's more of a C limitation.
Specialized *Event types inheriting from a common base Event type is a more natural interpretation of SDL's original intent.
Of course, this would come with pretty big tradeoff in C#. Firstly, we would need to introduce heap allocations and potentially even allocations for every event raised by SDL. Secondly, we would need to introduce an overhead mechanism that looks at every event raised and decides wich specialized *Event type to use for it. Since SDL event types (I'm talking about the EventType enum) are not necessarily contiguous, this would require a huge switch or some sort of lookup table. As I already said, it's kind of a huge performance tradeoff for the benefit of added ergonomics and safety.
Since it'd be still pretty interesting to see how this would look like, here's how I imagine it (I didn't bother to actually implement it, so this is just a sketch):
Making it OOP
As I imagine it, this would lead us to introducing the following conceptual types:
publicabstractclassEvent{privateprotectedunsafeSDL_Event*Pointer{get;}// For the sake of simplicity, I'm going to leave out any more details about the implementation}publicsealedclassKeyboardEvent:Event{privateunsafenewSDL_KeyboardEvent*Pointer=>(SDL_KeyboardEvent*)base.Pointer;publicKeycodeKeycode{get;}// Again, I'm going to leave out any more details about the implementation}// And so on for all the other event types...
As you can see, those types would be pretty much just wrappers around raw SDL event pointers, just like EventRef/EventRefReadOnly and EventRef<TEventData>/EventRefReadOnly<TEventData> in the previous option. If we want to give access to the underlying event structs to the user, we could just publicly define them in the same way they're currently implemented (just don't call them SDL_Event, SDL_KeyboardEvent, etc., but find a more C#-friendly naming scheme for them). This could help or even be required for user defined events.
The real benefit of this design is the ergonomic event handling and the simplicity of use:
protectedoverrideAppResultOnEvent(Sdlsdl,Event@event){switch(@event){caseKeyboardEvent{Type:EventType.KeyDown,Keycode:Keycode.X,IsRepeat:false}keyboardEvent:// Do something with `keyboardEvent`break;caseKeyboardEvent{Keycode:Keycode.Y}:// Do something elsebreak;caseKeyboardEvent{Type:EventType.KeyDown,Keycode:Keycode.Z,IsRepeat:true}:// Do something elsebreak;caseMouseButtonEvent{Type:EventType.MouseButtonDown,Button:MouseButton.Left}mouseButtonEvent:// Do something with `mouseButtonEvent`break;caseMouseButtonEvent{Type:EventType.MouseButtonDown,Button:MouseButton.Right}:// Do something elsebreak;caseQuitEvent:returnSuccess;}returnContinue;}
Pros:
It's a very ergonomic API.
Just like the previous API option, it's pretty safe by design. All the neccessary checks are in place behind the API surface, so we can do our best to prevent misuse.
I would argue that this design is the most faithful to SDL's original intent.
Cons:
Presumably a big performance degradation: We introduce new allocations and we need to touch every event raised beforehand, regardless of whether the user actually wants to handle it or not.
This goes with the performance degradation, but I wanted this to be a separate point: We trade EventType checks for actual runtime type checks. Those are usually way more expensive than just comparing integral values, but they can also be more optimized by the JIT (especially if it can proof the actual type of Event passed to OnEvent), so there's a chance that this could be actually more performant, depending on the circumstances.
Making it OOP, but with object pooling
This is pretty much just option 4, but we could try to pool the *Event objects instead of allocating new ones for every event raised. This could mitigate the performance degradation a bit, but on the other hand, it would require cooperation from the user.
We would "lend" an *Event object to the user, so they can use it in their event handling code, but they would need to somehow explicitly return it. The easiest way of doing this would be to have Event and *Event implement IDisposable, so the user can just use them in a using statement or block. We would need to think about:
What would happen if the user forgets to return a borrowed *Event object?
What would happen if the user stores a reference to a borrowed *Event (this kinda goes with the next point)?
What if the user uses a borrowed *Event after returning it? At some point we would reuse the same object (that's kinda the whole point of pooling), so this could lead to some pretty nasty bugs.
Lastly, object pooling is not some kind of magical silver bullet for performance. We would trade reduced allocations for the overhead that comes with managing the pools and we would need dedicated pools for each kind of *Event type. Depending on the circumstances, this could be actually more expensive than just allocating new objects, but we would have the benefit of reduced GC pressure and reduced memory fragmentation.
Anyway, we would need the user to cooperate and to do something like this:
It could mitigate the performance degradation of option 4 a bit, but it really depends on the circumstances and could be even more expensive.
Cons:
All the cons of option 4, minus some performance degradation, if the object pooling actually helps.
We would need to rely on the user to cooperate and to return borrowed *Event objects. This could lead to some pretty nasty bugs if they forget to do so or if they use a borrowed *Event after returning it.
Added complexity of managing the object pools.
More work to implement, since we would need to implement an additional pooling mechanism on top of the OOP design.
Object pooling can be actually more expensive than just allocating new objects, depending on the circumstances.
The "ergonomic" options
I thought about some kind of compromis between the "high performance" options and the OOP options, while still being pretty "ergonomic". The main idea is to use callback delegates for event handling and restricting the correct type of *Event to the scope of the callback.
Don't worry and don't get me wrong, although this sounds like C# events, it should not replace individual events that you can subscribe to on some objects (e.g., Window.Moved, Mouse.ButtonDown, etc.). Those will stay, regardless of whatever option we choose for the new event handling API. All the options here primarily focus on the OnEvent method and the new surface API for events.
Instead I envision something like this:
publicvoidEventHandler<TEvent>(refTEvent@event)whereTEvent:struct/*, ... */;publicTResultEventHandler<TEvent,outTResult>(refTEvent@event)whereTEvent:struct/*, ... */;publicvoidReadOnlyEventHandler<TEvent>(refreadonlyTEvent@event)whereTEvent:struct/*, ... */;publicTResultReadOnlyEventHandler<TEvent,outTResult>(refreadonlyTEvent@event)whereTEvent:struct/*, ... */;publicstructEvent{/* Again, I'm leaving out the details... */}publicstructWindowEvent{/* ... */}publicstructKeyboardEvent{/* ... */}// And so on for all the other event types...publicstaticclassEventExtensions{publicreadonlystructEventHandlerBuilder<TResult>{publicTResultAs<TEvent>(EventHandler<TEvent,TResult>handler)whereTEvent:struct/*, ... */{// Only call the handler if the event is of the right type, otherwise return the stored default value...}// Omitting the details, but this should store the default return value for the case where the event is not of the right type...}publicreadonlystructReadOnlyEventHandlerBuilder<TResult>{publicTResultAs<TEvent>(ReadOnlyEventHandler<TEvent,TResult>handler)whereTEvent:struct/*, ... */{// Only call the handler if the event is of the right type, otherwise return the stored default value...}// Omitting the details, but this should store the default return value for the case where the event is not of the right type...}extension(refEvent@event){publicvoidAs<TEvent>(EventHandler<TEvent>handler)whereTEvent:struct/*, ... */{// Only call the handler if the event is of the right type, otherwise do nothing...}publicEventHandlerBuilder<TResult>WithDefault<TResult>(TResultdefaultValue){// Return an `EventHandlerBuilder` with the provided default value; the actual callback will be passed to the `On` method of the returned `EventHandlerBuilder`...}}extension(refreadonlyEvent@event){publicvoidAs<TEvent>(ReadOnlyEventHandler<TEvent>handler)whereTEvent:struct/*, ... */{// Only call the handler if the event is of the right type, otherwise do nothing...}publicReadOnlyEventHandlerBuilder<TResult>WithDefault<TResult>(TResultdefaultValue){// Return a `ReadOnlyEventHandlerBuilder` with the provided default value; the actual callback will be passed to the `On` method of the returned `ReadOnlyEventHandlerBuilder`...}}}
I did not implement any of this yet, so it's just a sketch I imagined in my head. The main idea is that we pass references to the correct type of event data to user provided callbacks. This could prevent misuse, but if the refs are not used correctly, this could still result in accidental copies. We would need the user to know how to use C#'s refs semantically correctly.
But other than that let's jump directly to the options that this design opens up for us:
Delegate based event handling
This is how it could be used:
protectedoverrideAppResultOnEvent(Sdlsdl,refEvent@event){@event.As<KeyboardEvent>(ref keyboardEvent =>{switch(keyboardEvent){case{Type:EventType.KeyDown,Keycode:Keycode.X,IsRepeat:false}:// Do somethingbreak;case{Keycode:Keycode.Y}:// Do something elsebreak;case{Type:EventType.KeyDown,Keycode:Keycode.Z,IsRepeat:true}:// Do something elsebreak;}});@event.As<MouseButtonEvent>(ref mouseButtonEvent =>{switch(mouseButtonEvent){case{Type:EventType.MouseButtonDown,Button:MouseButton.Left}:// Do somethingbreak;case{Type:EventType.MouseButtonDown,Button:MouseButton.Right}:// Do something elsebreak;}});return@event.WithDefault(Continue).As<QuitEvent>(ref _ =>Success);}
C#14's new and improved lambda syntax allows us to have pretty ergonomics despite the fact that we are passing refs to the callbacks.
Now let's address the elephant in the room: Yes, that allocates new delegates for every event that the user wants to match, and not only that, but it would also do so for every event raised by SDL, regardless of whether the user actually handles it or not. This is again a pretty big performance tradeoff. Although, I'm not sure if and how much the JIT compiler could optimize here. Essentially, there's a chance that the JIT optimizes the delegates to funclets and can bypass their allocations. But I don't actually know if this is applicable in this scenario, so this might need some benchmarking to find out.
On the other hand, users could cache the delegates themselves and pass reused instances to the As methods instead of instantiating new ones for every event they want to match. This would require users to do some extra work and to be aware of performance implications of allocating new delegates and the benefits of caching them.
Pros:
It's a pretty ergonomic API.
It could be pretty safe by design, but we would require the user to handle refs correctly. But then again, we also require this from the user in the "high performance" options (that's why I don't list this as a con).
Since it's kinda based on the current API design, there might be less work to do in order to implement it compared to the other designs.
Cons:
A potentially big performance degradation: We allocate new delegates for every event that the user wants to match for every event raised.
To avoid some part of the performance impact, we would need the user to cache the delegates they want to use for event matching themselves.
Delegate based event handling, but with a mitigation for over-allocating
This is pretty much just like option 6, but we introduce an early bailout mechanism, so we can reduce the number of allocations per event raised. Essentially, we just swap the As method from option 6 with TryAs methods that return whether the event was matched. This is a simple way to reduce the amortized number of allocations, but would still result in a worst-case scenario of the same performance degradation as option 6.
This is the idiom I imagine for this option:
protectedoverrideAppResultOnEvent(Sdlsdl,refEvent@event){varresult=Continue;_=@event.TryAs<KeyboardEvent>(ref keyboardEvent =>{switch(keyboardEvent){case{Type:EventType.KeyDown,Keycode:Keycode.X,IsRepeat:false}:// Do somethingbreak;case{Keycode:Keycode.Y}:// Do something elsebreak;case{Type:EventType.KeyDown,Keycode:Keycode.Z,IsRepeat:true}:// Do something elsebreak;}})||@event.TryAs<MouseButtonEvent>(ref mouseButtonEvent =>{switch(mouseButtonEvent){case{Type:EventType.MouseButtonDown,Button:MouseButton.Left}:// Do somethingbreak;case{Type:EventType.MouseButtonDown,Button:MouseButton.Right}:// Do something elsebreak;}})||@event.WithDefault(Continue).TryAs<QuitEvent>(ref _ =>Success,outresult);returnresult;}
Pros:
All the pros of option 6.
It could mitigate the performance degradation of option 6 a bit, and amortized it surely does, but it would still result in the same worst-case performance degradation.
Cons:
All the cons of option 6, minus some performance degradation because of the early bailout mechanism.
The ergonomics are a bit worse than option 6.
The surely avoid some allocations, it would be still advisable for users to cache the delegates themselves.
The sparkling new "it's actually meant for that" option
There might be another way to design the API, and it might even turn out to be the best of all worlds. But I fear that it's not quite ready just yet.
I'm talking about C#15's new union types. Since the SDL C API uses SDL_Event as a discriminated union anyway, it could be a pretty good fit fot this use case and we'd just mirror the C API into C#'s type system in a quite faithful way.
At the moment, the details of C#15's union types are not yet fully clear and we would need to wait for it to finalize before we can start thinking about how we want to design the API based on it. But as it stands right now, it looks like it might be pretty hard to do. This might change until C#15's release. So the main issue here is that we need to wait for a finalized, or at least stabilized, design for C#15's union types before we can move forward with the event API design, and sadly, many other APIs that need to be designed in the future rely on the event API, so they would also need to wait.
However, I just wanted to mention this option as a potential modern C# approach to the problem at hand.
Using C#15's union types
There's no concrete design for this option yet, because we need to wait for union types to go out of preview before we can start thinking about how to use them for our API design, if even. But the idea is to have *Eventstruct types and an Eventunion type that aggregates all of them; pretty much just like SDL's C API. (Note: I really hope that this is actually different from the current [StructLayout(LayoutKind.Explicit)] approach. But I believe that union types have some advantages when it comes to pattern matching. So might even end up with an API that can be used in an ergonomic way, just like the OOP options.)
Pros:
It could be a pretty good fit for this use case and we'd just mirror the C API into C#'s type system in a quite faithful way.
Depending on the actual design of union types, we might be able to have a pretty ergonomic API, just like the OOP options.
Cons:
We really need to wait on this. And in turn it blocks the design of other APIs. But it might be worth the wait if it turns out to be a good fit for our use case.
At the moment, it seems like it might be rather hard to design a good API based on the current union type preview.
Some final thoughts that aren't mine
GitHub Copilot with GPT-5.4 reasoned that option 3 would be the best option, and options 4 and 5 would be the worst. You don't necessarily need to agree with that and maybe you shouldn't, but it might be worth mentioning.
What event API design to go for
1. The current design, no changes
0%
2. The current design, moving event queue related stuff into a dedicated location
0%
3. The new high-performance API desing
0%
4. The OOP API design
0%
5. The OOP API design, with object pooling
0%
6. The delegate based API design
0%
7. The delegate based API design, with an early bailout mechanism
0%
8. The API design that C#15's new `union` types would allow us to do
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
This poll is about the redesign of the event handling API. There are some big issues with the current API design, and I will get into more details about them further down.
In my opinion, this redesign is absolutely necessary, and since other APIs might depend on it, this should be prioritized. So I don't plan on doing anything other than the event handling API. We really need a finalized design that's future proof and don't come back all the time to haunt us (like it currently feels like).
I know, that this is kinda of the fourth or fifth time the event API gets redesigned, but that's why we need a final design and that's why I want to get this right by maximizing the amount of community input and feedback on this. In the end, it's you, the users, who will be using this API.
So please feel free to share your thoughts and opinions, even if they differ from the options I listed here. If you have any other ideas, please share them as well.
Thank you in advance for your input and feedback!
So I thought about this and came up with some options.
For some the options, I already did some work, but stopped as soon as I realized that this might not be the best approach. So that's why I came up with some more options to explore and to get some feedback on them before committing more work to any of them.
All of those options can be categorized into four groups:
The "high performance" options
All of the options in this category are somewhat closer to what SDL does on the native side just translated into C#. Therefore they're all very high performance, but they come with the tradeoff of potential misuse resulting in worse performance or even crashes.
How it is currently implemented (no changes).
This option is not recommended. The current implementation is garbage. And I can say that, I wrote it.
I won't even provide an example for this option, as it's essentially the same as the example for option 2. If you want to see how it currently looks like, you can check the example in option 2.
How it is currently implemented, but with all the event queue stuff moved from the
Sdlclass to either static methods on theEventstruct or to a standalone staticEventQueueclass in theSdl3Sharp.Eventsnamespace.Still less work to do; still not recommended.
This one moves all the event queue related stuff from the
Sdlclass to a dedicated place. This is not only more logical, but it plays well with the idea to create less dependencies on theSdlclass in the future. It only cleans up the API a bit, but it doesn't help with the main issue which is safety and ergonomics.Because it's a good idea to move the event queue related stuff to a dedicated place anyway, all following options will include this change as well, so I won't mention it the next times.
For the sake of completeness, I will provide an example of how to use it, just like for all the other options:
The new API
This is actually what I've been working on for a while now, and I got pretty far with it. You can check it out in the event-API-redesign branch. I stopped working on it when I realized that it might not be the best path forward, and I wanted to explore other options and hear your opinion on them before I commit more work to it.
The central idea is to have a two
ref structpairs calledEventRef/EventRefReadOnlyandEventRef<TEventData>/EventRefReadOnly<TEventData>. They shall replace the rawrefs when handling events, as well as replace the otherwise unusedNullableRef<T>andNullableRefReadOnly<T>types by merging their functionality into themselves. This should prevent misuse and accidentally copying large structs (Eventis 128 bytes!) for the mose part. Another idea is to have*EventDatastructs for each event type instead of dedicated structs. Together with the basicEventtype and the more specializedEvent<TEventData>type as well as making use of C#14'sextensionmembers, this mirrors the API design we already have for theRenderer/Renderer<TDriver>,Window/Window<TDriver>, etc. APIs pretty well. Speaking ofextensionmembers,EventRef<TEventData>andEventRefReadOnly<TEventData>will haveexentsionmembers based on their type ofTEventDataargument, that will allow for ergonomic access to the underlying event data without the need to touch theirTargetproperty. With the appropriate checks in place, this should guarantee for a safe API.That's how it's meant to be used:
(The example might look very similar, but if you look closely, you'll notice that we never left the realm of
ref structs, never touched an actualEvent,Event<TEventData>, or*EventDatastruct, while still having a less verbose and more ergonomic API.)Event/Event<TEventData>and*EventDatamight not be SDL's original intent, so it's pretty unfaithful to the underlying API.*EventDatastructs asextensionmembers on theEventRef<TEventData>andEventRefReadOnly<TEventData>types is kinda tedious.The "how it's meant to be from an abstraction point of view" options
The main issue with safeness is that we need to prevent the user from accidentally dereferencing an
Eventpointer into a memory offset that's not meant to be used by its actual event type. Of course, we can hide this pointer behind a type and use pointers to more specialized event types, hidden behind more specialized types, when the user does the appropriate checks. Does this sound familiar? Yes, this actually a good candidate for a OOP design. Although SDL uses itsEventtype like a discriminated union, that's more of a C limitation.Specialized
*Eventtypes inheriting from a common baseEventtype is a more natural interpretation of SDL's original intent.Of course, this would come with pretty big tradeoff in C#. Firstly, we would need to introduce heap allocations and potentially even allocations for every event raised by SDL. Secondly, we would need to introduce an overhead mechanism that looks at every event raised and decides wich specialized
*Eventtype to use for it. Since SDL event types (I'm talking about theEventTypeenum) are not necessarily contiguous, this would require a huge switch or some sort of lookup table. As I already said, it's kind of a huge performance tradeoff for the benefit of added ergonomics and safety.Since it'd be still pretty interesting to see how this would look like, here's how I imagine it (I didn't bother to actually implement it, so this is just a sketch):
Making it OOP
As I imagine it, this would lead us to introducing the following conceptual types:
As you can see, those types would be pretty much just wrappers around raw SDL event pointers, just like
EventRef/EventRefReadOnlyandEventRef<TEventData>/EventRefReadOnly<TEventData>in the previous option. If we want to give access to the underlying eventstructs to the user, we could just publicly define them in the same way they're currently implemented (just don't call themSDL_Event,SDL_KeyboardEvent, etc., but find a more C#-friendly naming scheme for them). This could help or even be required for user defined events.The real benefit of this design is the ergonomic event handling and the simplicity of use:
EventTypechecks for actual runtime type checks. Those are usually way more expensive than just comparing integral values, but they can also be more optimized by the JIT (especially if it can proof the actual type ofEventpassed toOnEvent), so there's a chance that this could be actually more performant, depending on the circumstances.Making it OOP, but with object pooling
This is pretty much just option 4, but we could try to pool the
*Eventobjects instead of allocating new ones for every event raised. This could mitigate the performance degradation a bit, but on the other hand, it would require cooperation from the user.We would "lend" an
*Eventobject to the user, so they can use it in their event handling code, but they would need to somehow explicitly return it. The easiest way of doing this would be to haveEventand*EventimplementIDisposable, so the user can just use them in ausingstatement or block. We would need to think about:*Eventobject?*Event(this kinda goes with the next point)?*Eventafter returning it? At some point we would reuse the same object (that's kinda the whole point of pooling), so this could lead to some pretty nasty bugs.Lastly, object pooling is not some kind of magical silver bullet for performance. We would trade reduced allocations for the overhead that comes with managing the pools and we would need dedicated pools for each kind of
*Eventtype. Depending on the circumstances, this could be actually more expensive than just allocating new objects, but we would have the benefit of reduced GC pressure and reduced memory fragmentation.Anyway, we would need the user to cooperate and to do something like this:
*Eventobjects. This could lead to some pretty nasty bugs if they forget to do so or if they use a borrowed*Eventafter returning it.The "ergonomic" options
I thought about some kind of compromis between the "high performance" options and the OOP options, while still being pretty "ergonomic". The main idea is to use callback delegates for event handling and restricting the correct type of
*Eventto the scope of the callback.Don't worry and don't get me wrong, although this sounds like C#
events, it should not replace individual events that you can subscribe to on some objects (e.g.,Window.Moved,Mouse.ButtonDown, etc.). Those will stay, regardless of whatever option we choose for the new event handling API. All the options here primarily focus on theOnEventmethod and the new surface API for events.Instead I envision something like this:
I did not implement any of this yet, so it's just a sketch I imagined in my head. The main idea is that we pass references to the correct type of event data to user provided callbacks. This could prevent misuse, but if the
refs are not used correctly, this could still result in accidental copies. We would need the user to know how to use C#'srefs semantically correctly.But other than that let's jump directly to the options that this design opens up for us:
Delegate based event handling
This is how it could be used:
C#14's new and improved lambda syntax allows us to have pretty ergonomics despite the fact that we are passing
refs to the callbacks.Now let's address the elephant in the room: Yes, that allocates new delegates for every event that the user wants to match, and not only that, but it would also do so for every event raised by SDL, regardless of whether the user actually handles it or not. This is again a pretty big performance tradeoff. Although, I'm not sure if and how much the JIT compiler could optimize here. Essentially, there's a chance that the JIT optimizes the delegates to funclets and can bypass their allocations. But I don't actually know if this is applicable in this scenario, so this might need some benchmarking to find out.
On the other hand, users could cache the delegates themselves and pass reused instances to the
Asmethods instead of instantiating new ones for every event they want to match. This would require users to do some extra work and to be aware of performance implications of allocating new delegates and the benefits of caching them.refs correctly. But then again, we also require this from the user in the "high performance" options (that's why I don't list this as a con).Delegate based event handling, but with a mitigation for over-allocating
This is pretty much just like option 6, but we introduce an early bailout mechanism, so we can reduce the number of allocations per event raised. Essentially, we just swap the
Asmethod from option 6 withTryAsmethods that return whether the event was matched. This is a simple way to reduce the amortized number of allocations, but would still result in a worst-case scenario of the same performance degradation as option 6.This is the idiom I imagine for this option:
The sparkling new "it's actually meant for that" option
There might be another way to design the API, and it might even turn out to be the best of all worlds. But I fear that it's not quite ready just yet.
I'm talking about C#15's new
uniontypes. Since the SDL C API usesSDL_Eventas a discriminated union anyway, it could be a pretty good fit fot this use case and we'd just mirror the C API into C#'s type system in a quite faithful way.At the moment, the details of C#15's
uniontypes are not yet fully clear and we would need to wait for it to finalize before we can start thinking about how we want to design the API based on it. But as it stands right now, it looks like it might be pretty hard to do. This might change until C#15's release. So the main issue here is that we need to wait for a finalized, or at least stabilized, design for C#15'suniontypes before we can move forward with the event API design, and sadly, many other APIs that need to be designed in the future rely on the event API, so they would also need to wait.However, I just wanted to mention this option as a potential modern C# approach to the problem at hand.
Using C#15's
uniontypesThere's no concrete design for this option yet, because we need to wait for
uniontypes to go out of preview before we can start thinking about how to use them for our API design, if even. But the idea is to have*Eventstructtypes and anEventuniontype that aggregates all of them; pretty much just like SDL's C API.(Note: I really hope that this is actually different from the current
[StructLayout(LayoutKind.Explicit)]approach. But I believe thatuniontypes have some advantages when it comes to pattern matching. So might even end up with an API that can be used in an ergonomic way, just like the OOP options.)uniontypes, we might be able to have a pretty ergonomic API, just like the OOP options.uniontype preview.Some final thoughts that aren't mine
GitHub Copilot with GPT-5.4 reasoned that option 3 would be the best option, and options 4 and 5 would be the worst. You don't necessarily need to agree with that and maybe you shouldn't, but it might be worth mentioning.
0 votes ·
All reactions