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)

Function Types Spectrum Diagram

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.