C++ For C# Developers: Part 25 – Intro to Templates
C# generics (List<T>
) look a lot like C++ templates (list<T>
), but they’re different in many key ways. It’s a big subject, so today we’ll start by looking at some of the most common uses of templates: applying them to classes, functions, members, lambdas, and variables.
Table of Contents
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrap-up
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
What are Templates?
Templates really are what their name suggests: a template for something. When we speak of a template, we say it’s a “something template” not a “template something.” This may sound like a trivial difference, and it’s even a commonly-heard misnomer, but there’s actually an important distinction to be made.
Say we’re talking about functions. If we say “template function” then we use “template” as an adjective, as though that’s what kind of function it is. This is how we properly talk about “static functions” and “member functions” or even “static member functions.” Those are all adjectives that clarify what kind of function we’re talking about.
This is not the case with templates. We never write a “template function” but instead a “template for a function.” This is then shortened to just “function template.” The template can be used to make a function which is then usable like any other function. This process is known as “instantiation.” That’s the same term we use when we make an object of a class type: the object is an instance of the class.
Template instantiation always takes place at compile time. We may pass arguments to the template in order to control how the template is instantiated into a run-time entity. This is conceptually similar to calling a constructor.
C++’s approach with templates differs from C#’s approach with generics. Rather than instantiating templates at compile time, the C# compiler generates MSIL that describes generic types and methods. The runtime then instantiates generics at the point where they’re first used. The generic is instantiated for each primitive (int
, float
, etc.) and once for all reference types (string
, GameObject
, etc.). Run-time implementations of this differ greatly. For example, IL2CPP instantiates generics at compile time but Microsoft runtimes instantiate them at run time.
With this in mind, let’s start looking at the kinds of templates we can make.
Variables
Perhaps the simplest form of template is a template for a variable. Consider the case of defining π:
constexpr float PI_FLOAT = 3.14f; constexpr double PI_DOUBLE = 3.14; constexpr int32_t PI_INT32 = 3;
This requires us to create many variables, each with essentially the same value. We have to come up with unique names for them which adds a lot of noise to our code.
Now let’s look at how we’d do this with a variable template:
template<typename T> constexpr T PI = 3.14;
First, we start with the template
keyword and the “template parameters” in angle brackets: <typename T>
. We’ll go in-depth into the various options for template parameters in the next article. For now, we’ll use the simple typename T
. This says the template takes one parameter, a “type name” with the name T
. This is just like parameters to functions. We state the type of the required parameters and what we want to refer to them as in the function: int i
.
After the template
we have the thing that the template creates when instantiated. In this case we have a variable named PI
. It has access to the template parameters, which in this case is just T
. Here we use T
as the type of the variable: T PI
. Just like other variables, we’re free to make it constexpr
and initialize it: = 3.14
. We could also make it a pointer, a reference, const
, or use other forms of initialization like {3.14}
.
Now that we’ve defined a template of a variable, let’s instantiate it so we have an actual variable:
float pi = PI<float>; DebugLog(pi); // 3.14
Instantiation involves naming the template (PI
) and providing arguments for the required template parameters. This is just like calling a function except that we’re using angle brackets (<>
) instead of parentheses (()
). We’re also passing a type name (float
) instead of an object (3.14
).
When the compiler sees this, it looks at the template (PI
) and matches up our template arguments (float
) to the template parameters (T
). It then substitutes the arguments wherever they’re used in the templated entity. In this case T
is replaced with float
, so we get this:
constexpr float PI = 3.14;
Then our usage of the template is replaced with usage of the instantiated template, so we get this:
float pi = PI; DebugLog(pi); // 3.14
Looking back at the original example where we had double
and int32_t
versions of π, we can now replace those with uses of the PI
template:
float pif = PI<float>; DebugLog(pif); // 3.14 double pid = PI<double>; DebugLog(pid); // 3.14 int32_t pii = PI<int32_t>; DebugLog(pii); // 3
This example instantiates the PI
variable template three times. The first instantiation passes float
as the argument for T
and the second and third instantiations pass double
and int32_t
. This causes the compiler to generate three variables:
constexpr float PI = 3.14; constexpr double PI = 3.14; constexpr int32_t PI = 3.14;
There are two apparent problems here. First, we’re initializing an int32_t
with = 3.14
. This is fine since 3.14
will be truncated to 3
per the initialization rules. If we didn’t want that behavior, we could have used constexpr T PI{3.14}
and PI<int32_t>
would then cause a compiler error as int32_t{3.14}
is not allowed.
Second, we apparently have three variables named PI
and therefore have a naming conflict. The compiler steps in and generates unique names. It may call them PI_FLOAT
, PI_DOUBLE
, and PI_INT32
or any other names it deems appropriate so long as they’re unique.
This is the same process that occurs when we overload functions: the compiler generates unique names for those functions. When we refer to the function, such as by calling it, the compiler determines which one we’re calling and substitutes Foo()
with Foo_Void()
or whatever it named the function. With templates, the compiler substitutes PI<float>
with PI_FLOAT
.
Lastly, we can explicitly instantiate a variable template without using it for any particular purpose:
template constexpr float PI<float>;
This is commonly used in libraries that don’t need to use the template but rather want to make sure it’s compiled into a static or dynamic library so it’s available for users at link time.
Functions
Function templates can be instantiated to produce a function just like how variable templates can be instantiated to produce a variable. For example, here’s a template for functions that return the maximum of two arguments:
// Function template template<typename T> T Max(T a, T b) { return a > b ? a : b; } // int version int maxi = Max<int>(2, 4); DebugLog(maxi); // 4 // float version float maxf = Max<float>(2.2f, 4.4f); DebugLog(maxf); // 4.4 // double version double maxd = Max<double>(2.2, 4.4); DebugLog(maxd); // 4.4
Again we see the template<typename T>
that begins a template. After it we wrote a function instead of a variable. That function has access to the type name argument T
. It uses it as the type of the two parameters as well as the return value.
Then we see three instantiations of the Max
template: Max<int>
, Max<float>
, and Max<double>
. Just like with variables, the compiler instantiates three functions by substituting the template argument (int
, float
, or double
) anywhere the template parameter T
is used in the function template:
int MaxInt(int a, int b) { return a > b ? a : b; } float MaxFloat(float a, float b) { return a > b ? a : b; } double MaxDouble(double a, double b) { return a > b ? a : b; }
Then the three function calls that caused this instantiation are replaced by calls to the instantiated functions:
// int version int maxi = MaxInt(2, 4); DebugLog(maxi); // 4 // float version float maxf = MaxFloat(2.2f, 4.4f); DebugLog(maxf); // 4.4 // double version double maxd = MaxDouble(2.2, 4.4); DebugLog(maxd); // 4.4
Also, as with any template, we’re not limited to just primitive types. We can use any type:
struct Vector2 { float X; float Y; bool operator>(const Vector2& other) const { return X > other.X && Y > other.Y; } }; // Vector2 version Vector2 maxv = Max<Vector2>(Vector2{4, 6}, Vector2{2, 4}); DebugLog(maxv.X, maxv.Y); // 4, 6
The implication here is that a template places prerequisites on its parameters. The Max
template requires that there’s a T > T
operator available. That’s definitely satisfied by int
, float
, and double
, but we needed to write an overloaded Vector2 > Vector2
operator in order for it to work with Vector2
. Without this operator, we’d get a compiler error:
struct Vector2 { float X; float Y; }; template <typename T> T Max(T a, T b) { // Compiler error: // "Invalid operands to binary expression (Vector2 and Vector2)" return a > b ? a : b; } Vector2 maxv = Max<Vector2>(Vector2{4, 6}, Vector2{2, 4}); DebugLog(maxv.X, maxv.Y); // 4, 6
Another option, as we’ve seen before, available to us in C++20 is to implicitly create function templates using auto
parameters, auto
return types, or both. These are known as “abbreviated function templates:”
// Abbreviated function template auto Max(auto a, auto b) { return a > b ? a : b; } // Usage is identical Vector2 maxv = Max<Vector2>(Vector2{4, 6}, Vector2{2, 4}); DebugLog(maxv.X, maxv.Y); // 4, 6
Finally, function templates can be explicitly instantiated like this:
template bool IsOrthogonal<Vector2>(Vector2, Vector2);
Classes
The next kind of template we can create is a template for a class
, struct
, or union
. As with variables and functions, we start with template<params>
and then write a class:
template<typename T> struct Vector2 { T X; T Y; T Dot(const Vector2<T>& other) const { return X*other.X + Y*other.Y; } };
Notice how the class uses the template argument T
in place of specific types like float
. This is allowed anywhere a specific type would otherwise go, such as in the types of data members like X
and Y
, the return type of member functions like Dot
, or in parameters like other
.
Here’s how we’d instantiate this class template to create vectors of a few different types:
Vector2<float> v2f{0, 1}; DebugLog(v2f.X, v2f.Y); // 0, 1 DebugLog(v2f.Dot({1, 0})); // 0 Vector2<double> v2d{0, 1}; DebugLog(v2d.X, v2d.Y); // 0, 1 DebugLog(v2d.Dot({1, 0})); // 0 Vector2<int32_t> v2i{0, 1}; DebugLog(v2i.X, v2i.Y); // 0, 1 DebugLog(v2i.Dot({1, 0})); // 0
The compiler-instantiated Vector2
classes then look like this:
struct Vector2Float { float X; float Y; float Dot(const Vector2Float& other) const { return X*other.X + Y*other.Y; } }; struct Vector2Double { double X; double Y; double Dot(const Vector2Double& other) const { return X*other.X + Y*other.Y; } }; struct Vector2Int32 { int32_t X; int32_t Y; int32_t Dot(const Vector2Int32& other) const { return X*other.X + Y*other.Y; } };
Then the usages of the class template are replaced with usages of these instantiated classes:
Vector2Float v2f{0, 1}; DebugLog(v2f.X, v2f.Y); // 0, 1 DebugLog(v2f.Dot({1, 0})); // 0 Vector2Double v2d{0, 1}; DebugLog(v2d.X, v2d.Y); // 0, 1 DebugLog(v2d.Dot({1, 0})); // 0 Vector2Int32 v2i{0, 1}; DebugLog(v2i.X, v2i.Y); // 0, 1 DebugLog(v2i.Dot({1, 0})); // 0
Note that the Dot
calls are particularly compact and valid only because of several rules we’ve seen so far. Take the case of v2f
which is a Vector2<float>
. When we call v2f.Dot({1, 0})
, the compiler looks at the Dot
it instantiated as part of the Vector2
template. That Dot
takes a const Vector2<float>&
parameter, so the compiler interprets {1, 0}
as aggregate initialization of a Vector2<float>
. Because {0, 1}
doesn’t have a name, that Vector2<float>
is an rvalue. It can be passed to a const
lvalue reference and it’s lifetime is extended until after Dot
returns.
Class templates can be explicitly instantiated like this:
template struct Vector2<float>; template struct Vector2<double>; template struct Vector2<int32_t>;
Members
Classes can include templates for member functions:
struct Vector2 { float X; float Y; template<typename T> bool IsNearlyZero(T threshold) const { return X < threshold && Y < threshold; } };
Even though Vector2
isn’t a template, it can contain a member function template. We use it just like a normal member function:
Vector2 vec{0.5f, 0.5f}; // Float DebugLog(vec.IsNearlyZero(0.6f)); // true // Double DebugLog(vec.IsNearlyZero(0.1)); // false // Int DebugLog(vec.IsNearlyZero(1)); // true
This causes the compiler to instantiate IsNearlyZero
three times:
struct Vector2 { float X; float Y; bool IsNearlyZeroFloat(float threshold) const { return X < threshold && Y < threshold; } bool IsNearlyZeroDouble(double threshold) const { return X < threshold && Y < threshold; } bool IsNearlyZeroInt(int threshold) const { return X < threshold && Y < threshold; } };
Then the calls to the member function template are replaced with calls to the instantiated functions:
Vector2 vec{0.5f, 0.5f}; // Float DebugLog(vec.IsNearlyZeroFloat(0.6f)); // true // Double DebugLog(vec.IsNearlyZeroDouble(0.1)); // false // Int DebugLog(vec.IsNearlyZeroInt(1)); // true
We can also write templates for static
member variables:
struct HealthRange { template<typename T> constexpr static T Min = 0; template<typename T> constexpr static T Max = 100; };
They’re used the same way other static
member variables are:
float min = HealthRange::Min<float>; int32_t max = HealthRange::Max<int32_t>; DebugLog(min, max); // 0, 100
As expected, the compiler instantiates these templates like so:
struct HealthRange { constexpr static float MinFloat = 0; constexpr static int32_t MaxInt = 100; };
And then replaces the member variable template usage with these instantiated member variables:
float min = HealthRange::MinFloat; int32_t max = HealthRange::MaxInt; DebugLog(min, max); // 0, 100
Lastly, we can write templates for member classes:
struct Math { template<typename T> struct Vector2 { T X; T Y; }; };
These can then be used like normal member classes:
Math::Vector2<float> v2f{2, 4}; DebugLog(v2f.X, v2f.Y); // 2, 4 Math::Vector2<double> v2d{2, 4}; DebugLog(v2d.X, v2d.Y); // 2, 4
The compiler then performs the usual instantiation and replacement:
struct Math { struct Vector2Float { float X; float Y; }; struct Vector2Double { double X; double Y; }; }; Math::Vector2Float v2f{2, 4}; DebugLog(v2f.X, v2f.Y); // 2, 4 Math::Vector2Double v2d{2, 4}; DebugLog(v2d.X, v2d.Y); // 2, 4
Finally, explicit instantiation of member templates looks like this:
// Member function template template bool Vector2::IsNearlyZero<float>(float) const; // Member variable template template const float HealthRange::Min<float>; // Member class template template struct Math::Vector2<float>;
Lambdas
Since lambdas are compiler-generated classes, their overloaded operator()
can also be templated. Prior to C++20, the “abbreviated function template” syntax based on auto
parameters and return values needed to be used:
// "Abbreviated function template" of LambdaClass' operator() auto madd = [](auto x, auto y, auto z) { return x*y + z; }; // Instantiate with float DebugLog(madd(2.0f, 3.0f, 4.0f)); // 10 // Instantiate with int DebugLog(madd(2, 3, 4)); // 10
The compiler-generated lambda class will then look something like this:
struct Madd { // Abbreviated function template auto operator()(auto x, auto y, auto z) const { return x*y + z; } };
Then the two calls to its operator()
cause the abbreviated function template to be instantiated into an overload set:
struct Madd { float operator()(float x, float y, float z) const { return x*y + z; } int operator()(int x, int y, int z) const { return x*y + z; } };
The compiler will then replace the lambda syntax with instantiation of these classes and calls to their operator()
:
Madd madd{}; // Call (float, float, float) overload of operator() DebugLog(madd(2.0f, 3.0f, 4.0f)); // 10 // Call (int, int, int) overload of operator() DebugLog(madd(2, 3, 4)); // 10
Starting in C++20, we can use the normal, non-abbreviated, style of template syntax. The compiler generates exactly the same code with this version as it did with the “abbreviated” syntax:
// Lambda with explicit template and "trailing return type" auto madd = []<typename T>(T x, T y, T z) -> T { return x*y + z; }; // Instantiate with float DebugLog(madd(2.0f, 3.0f, 4.0f)); // 10 // Instantiate with int DebugLog(madd(2, 3, 4)); // 10
Lambdas’ templated operator()
can’t be explicitly instantiated.
C# Equivalency
As we’ve seen, C++ has quite a different take on generic programming than C#. Its templates can be applied not only to classes, structs, and member functions, but also lambdas, variables, functions, member variables, and member functions. All of these except lambdas can be emulated in C# by wrapping them in a static
class:
// C# public static class Wrapper<T> { // Variable public static readonly T Default = default(T); // Function public static string SafeToString(T obj) { return object.ReferenceEquals(obj, null) ? "" : obj.ToString(); } } // Variable DebugLog(Wrapper<float>.Default); // 0 // Function DebugLog(Wrapper<Player>.SafeToString(new Player())); // "Player 1" DebugLog(Wrapper<Player>.SafeToString(null)); // ""
The major difference comes in when we consider what we’re allowed to do with type parameters. By default, a C# type parameter is treated like a System.Object
/object
. That means it has almost no functionality beyond basics like the ToString()
and default(T)
expressions we used above.
We can add restrictions using where
constraints to enable more functionality, but this is very limited. There are a handful of constraints like where T : new()
that allow us to call a default constructor or where T : class
that allow us to use null
, but mostly we use where T : ISomeInterface
or where T : SomeBaseClass
to enable calls to virtual
functions in the interface or base class.
Let’s try porting one of the above Vector2
examples from C++ to C#:
template<typename T> struct Vector2 { T X; T Y; T Dot(const Vector2<T>& other) const { return X*other.X + Y*other.Y; } };
First, a literal translation of the syntax looks like this:
// C# public struct Vector2<T> { public T X; public T Y; public T Dot(in Vector2<T> other) { return X*other.X + Y*other.Y; } }
Other than needing to make Dot
non-const
, because that’s not supported in C#, not much has changed. The problem is that we now get compiler errors on X*other.X
, Y*other.Y
, and the +
operator between them:
Operator ‘‘ cannot be applied to operands of type ‘T’ and ‘T’
Operator ‘‘ cannot be applied to operands of type ‘T’ and ‘T’
Operator ‘+’ cannot be applied to operands of type ‘T’ and ‘T’
The compiler is treating T
as an object
and both object*object
and object+object
are compiler errors. It doesn’t matter that we only ever use types like float
and double
that do support the *
and +
operators. The compiler insists that all possible types that could be used for T
support T*T
and T+T
. Since that’s not the case for types like Player
, we get compiler errors.
So we’ll need to add a where
constraint in order to restrict T
to a subset of types that does support *
and +
. Looking over the list of options, we don’t see anything like a where T : T*T
or where T : T+T
. Our only option is to avoid *
and +
and instead call virtual
functions named Multiply
and Add
in an implemented interface or base class because we can write where
constraints for those.
Here’s such an interface:
// C# public interface IArithmetic<T> { T Multiply(T a, T b); T Add(T a, T b); }
Here’s a struct
that implements it for float
:
// C# public struct FloatArithmetic : IArithmetic<float> { public float Multiply(float a, float b) { return a * b; } public float Add(float a, float b) { return a + b; } }
Now we can pass a FloatArithmetic
to Vector2
for it to call IArithmetic.Multiply
and IArithmetic.Add
on instead of the built-in *
and +
operators:
// C# public struct Vector2<T, TArithmetic> where TArithmetic : IArithmetic<T> { public T X; public T Y; private TArithmetic Arithmetic; public Vector2(T x, T y, TArithmetic arithmetic) { X = x; Y = y; Arithmetic = arithmetic; } public T Dot(Vector2<T, TArithmetic> other) { T xProduct = Arithmetic.Multiply(X, other.X); T yProduct = Arithmetic.Multiply(Y, other.Y); return Arithmetic.Add(xProduct, yProduct); } }
Here’s how we’d use this:
// C# var vecA = new Vector2<float, FloatArithmetic>(1, 0, default); var vecB = new Vector2<float, FloatArithmetic>(0, 1, default); DebugLog(vecA.Dot(vecB)); // 0
While this design works, it’s created several problems. First, we have a lot of boilerplate in IArithmetic
, FloatArithmetic
, extra type arguments to the generics (<T, TArithmetic>
instead of just <T>
), and an extra arithmetic
parameter to the constructor. That’s a hit to productivity and readability, but at least not a concern that translates much to the executable the compiler generates.
The second issue is that our Vector2
has increased in size since it includes an Arithmetic
field. That’s a managed reference to an IArithmetic
. On a 64-bit CPU, it’ll take up at least 8 bytes. Since X
and Y
both take up 4 bytes, the size of Vector2
has doubled. This will impact memory usage and, perhaps more importantly, cache utilization as only half as many vectors can now fit in a cache line.
The third issue is that FloatArithmetic
needs to be “boxed” from a struct
into a managed IArithmetic
reference. This will create garbage for the garbage collector to later collect. In the above example, this happens with each call to the Vector2
constructor. This deferred performance cost may cause frame hitches or other issues.
To avoid the boxing, we could switch from a struct
to a class
and share a global instance::
// C# public class FloatArithmetic : IArithmetic<float> { public static readonly FloatArithmetic Default = new FloatArithmetic(); public float Multiply(float a, float b) { return a * b; } public float Add(float a, float b) { return a + b; } } var vecA = new Vector2<float, FloatArithmetic>(1, 0, FloatArithmetic.Default); var vecB = new Vector2<float, FloatArithmetic>(0, 1, FloatArithmetic.Default); DebugLog(vecA.Dot(vecB)); // 0
This presents another performance issue: our reads of FloatArithmetic.Default
may read from “cold” memory, i.e. memory that’s not in a CPU cache.
The fourth and final issue is that the calls to Arithmetic.Multiply
and Arithmetic.Add
in Dot
are virtual
function calls because all functions in interfaces are implicitly virtual
. In some cases, the compiler will be able to conclusively determine that TArithmetic
is FloatArithmetic
and “de-virtualize” the calls. In many other cases, we’ll suffer the runtime overhead of three virtual function calls per Dot
.
Another approach is to wrap the float
values in a class that implements an interface with Multiply
and Add
:
// C# public interface INumeric<T> { INumeric<T> Create(T val); T Value { get; set; } INumeric<T> Multiply(T val); INumeric<T> Add(T val); } public class FloatNumeric : INumeric<float> { public float Value { get; set; } public FloatNumeric(float val) { Value = val; } public INumeric<float> Create(float val) { return new FloatNumeric(val); } public INumeric<float> Multiply(float val) { return Create(Value * val); } public INumeric<float> Add(float val) { return Create(Value + val); } }
Vector2
can now hold INumeric
fields and call its virtual
functions:
// C# public struct Vector2<T, TNumeric> where TNumeric : INumeric<T> { public TNumeric X; public TNumeric Y; public Vector2(TNumeric x, TNumeric y) { X = x; Y = y; } public T Dot(Vector2<T, TNumeric> other) { INumeric<T> xProduct = X.Multiply(other.X.Value); INumeric<T> yProduct = Y.Multiply(other.Y.Value); INumeric<T> sum = xProduct.Add(yProduct.Value); return sum.Value; } } var vecA = new Vector2<float, FloatNumeric>( new FloatNumeric(1), new FloatNumeric(0) ); var vecB = new Vector2<float, FloatNumeric>( new FloatNumeric(0), new FloatNumeric(1) ); DebugLog(vecA.Dot(vecB)); // 0
This suffers the same problems as the previous approach: garbage creation for each FloatNumeric
that’s created, virtual function calls to Add
and Multiply
, extraneous type parameters, boilerplate, etc. At this point, many C# programmers will give up and manually “instantiate” the Vector2
“template” like a C++ compiler would:
// C# public struct Vector2Float { public float X; public float Y; public float Dot(in Vector2Float other) { return X*other.X + Y*other.Y; } } public struct Vector2Double { public double X; public double Y; public double Dot(in Vector2Double other) { return X*other.X + Y*other.Y; } } public struct Vector2Int { public int X; public int Y; public int Dot(in Vector2Int other) { return X*other.X + Y*other.Y; } } var vecA = new Vector2Float{X=1, Y=0}; var vecB = new Vector2Float{X=0, Y=1}; DebugLog(vecA.Dot(vecB)); // 0
This is efficient, but now suffers all the usual issues with code duplication: the need to change many copies, bugs when the copies get out of sync, etc. To address this, we may turn to a code generation tool that uses some form of templates to generates .cs
files. This may be run in an earlier build step, but it won’t be integrated into the main codebase, may require additional languages, still requires unique naming, and a variety of other issues.
C++ avoids the code duplication, the external tools, the virtual function calls, the cold memory reads, the boxing and garbage collection, the need for an interface and boilerplate implementation of it, the extra type parameters, and the where
constraints. Instead, it simply produces a compiler error when T*T
or T+T
is a syntax error.
Conclusion
We’ve barely scratched the surface of templates and already we’ve seen that they’re far more powerful than C# generics. We can easily write code like Dot
that’s simultaneously efficient, readable, and generic. C# struggles with even simple examples like this and often requires us to sacrifice one or more of these qualities.
Next week’s article will continue with templates by looking at even more advanced functionality like template “specialization” and default arguments. Stay tuned!
#1 by radwan on August 17th, 2022 ·
Hey Jackson! Thank you so much for the series, it has already proved to be a great source of knowledge.
Regarding the generic Vector2 in C#, there is at least one more way to approach this. Knowing how the JIT compiler works we can use `typeof` to provide the implementation of the operation for a specific type without any costs:
generates:
It doesn’t eliminate the boilerplate problem (we still need to provide implementations for all the primitive types we want to support) but it doesn’t induce any additional costs.
#2 by Brendan on January 4th, 2023 ·
In The C++ Programming Language 4th Ed., page 670, Bjarne suggests using “template function” and “function template” interchangeably. Your explanation is useful but I wouldn’t get too hung up on it.