Strongly-Typed Integers
An int
can be anything: points, health, currency, time, etc. We often make mistakes using one int
where another int
was supposed to go. Imagine a function DoDamage(int, int)
. It’s not obvious what the parameters mean. Today we’ll use the C# type system to make the code much more readable and less error-prone!
Inspiration
.NET has a great type called TimeSpan. It provides us with a way to specify a time duration without needing to use something like a long
that represents a number of ticks. Internally, that’s all a TimeSpan
is though. It’s just a struct with one long
field.
There are several big advantages to using TimeSpan
over just a long
. First, it’s obvious when you see TimeSpan
that it represents a span of time:
void Fire(Enemy e, long d); // What is d? Time? void Fire(Enemy e, TimeSpan d); // d is clearly the duration
Next, TimeSpan
removes the need to explitly deal with units of time. There’s no danger that someone will pass milliseconds to Fire
when it’s expecting ticks. The reason is that all units of time are handled explicitly by well-named functions:
TimeSpan delay = TimeSpan.FromMilliseconds(100); // Clearly 100ms double seconds = delay.Seconds; // Clearly seconds
Finally, since TimeSpan
is a struct it can contain lots of easily-discoverable methods to provide a feature set related to spans of time. This includes the above unit conversions as well as parsing time strings and overloaded operators.
Making Our Own Type
It turns out that it’s really easy for us to create our own strongly-typed integer. Just like how TimeSpan
strongly types a long
, we can do the same with any other units we might have. Consider a game that includes currency denominated in bronze, silver, and gold. 100 bronze is a silver and 100 silver is a gold. Of course .NET doesn’t provide us a type for this, so we’ll make one ourselves!
First, wrap an integer in a struct named after the type of units:
// Currency whose denominations are gold, silver, and bronze // 100 bronze to a silver // 100 silver to a gold public struct Currency { // Amount of Bronze, which may be over 100 private uint Amount; }
Then provide functions to get each denomination. Here’s a Gold
property:
public uint Gold { get { return Amount / 10000; } set { uint bronze = Amount; uint gold = bronze / 10000; bronze -= gold * 10000; uint silver = bronze / 100; bronze -= silver * 100; Amount = value * 10000 + silver * 100 + bronze; } }
On the reverse side, provide factory functions to explicitly create a Currency
from a particular denomination:
public static Currency FromGold(uint gold) { return new Currency { Amount = gold * 10000 }; }
Now provide overloaded operators like +
:
public static Currency operator +(Currency a, Currency b) { return new Currency { Amount = a.Amount + b.Amount }; }
Finally, implement some common interfaces and fill out functions like Equals
and CompareTo
:
public struct Currency : IEquatable<Currency>, IComparable<Currency> { public bool Equals(Currency other) { return Amount == other.Amount; } public int CompareTo(Currency other) { return Amount.CompareTo(other.Amount); } }
It’s all extremely straightforward, boilerplate work. Here’s a full Currency
struct:
using System; using System.Text; // Currency whose denominations are gold, silver, and bronze // 100 bronze to a silver // 100 silver to a gold public struct Currency : IEquatable<Currency>, IComparable<Currency> { // Amount of Bronze, which may be over 100 private uint Amount; public uint Gold { get { return Amount / 10000; } set { uint bronze = Amount; uint gold = bronze / 10000; bronze -= gold * 10000; uint silver = bronze / 100; bronze -= silver * 100; Amount = value * 10000 + silver * 100 + bronze; } } public uint Silver { get { uint bronze = Amount; uint gold = bronze / 10000; bronze -= gold * 10000; return bronze / 100; } set { uint bronze = Amount; uint gold = bronze / 10000; bronze -= gold * 10000; uint silver = bronze / 100; bronze -= silver * 100; Amount = gold * 10000 + value * 100 + bronze; } } public uint Bronze { get { uint bronze = Amount; uint gold = bronze / 10000; bronze -= gold * 10000; uint silver = bronze / 100; bronze -= silver * 100; return bronze; } set { uint bronze = Amount; uint gold = bronze / 10000; bronze -= gold * 10000; uint silver = bronze / 100; Amount = gold * 10000 + silver * 100 + value; } } public uint TotalBronze { get { return Amount; } } public double TotalSilver { get { return Amount / 100f; } } public double TotalGold { get { return Amount / 10000f; } } public static Currency FromDenominations( uint gold, uint silver, uint bronze) { return new Currency { Amount = gold * 10000 + silver * 100 + bronze }; } public static Currency FromGold(uint gold) { return new Currency { Amount = gold * 10000 }; } public static Currency FromSilver(uint silver) { return new Currency { Amount = silver * 100 }; } public static Currency FromBronze(uint bronze) { return new Currency { Amount = bronze }; } public static Currency operator ++(Currency currency) { return new Currency { Amount = currency.Amount + 1 }; } public static Currency operator --(Currency currency) { return new Currency { Amount = currency.Amount - 1 }; } public static Currency operator +(Currency a, Currency b) { return new Currency { Amount = a.Amount + b.Amount }; } public static Currency operator -(Currency a, Currency b) { return new Currency { Amount = a.Amount - b.Amount }; } public static Currency operator *(Currency a, uint value) { return new Currency { Amount = a.Amount * value }; } public static Currency operator /(Currency a, uint value) { return new Currency { Amount = a.Amount / value }; } public static bool operator ==(Currency a, Currency b) { return a.Amount == b.Amount; } public static bool operator !=(Currency a, Currency b) { return a.Amount != b.Amount; } public static bool operator <(Currency a, Currency b) { return a.Amount < b.Amount; } public static bool operator >(Currency a, Currency b) { return a.Amount > b.Amount; } public static bool operator <=(Currency a, Currency b) { return a.Amount <= b.Amount; } public static bool operator >=(Currency a, Currency b) { return a.Amount >= b.Amount; } public bool Equals(Currency other) { return Amount == other.Amount; } public override bool Equals(object obj) { return obj is Currency other && Equals(other); } public override int GetHashCode() { return Amount.GetHashCode(); } public int CompareTo(Currency other) { return Amount.CompareTo(other.Amount); } public override string ToString() { StringBuilder builder = new StringBuilder(16); builder.Append('('); builder.Append(Gold); builder.Append(", "); builder.Append(Silver); builder.Append(", "); builder.Append(Bronze); builder.Append(')'); return builder.ToString(); } }
Using Our Own Type
Using Currency
is extremely easy and very similar to TimeSpan
since it’s API is patterned off of it. Here are some examples that go through each function:
// Default to zero Currency c = default(Currency); print(c); // (0, 0, 0) // Get and set denominations c.Gold = 10; print(c); // (10, 0, 0) c.Silver = 20; print(c); // (10, 20, 0) c.Bronze = 30; print(c); // (10, 20, 30) print(c.Gold); print(c.Silver); print(c.Bronze); // Get totals print(c.TotalBronze); // 102030 print(c.TotalSilver); // 1020.3 print(c.TotalGold); // 10.203 // Build from all denominations or just one c = Currency.FromDenominations(1, 2, 3); print(c); c = Currency.FromGold(1); print(c); c = Currency.FromSilver(2); print(c); c = Currency.FromBronze(3); print(c); // Reset c = default(Currency); print(c); // ++ and -- c++; print(c); // (0, 0, 1) ++c; print(c); // (0, 0, 2) c--; print(c); // (0, 0, 1) --c; print(c); // (0, 0, 0) // += and -= c += Currency.FromBronze(10); print(c); // (0, 0, 10) c -= Currency.FromBronze(4); print(c); // (0, 0, 6) // + and - print(c + Currency.FromBronze(2)); // (0, 0, 8) print(c - Currency.FromBronze(2)); // (0, 0, 4) // * and / print(c * 2); // (0, 0, 12) print(c / 2); // (0, 0, 3) // == and != print(c == Currency.FromBronze(6)); // true print(c != Currency.FromBronze(6)); // false // < and > print(c < Currency.FromBronze(5)); // false print(c > Currency.FromBronze(5)); // true // <= and >= print(c <= Currency.FromBronze(5)); // false print(c >= Currency.FromBronze(6)); // true // Equals(object) and Equals(Currency) print(c.Equals("something else")); // false print(c.Equals(Currency.FromBronze(6))); // true // GetHashCode() and CompareTo(Currency) print(c.GetHashCode()); // 6 print(c.CompareTo(Currency.FromBronze(5))); // 1
We can also use Currency
in a Burst-compiled job since it’s just a struct with an int
in it. Here’s one that outputs the denominations to a NativeArray<uint>
:
[BurstCompile] struct CurrencyJob : IJob { public Currency Currency; public NativeArray<uint> Denominations; public void Execute() { Denominations[0] = Currency.Gold; Denominations[1] = Currency.Silver; Denominations[2] = Currency.Bronze; } }
Here’s how to run the job:
Currency currency = Currency.FromDenominations(1, 2, 3); NativeArray<uint> denom = new NativeArray<uint>(3, Allocator.TempJob); new CurrencyJob { Currency = currency, Denominations = denom }.Run(); print(denom[0] + ", " + denom[1] + ", " + denom[2]); // 1, 2, 3 denom.Dispose();
And here’s the assembly it compiles to for 64-bit x86:
mov eax, dword ptr [rdi] mov ecx, 3518437209 imul rcx, rax shr rcx, 45 mov rdx, qword ptr [rdi + 8] mov dword ptr [rdx], ecx imul ecx, ecx, 10000 sub eax, ecx imul rcx, rax, 1374389535 shr rcx, 37 mov dword ptr [rdx + 4], ecx imul ecx, ecx, 100 sub eax, ecx mov dword ptr [rdx + 8], eax ret
Notice that no overhead has been added for any of this convenience. The resulting code is just like if we’d used a raw uint
and written the Gold
, Silver
, and Bronze
properties directly inside the job’s Execute
function.
Conclusion
Wrapping integers in a struct has numerous advantages. We make our APIs clearer and in so doing less error-prone as passing the wrong type of unit is now very difficult. We simply can’t pass health points to a function expecting time. We also make it much less likely that we’ll accidentally use the wrong scale of units or incorrectly convert between them. We can’t forget to convert milliseconds to ticks or multiply by the wrong number of zeroes. Other primitives, such as float
, can also be wrapped with similar results.
It’s worth thinking through the types of units in your game and considering adding a wrapper struct for them. Investing a few minutes to create it might pay off many times in reduced debugging and improved readability.
#1 by Osman Zeki on October 14th, 2019 ·
Interesting, this reminds me how much I use TypeScript’s type aliases in these situations it makes code so much clearer without necessarily mechanically doing anything specific. Not as robust as a real abstraction like you describe here but still useful a form of “type comment”. I wish C# took this from TS, it would be useful sometimes just to clarify some ambiguities.
Ex:
Playground:
http://www.typescriptlang.org/play/?ssl=1&ssc=1&pln=10&pc=2#code/C4TwDgpgBAyhDGB7AdgEwM5QLxWQVwFsAjCAJwG4AoUSKAWQEsAbJh9BFDbXQkiyygDM8yeMAYooAcwjAAKgwIQAksjhI06ABQF0ALnrNW7DRgCUB9Z0wBvSlAdRSsvKWRRdUAFRQAjAAZAqgBfAWFRcUkZeUUVZEYWNg5NLX1YZPMDBOMM23tHZ2BXd0wAej9A-xCgA
#2 by jackson on October 15th, 2019 ·
It’s similar to that, or
typedef
in C and C++, but enforces stronger typing. For example, I added these lines to your Playground:Even though
getTimeInMilliseconds
takesSeconds
, I can passMilliseconds
by mistake because both are anumber
.#3 by Jonathan Pace on October 15th, 2019 ·
This is because Typescript is, at its core, a ‘structural’ typing system. (I.e, if two types are *effectively* the same, they *are* the same).
But there are ways you can make it act more like a ‘nominal’ typing system:
https://basarat.gitbooks.io/typescript/docs/tips/nominalTyping.html
#4 by Osman Zeki on October 14th, 2019 ·
It seems like long words/urls will not display properly in this comment section. Adding the css rule for breaking long words might be helpful:
#5 by jackson on October 15th, 2019 ·
I added that rule and it fits better now. Thanks!
#6 by Mike on October 14th, 2019 ·
So simple, yet so good! I’m now kicking myself for not doing this earlier :) Anything that can increase code quality is worth the extra effort in my book!