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

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!