C# Type Tricks
A lot of powerful language features like LINQ require massive performance hits, but today we’ll discuss some easy, low-overhead ways to add some safety and usability to C#.
Synonyms for Integers
Oftentimes we only need an int
, not something more complex like a struct or a class. This is the case for IDs, handles, and plenty of other scenarios. Just using the bare int
type can lead to confusion in APIs and bugs when an int
from the wrong “namespace” of values is used. It’s easy to inadvertently pass an int
that means a number of seconds as a parameter for an int
that the function uses as a number of milliseconds.
We can work around this problem by giving the int
a name. This can be done with a tiny amount of code using an enum like so:
public enum Milliseconds : int { } public enum Seconds : int { OneMinute = 60, // optional values can be convenient }
These enums are exactly the size of int
since that’s their underlying type. Any int
can be cast to or from a Milliseconds
or Seconds
when the actual value needs to be used or arithemetic needs to be performed on it. Until that point, we can use these types to avoid bugs, more clearly express our APIs, and even provide overloads. Here’s an example API:
public class Clock { private Milliseconds time; public void Advance(Milliseconds amount) { time = (Milliseconds)((int)time + (int)amount); } public void Advance(Seconds amount) { // Convert to milliseconds time = (Milliseconds)((int)time + (int)amount * 1000); } }
Notice how we can overload the Advance
function based on the two enum types. The APIs clearly state what units of time to pass. The field clearly states what units it’s stored in without the need for a Millis
postfix or a comment. One downside is the need for casting to perform arithmetic.
Now here’s some example usage of this API:
void Foo(Clock c) { c.Advance(1000); // compiler error, no Advance takes an int c.Advance((Milliseconds)1000); // clearly uses the Milliseconds version c.Advance(Seconds.OneMinute); // clearly uses the Seconds version }
Synonyms for Any Type
Just as with integers like int
, we can create synonym types for any type using structs. All we need to do is create a struct with one element to make a synonym of that type:
public struct Position { public Vector3 Value; public Position(Vector3 val) { Value = val; } } public struct Velocity { public Vector3 Value; public Velocity(Vector3 val) { Value = val; } }
In this case we’ve created synonyms for Vector3
called Position
and Velocity
. We don’t strictly need to provide a constructor, but doing so will avoid some IL2CPP overhead. These synonyms clear up APIs just like the enum did for integers. For example, we can now write this:
public class Particles { public void Add(Position pos, Velocity vel) { // ... } }
Now it’s impossible to mix up the parameters:
struct Bullet { public Vector3 Position; public Vector3 Velocity; } void Foo(Bullet b, Particles p) { p.Add(b.Velocity, b.Position); // compiler error, can't use Vector3 p.Add(new Velocity(b.Velocity), new Position(b.Position)); // compiler error, wrong order p.Add(new Position(b.Position), new Velocity(b.Velocity)); // OK }
Reference Type Wrappers
Many types in C# are value types, meaning they are copied on assignment rather than simply referring to the same object. These include primitives like int
, enums like KeyCode
, and structs like Quaternion
. Sometimes we’d like to have a reference to these types rather than copying their value. Thankfully we can do this easily with a class:
public class QuaternionReference { public Quaternion Value; public QuaternionReference(Quaternion val) { Value = val; } }
Quaternion
is a struct, so it’s a value type. QuaternionReference
is a class, so it’s a reference type. We can pass around QuaternionReference
objects so many variables all refer to the same object. That object simply has a single field which is a Quaternion
. This means we essentially have a reference type version of Quaternion
just by using this simple adapter. The downside is that creating a class will create garbage for the GC to later collect, so we’ll either take that hit or add complexity by pooling these objects.
Here’s an example of how to use this:
class Rotator { public QuaternionReference Q; public void Rotate() { // ... } } void Foo(Rotator r1, Rotator r2, Rotator r3) { Quaternion q = new Quaternion(); QuaternionReference qr = new QuaternionReference(q); r1.Q = qr; r2.Q = qr; r3.Q = qr; }
Foo
gives r1
, r2
, and r3
the same reference to a QuaternionReference
which contains a Quaternion
. So now when any of r1
, r2
, or r3
have their Rotate
called, they’ll rotate the same Quaternion
.
We can go further with this concept to create a read-only reference by simply adding the keyword readonly
:
public class ReadonlyQuaternionReference { public readonly Quaternion Value; public ReadonlyQuaternionReference(Quaternion value) { Value = value; } }
Now it’s illegal to overwrite the wrapped Quaternion
value type. We’ll get a compiler error if we try that now:
void Foo(ReadonlyQuaternionReference qr) { qr.Value = new Quaternion(); // compiler error, Value is readonly }
While it’s trivial to create these wrapper types, we can accept some IL2CPP overhead to use generics so we’ll never need to type them at all:
public class Reference<T> where T : struct { public T Value; public Reference(T value) { Value = value; } } public class ReadonlyReference<T> where T : struct { public readonly T Value; public Reference(T value) { Value = value; } }
Now that these are available, we can use them instead:
class Rotator { public Reference<Quaternion> Q; public void Rotate() { // ... } } void Foo(Rotator r1, Rotator r2, Rotator r3) { Quaternion q = new Quaternion(); Reference<Quaternion> qr = new Reference<Quaternion>(q); r1.Q = qr; r2.Q = qr; r3.Q = qr; }
This technique allows us to write structs instead of classes even when we think that we’ll need reference semantics for the type. When reference semantics aren’t needed, the struct type can be used directly for more efficient code that doesn’t feed the GC. When reference semantics are needed, the Reference
wrapper can simply be used and there’s no additional overhead compared to if we were to have just used a class in the first place.