Function Types: A Spectrum
C# gives us lots of types of functions for us to call. We must constantly decide between them. Should this function be static
? Should it be virtual
? There are many factors that go into making the decision. Today we’ll look at the function types as a spectrum and hopefully get a little perspective on our options.
Static Functions
Example:
// Define public class GameUtils { public static float GetDamage(float range) { return Math.Max(10.0f, 200.0f-range); } } // Invoke float damage = GameUtils.GetDamage(50.0f);
Static functions are the simplest type of function. They can only access their parameters and static
variables, which are essentially global and should be extremely few. This is hugely advantageous because we can look at a static function and only have to think about the code we see in front of us. Invoking one means basically just jumping to the memory address of the function’s code, which is known at compile time. They’re as simple and fast as can be.
On the downside, static functions are very rigid. They lack all of the tricks offered by the rest of the types we’re about to talk about.
Instance Functions
Example:
// Define public class Weapon { public float m_Level; public float GetDamage(float range) { return m_Level * Math.Max(10.0f, 200.0f-range); } } // Invoke Weapon weapon = new Weapon { m_Level = 3 }; float damage = weapon.GetDamage(50.0f);
Instance functions are slightly more complex. They can access their parameters, static
variables, the fields and functions of their class, and the fields and functions of their base classes. These class fields and functions are reached via an invisible this
parameter which is listed before the function call: weapon.GetDamage(50.0f)
instead of GetDamage(weapon, 50.0f)
. Compared to static functions, we must now inspect the rest of the class and the base classes in order to find all of the data and functions the function may access due to the extra this
parameter.
In the case of an instance function on a class, the compiler automatically generates a check to see if the this
parameter is null
. If it is, an exception is thrown and the game probably has severe errors as a result. The check also slows down the function call compared to static function calls that never have any checks. That is to say, a null
parameter is OK with a static function but only OK with explicit parameters of an instance function on a class.
Instance functions are also pretty rigid. They also can’t perform many of the the other types’ tricks, such as being overridden. For that, we’ll need virtual functions…
Virtual Functions
Example:
// Define public class Weapon { public float m_Level; public virtual float GetDamage(float range) { return m_Level * Math.Max(10.0f, 200.0f-range); } } public class Crossbow : Weapon { public override float GetDamage(float range) { return 2.0f * base.GetDamage(range); } } // Invoke Crossbow weapon = new Crossbow { m_Level = 3 }; float damage = weapon.GetDamage(50.0f);
Virtual functions, which includes abstract
functions, are a special kind of instance functions. They are called indirectly via a “function pointer.” This is the memory address of the function to call, which isn’t known at compile time. This allows derived classes like Crossbow
to overwrite that function pointer in its constructor. The actual function pointer, its initialization, and its overwriting are all invisible. This function pointer, its lookup, and the indirect call based on its address all contribute to slowness compared to non-virtual
instance functions and static functions.
When we look at Weapon.GetDamage
, we can’t know if this version of the function will be called or if a dervied version such as Crossbow.GetDamage
will be called. The possibilities grow as more derived classes are added, especially multi-level inheritance that forms a class hierarchy tree. The function call has become stateful because it depends on the type of the object used as this
, which may vary at runtime due to polymorphism.
Even still, virtual functions lack some of the tricks of even fancier function types…
Interface Functions
Example:
// Define public interface IWeapon { float GetDamage(float range); } public class Crossbow : IWeapon { public float m_Level; public float GetDamage(float range) { return 2.0f * m_Level * Math.Max(10.0f, 200.0f-range); } } // Invoke Crossbow weapon = new Crossbow { m_Level = 3 }; float damage = weapon.GetDamage(50.0f);
Interface functions are very similar to virtual functions. The major difference is that the interface defines what is essentially an abstract
function and is itself essentially an abstract
class. One major difference is that classes and structs can “implement” multiple interfaces while classes can only “extend” one class and structs can’t “extend” other structs at all. The complexity growth here comes in two ways. First, its possible to have a variable with an interface type such as IWeapon
. When looking at such code, there is no longer a possibility that its implementation of the function is that one that will be called because it doesn’t have an implementation. Instead, we must search elsewhere to try to find the class that implements that interface and is the instance the line of code is going to have when it makes the call.
Second, multiple interfaces may have functions with the same name and this will lead to conflicts. Consider IEnumerable
and IEnumerable<T>
. Both have GetEnumerator
but one returns IEnumerator
and the other returns IEnumerator<T>
. To implement both interfaces, as is common, the ambiguity must be resolved, the syntax is usually found to be particularly tricky, and determining which will be called in which circumstances is complicated.
Even at this function type’s level of complexity, we can still go further and implement even more complex kinds of functions. It’s even common to do so!
Delegates
Example:
// Define public delegate float GetDamage(float range); public class Crossbow : IWeapon { public float m_Level; public float GetDamage(float range) { return 2.0f * m_Level * Math.Max(10.0f, 200.0f-range); } } // Invoke Crossbow weapon = new Crossbow { m_Level = 3 }; GetDamage del = new GetDamage(weapon.GetDamage); float damage = del(50.0f);
Delegates, often incorrectly called events, are objects that represent functions. GetDamage
is a delegate type. It can refer to zero or more functions of any type: static, instance, virtual, interface, and even other delegates! The result is that calling a delegate may call zero functions, one function, multiple functions, or a whole tree of functions due to delegates referring to delegates referring to more delegates. The definitions of these functions is extremely opaque as even the type of function is uknown, let alone the type that declared it.
Further, lambdas and anonymous functions are delegates that the compiler generates closures for. Consider this:
// Define public delegate float GetDamage(float range); public class Crossbow : IWeapon { public float m_Level; public GetDamage GetGetDamageDelegate(float base) { return range => base + 2.0f * m_Level * Math.Max(10.0f, 200.0f-range); } } // Invoke Crossbow weapon = new Crossbow { m_Level = 3 }; GetDamage del = weapon.GetGetDamageDelegate(5.0f); float damage = del(weapon.GetDamage);
The lambda inside GetGetDamageDelegate
means that a new class is created with a Crossbow
field, a float base
field, and an instance function that looks like the body of the lambda except that it gets m_Level
via that Crossbow
field’s m_Level
and base
via the float base
field. When GetGetDamageDelegate
is called, a new instance of the class is intantiated and its Crossbow
field is set to this
. This means this
and base
are “captured” at its current value and used later on when the delegate is invoked.
The result is an even more complex function since all of this is invisible and we must remember the present state of all, also invisibly, captured variables. If those variables are reference types, such as Crossbow
, then changes to one instance (captured or not) affects all the other instances. There are so many invisible constructs and actions that it can be truly mind-bending to figure out what’s happening when a delegate is invoked.
The functions the delegate refers to may be null
and the delegate itself may also be null
. This means the compiler inserts a check for null
at the point where the delegate is invoked as well as the point where each function it refers to is invoked.
Clearly, this all has a cost. These null
checks are part of it, but not all. The delegate must maintain an internal list of all the functions it refers to. This list may grow arbitrarily large and shrink back to zero. This means there will be copying, reallocation, and garbage creation as functions are added. To invoke the delegate, this list must be iterated and a series of “function pointers” with null
checks must be performed. During invocation, referenced functions may modify the delegate by adding or removing functions. The delegate must make sure it still calls all of its referred functions at the time invocation began. While implementation-dependent, this is complicated logic that takes a performance toll.
RPCs
Example:
byte[] parameters = new byte[] { 1, 2, 3, 4 }; WWW www = new WWW("https://server:5000", parameters); while (!www.isDone) { } byte[] returnValue = www.bytes;
An RPC is a Remote Procedure Call. In this case the body of the function doesn’t even necessarily execute on the same computer as the game. It may be written in another language. It may execute on another CPU or even GPU architecture. It may execute half way around the world or in outer space. Parameters to such a function are always serialized to bytes, so the C# type system is completely ignored. Likewise, return values are whatever data is sent back, again in the form of bytes.
Such calls are asynchronous, so they need to be checked for completion in order to even know that they’re done. That completion may be in the form of errors, even if the parameters are valid, due to various network errors or problems on the host computer. Tracking down problems with this kind of call enters the realm of computer networking and is its own specialty, unlike general usage of all of the other function types.
Latency is typically abysmal. Single digit millisecond times are considered excellent, but that is a million times longer than the nanoseconds that all other function types take. The advantage is that the processing work is performed on another machine, allowing even a lowly mobile phone to take advantage of high-end processors in some data center. So while latency is awful, overall performance may actually improve if the function is sufficiently complex. That’s not typically the case for games, except when receiving entire frames from a streaming service.
Conclusion
The following diagram shows the function call spectrum described above: (not to scale)
We have options from the simple, fast, and rigid static functions all the way to the complex, slow, and flexible RPCs. Which is the right type of function for your task? It entirely depends on the task. It’s best to know your options, the tradeoffs involved, and make an informed decision.
#1 by Hessel on April 6th, 2020 ·
Nice overview. Are local functions left out on purpose, if not I think they’re worth a mention :)
#2 by jackson on April 6th, 2020 ·
Great catch! Local functions are similar to static functions since they’re not attached to an instance of a class or struct and they can’t be a variable like a delegate can. On the spectrum, I’d put them just to the right of static functions because they can access local variables and parameters even though they’re not in the local function’s parameter list.
For more detail on the C++ that IL2CPP generates, check out my article from last January.
#3 by B Porter on April 11th, 2020 ·
Awesome read, this is some of the best C# / Unity deep dive info out there!
I did some tests in the Unity profiler (using ProfilerMarker and Auto()), and confirmed what you said except for one thing. My static and instanced functions came out exactly the same speed (I made sure it’s not overhead from a heavy function, etc). Is that because it’s in the Unity editor (and not IL2CPP build)?
Also, is dotPeek still the easiest way to see the IL2CPP compiled code? I haven’t delved into that yet, would be awesome if you had an article on that.
PS – (hope my terminology is right here), I compared (I made 4 classes total) an abstract class overrided function to a Virtual base class function, and it’s derived virtual overrided function. The abstract one came out the fastest, just a tad faster than the virtual base class function. Both of those around 15% faster than the virtual overrided function.
#4 by jackson on April 12th, 2020 ·
Thanks, I’m glad you liked it. :)
This is a pretty high-level article, so there are a lot of little details that aren’t fully caveated. This is one of them since various compilers (Burst, IL2CPP, C++, etc.) involved may optimize some of the overhead. This is especially true in the case of instance functions. For example, if you’re not using
this
in the function then the compiler can omit it. If it can prove thatthis
can’t benull
, such as calling an instance function right after the instance is created, then it can omit thenull
checks. You can also omit thenull
checks yourself with an IL2CPP attribute. So these optimizations, options, and nit-picky testing details will really affect the numbers.#5 by B Porter on April 13th, 2020 ·
Great, I’m reading up now (https://docs.unity3d.com/Manual/IL2CPP-CompilerOptions.html)