I’ve never been totally convinced about callbacks. They are extremely powerful but kinda mess everything up. Whenever I deal with code that uses them, it never feels like the «right» thing to use for the task.
Once you’ve set up a callback, the class that contains the callback method loses control of when it is called. Yes, we’ve supposedly configured an event that is called once something specific happens, but we rely on some other piece of code to properly understand and handle when that’s supposed to happen.
Also, there is no visual connection in the code between what triggers the call and the call itself. We subscribe to an event and somewhere else define the callback itself. Whoever comes after us needs to go back and forth around the code and make the proper mental connections to understand what’s going on.
On the other hand, I find asynchronous code a lot more idiomatic. Let’s take a look at the following example that I’m just making up as I write:
// Using event callbacks
public void Setup(){
NetworkManager.Instance.OnChatMessageSent += OnChatMessageSent;
}
private void OnDestroy(){
NetworkManager.Instance.OnChatMessageSent -= OnChatMessageSent;
}
………
private void OnChatMessageSent(MessageData message){
… // Handle chat UI
}
//-------------------------
//-------------------------
// Using UniTask
public void Setup(){
HandleChatEvents().Forget();
}
…
public async UniTaskVoid HandleChatEvents(){
while(this != null && NetworkManager.Instance != null){
MessageData message = await NetworkManager.Instance.OnChatMessageSentTCS;
… // Handle chat UI
}
}
Although the 2 approaches are very close, I think it’s immediately clear for anyone familiar with async code, what the 2nd implementation is doing. I like how explicitly we declare to be waiting for something to happen.
The two ideas at play here are very different mental models:
- I set up a method and someone, at some moment, will call it. I send a token (a callback) and expect someone to properly handle it.
- I «await» for something specific to happen, halting the execution until that condition is met. It’s perfectly clear what I am waiting for and the before-during-after sequence is easy to follow because it’s perfectly sequential in the code itself. In this case I retrieve (instead of sending) a token (a task) and I am in charge of what to do with it.
As an added benefit, here NetworkManager doesn’t need to care at all about who’s subscribed to the event, and we don’t need to care about unsubscribing at all. NetworkManager will just resolve its TaskCompletionSource without caring about who’s listening.
I believe this approach makes code easier to follow and mistakes much more unlikely. I know some developers will be hesitant to use asyncronous code like this, but I have yet to find a good reason not to use it.
The only downside I found versus a callback is memory allocation, since a task will need to be realocated after every resolution. Its memory impact is low, though, so it should not be a problem unless the events are happening extremely often.
For those who might not be familiar with TaskCompletionSource: it allows us to create a sort of «token» that can be awaited from asyncronous code, but remains inert until some other piece of code «resolves» it. The most common use-case for TaskCompletionSource is actually to make callback legacy code awaitable, for example:
// Before - classic (basic) example of callback hell
public void Setup(){
Login(success => {
…… // Handle login status in a nested lambda function
});
}
private void Login(Action<bool> callback)
{
NetworkManager.Instance.Login(result => {
switch(result)
{
case NetworkManager.LoginStatus.Success:
callback?.Invoke(true);
break;
case NetworkManager.LoginStatus.Canceled:
case NetworkManager.LoginStatus.Failed:
Debug.LogError($"Login failed with status {result}");
callback?.Invoke(false);
break;
}
});
}
// After - we can't change NetworkManager's API but we can break callback hell using a TCS
public async UniTaskVoid Setup(){
var success = await Login();
…… // Handle login status in a subsequent line after awaiting
}
// Note Login does not need to be async itself because it's not awaiting anything
private UniTask<bool> Login()
{
UniTaskCompletionSource<bool> tcs = new UniTaskCompletionSource<bool>();
NetworkManager.Instance.Login(result => {
switch(result)
{
case NetworkManager.LoginStatus.Success:
tcs.TrySetResult(true);
break;
case NetworkManager.LoginStatus.Canceled:
case NetworkManager.LoginStatus.Failed:
Debug.LogError($"Login failed with status {result}");
tcs.TrySetResult(false);
break;
}
});
return tcs.Task;
}
Here, NetworkManager is supposed to be out of reach, either because we don’t want to mess with it or because its API is out of our jurisdiction. TaskCompletionSource, though, allows us to handle it like a task and thus stop nesting callbacks.
Unlocking asyncronous thinking as a programmer can make our code a lot cleaner and easier to understand, specially in interactive applications like videogames.
Deja un comentario