Learning C# Notes - Part V: Delegates, Events, Actions and Funcs
/The Observer Pattern
Usually when we start using Unity and C#, we have to tackle the question of how classes should send information between each other. One of the most common solutions is to just make methods public and call them from one class to another.
This approach is simple and intuitive but the problem with it is that we create dependencies on the code where if we want to remove a class from the project, this could create errors in many others. The Observer pattern solves this issue by separating communication and functionality.
So the basic idea behind this pattern is that some classes will announce that something has happened while others will receive these messages and act on them. This creates a more modular system where an event can be broadcasted and very different systems can then use that information to trigger specific methods. For example, the event of the player dying could be registered by the audio, scoring and achievement systems.
C# and Unity offer a few different ways of using this pattern. Is quite easy to use but, to be honest, is not always very intuitive at the start, when you are not familiar with the syntax. Let’s see how it works.
Delegate Basics
You can think of delegates as variable types that can be assigned a method as a value. is also important to know that multiple methods could be assigned to the same delegate.
You can declare them in a similar way to how you declare methods. They also have a return type and optional parameters. Se an example below:
delegate void DelegateTest(float n);
If a method matches the return type and parameters of a delegate we can say they are compatible.
void CompatiblMethod(float n) { //Method Functionaility }
We can create a delegate instance and set it equal to any method that is compatible. We can call the method that we set equal to our delegate by using Invoke(), after the delegate instance. A shorthand can be used where we can skip Invoke() and directly use the delegate instance itself.
delegate void DelegateTest(float n); void Start() { DelegateTest myDelegate = MeThodName; myDelegate.Invoke(5f); myDelegate(5f); //Shorthand form } void CompatibleMethod(float n) { //Method Functionality }
Notice how on the first line of the Start() method we assigned the delegate instance to our method without using () after its name. This syntaxis looks weird for sure but it hints to the idea that we are not calling the method itself, we are just assigning it or rather subscribing it to the delegate.
So, basically a delegate allows us to store references to methods inside a variable of type delegate. This, in turn, allows us to pass references to methods inside other methods. See the example below. We are calling CompatibleMethod in an indirect way through AnotherMethod and its delegate.
delegate void DelegateTest(float n); void Start() void AnotherMethod(DelegateTest myDelegate) void CompatibleMethod(float n) { //Method Functionality }
So why complicate things like this? Having the ability to pass methods to other methods by using delegates can allow us to write methods in a more compact and flexible way. If we have a few large methods which contain basically the same instructions but they only differ in a small consistent way, that’s a good candidate for using delegates.
We can use a delegate to turn those multiple methods into a single one whose functionality changes as we pass different small helper methods that are compatible to it. Furthermore, we can then use lambda expressions to make the code even more compact.
This is why when I first looked at this kind of code wizardry, it all looked very strange and cryptic but if you slowly take apart all the components, you can start to understand it. Having very compact code that uses lambda expressions is cool and all but the main point of delegates is to give flexibility and scalability to the way we design and build code.
See an example of all this in Sebastian Lague’s video below:
Using Delegates with the Observer Pattern
Sebastian’s example is confined to a single class but is not hard to see how making delegates static or just accessible to other classes in different ways can start to give us a path to using the observer pattern.
Something else to keep in mind is the important fact that when we invoke a delegate, the delegate MUST have a method already subscribed to it. Otherwise, we will have a null reference exception. This is way is good practice to do a null check when invoking:
if (myDelegate != null) //Or we can use the following shorthand: myDelegate?.Invoke(10f);
We should also consider delegate return types. When working with the observer pattern, it usually doesn’t make sense to use delegates with a return type since many different systems could potentially subscribe to it and only the last method that was called will return the values. Is usually inconvenient to know or keep track of which method subscribed to our delegate last and so, to implement the observer pattern, void delegates are used.
There is another useful shorthand that we can use to subscribe and unsubscribe methods to delegates. It is good practice to always unsubscribe when we know we don’t need to listen anymore, which in Unity usually happens when the component is disabled or the game object destroyed.
public delegate void ExampleDelegate(); public ExampleDelegate exampleDelegate; private void OnEnable() { exampleDelegate += MyMethod; exampleDelegate += ADifferentMethod; } private void OnDisable() { exampleDelegate -= MyMethod; exampleDelegate -= ADifferentMethod; }
Events
Now that we have seen how delegates work, we are ready to look at Events. in a nutshell, events are special delegates that have some specific restrictions which usually reduce the probability of us making mistakes:
You can’t directly assign a method to the delegate form an external class using the = operator. The only thing you can do is to subscribe to it using the += syntax.
You can’t directly call the delegate from another class. So this other class can only receive information from the class containing the delegate but can’t send any to it through the delegate.
As you can see, these restrictions make events a good option to use with the observer pattern. What they really do is to make sure information only flows in the direction we want, which is from the class broadcasting that something happened to the subscribed classes listening to this. To use an event, you just use the event keyword when you define the delegate instance.
See an example below where a Player class broadcasts when the player has died and an audio class subscribes to this event to trigger some audio. Notice how, since we are using the event keyword, the audio class would never be able to trigger the delegate or assign it directly to one of its methods, which would unsubscribe any other methods assigned from other classes.
public class Player { public delegate void DeathDelegate(); public event DeathDelegate deathEvent; void Die() } public class AudioClass { void Start() { FindObjectOfType<Player>().deathEvent += OnPlayerDeath; } public void OnPlayerDeath() { //Play some player death audio } void OnDestroy() { FindObjectOfType<Player>().deathEvent -= OnPlayerDeath; } }
The above example gives a closer look at how the observer pattern would work. Having said that, we still need to see a couple of other delegate types that can be even more useful and convenient to use.
Actions & Funcs
You can think of these as quick ways to create delegates with restrictions that we may find useful.
Actions can have input parameters but can’t have return values.
Funcs can have both input parameters AND return values but the return values are handled as an out value that is always the last input parameter.
When you look at code examples of people implementing the observer pattern, you will usually see that Actions are the delegate type most widely used. This is because we usually don’t need return values and we can declare them in a compact and convenient way. To declare an Action, we use the keyword event AND then Action. This already works as the instance declaration, all in one line. We can also specify the parameter by using <> after the Action keyword.
See an example below of two actions, one of them taking an int parameter:
public static event Action myStaticEvent; public static event Action<int> myStaticEventWithInt; private void Update() { myStaticEvent?.Invoke(); myStaticEventWithInt.Invoke(12); }
In Closing
I know all this looks scary at first but once you understand how it works and know the syntax, it can become a very useful tool for sure. See an additional video below that rounds up all the concepts we have talked about and good luck!