Lecture 2. Templates, Three-Way Comparison Operator

Date: 2023-05-02

Templates

1. Introduction to Templates

1.1. What are Templates and Why Do We Need Them?

Templates are one of C++'s most powerful features, allowing for code generalization without sacrificing performance. At its core, templates allow you to write a single piece of code that can operate on multiple data types, as opposed to writing separate code for each data type.

Imagine you have to write functions to add integers, float numbers, and double numbers. Instead of writing three different functions, with templates, you can write a single generic function that works for all these types.

Benefits:
  • Code Reusability: You don't have to rewrite the same logic for different data types.
  • Performance: Since templates are compiled and the type is known at compile-time, there’s no runtime overhead. It's as efficient as writing separate functions for each type.

1.2. Generic Programming

Generic programming is a way of designing your code so that it works independently of any particular type. It emphasizes the idea of writing the most general (or generic) code possible. Templates are the primary tool in C++ to achieve generic programming.

In the world of C++, the Standard Template Library (STL) is a perfect example of generic programming. It provides a collection of template classes and functions, which allows developers to create programs that are more versatile, without knowing beforehand what types will be used.


2. Function Templates

2.1. Basic Syntax

Function templates are a way to define generic functions. The template keyword is used to introduce a function template, followed by template parameters.

Here's a basic example of a function template for a generic add function:

template <typename T>
T add(T a, T b) {
    return a + b;
}

With the above function template, you can add two integers, two doubles, or even two strings without defining multiple add functions for each type.

2.2. Template Parameters vs Function Parameters

It's essential to distinguish between template parameters and function parameters:

  • Template Parameters: These are placeholders for data types or values and are declared between < > after the template keyword. For instance, in the above example, T is a template parameter.

  • Function Parameters: These are regular function arguments like a and b in the add function.

2.3. Instantiation of Function Templates

When you call a function template with specific data types, the compiler generates an "instance" of the template for that particular type, which is a real function that gets executed.

For example, calling add(5, 6) would instantiate a version of add that operates on int types, while add(5.5, 6.5) would operate on double types.

2.4. Automatic Type Deduction

One of the beauties of C++ function templates is that the compiler often automatically deduces the correct type for the template parameters. So, when you call add(5, 6), the compiler knows T should be int.

However, in cases where automatic type deduction may be ambiguous, you can explicitly specify the type:

add<int>(5, 6);

This capability is especially handy when dealing with complex template situations or to avoid unwanted type promotions.


3. Class Templates

3.1. Introduction to Class Templates

Just as function templates allow functions to operate on a generic type, class templates allow classes to have members that use generic types. It's like creating a blueprint for generating classes based on the given type(s).

Example: A Generic Box Class

Consider a simple class representing a box that can hold an item of any type:

template <typename T>
class Box {
private:
    T item;

public:
    Box(T newItem) : item(newItem) {}
    T getItem() { return item; }
};

Here, Box is a template class that can be instantiated with any type. For instance, Box<int> represents a box that holds an integer, while Box<std::string> would hold a string.

3.2. Defining Member Functions

Member functions of a class template can be defined both inside the class definition (inline) or outside the class definition. As demonstrated in the Box example above, the getItem function is defined inline.

Outside the Class Definition

When defining member functions outside the class template, you'll have to provide the template specification:

template <typename T>
T Box<T>::getItem() {
    return item;
}

Note the use of Box<T>:: before the function name. This syntax indicates that the function getItem belongs to the class template Box instantiated with the type T.

Also, remember that due to the way C++ handles templates, it's common to include both the declaration and the definition of class templates in header files. This ensures the compiler has access to the entire template definition when instantiating it for specific types.


4. Specialization

4.1. The Idea Behind Specialization

Template specialization allows you to define a different implementation for a specific type or set of types. It's a way to tell the compiler, "Hey, for this specific type, I want to do something different than the general case."

There might be cases where the generic implementation is not efficient or does not make sense for a particular type, and specialization provides a way to handle such scenarios.

4.2. Function Template Specialization

Suppose you have a generic function template to compare two items:

template <typename T>
bool areEqual(T a, T b) {
    return a == b;
}

But for floating-point numbers, a direct comparison might not be suitable due to precision issues. You can provide a specialized version for double:

template <>
bool areEqual<double>(double a, double b) {
    double epsilon = 0.000001;
    return (a - b) < epsilon && (b - a) < epsilon;
}

Here, the specialized version checks if the difference between a and b is smaller than a specified threshold.

4.3. Class Template Specialization

Using the Box class from before:

template <typename T>
class Box {
private:
    T item;

public:
    Box(T newItem) : item(newItem) {}
    T getItem() { return item; }
};

Imagine you want a specialized version for pointers, which provides a method to dereference the pointer:

template <typename T>
class Box<T*> {
private:
    T* item;

public:
    Box(T* newItem) : item(newItem) {}
    T* getItem() { return item; }
    T& dereference() { return *item; }
};

In this specialized version for pointer types (Box<T*>), a new method dereference is added, which isn't present in the generic Box class.


5. Non-type Parameters and Multiple Template Parameters

5.1. Non-type Parameters

While type parameters allow us to define templates that can operate on any type, non-type parameters let us define templates that can operate with constant values known at compile time.

Commonly used non-type parameters include:

  1. Integral values:
  2. Can be any integral type like int, char, unsigned, etc.
template <int size>
class Array {
    int data[size];
};
  1. Pointers:
  2. Can be pointer to objects or functions.
template <int* ptr>
void func() {
    // use ptr
};
  1. References:
  2. Similar to pointers but refers directly to objects.
template <int& ref>
void func() {
    // use ref
};
  1. Enumerations:
  2. Enum values can also be used as non-type parameters.
enum class Color { RED, GREEN, BLUE };

template <Color c>
class ColoredBox {
    // ...
};

5.2. Multiple Template Parameters

Templates can be designed with multiple parameters. They can be a mix of type and non-type parameters.

For example, a Matrix class that has both a data type and dimensions as template parameters:

template <typename T, int rows, int columns>
class Matrix {
    T data[rows][columns];
    // ...
};

When instantiating or using a template with multiple parameters, provide them in the order in which they are declared:

Matrix<double, 3, 3> myMatrix;

Remember, the order matters, and the compiler will expect values that match the type or nature (type vs. non-type) of each parameter accordingly.


6. Advanced Topics

Given the time constraints, we'll touch upon some advanced topics that are worth exploring in more depth at a later stage in your C++ journey.

6.1. Variadic Templates

Variadic templates are a feature that allows you to pass an arbitrary number of arguments to a template. It uses the ellipsis (...) notation.

For example, a function template that can take any number of arguments:

template <typename... Args>
void printAll(Args... args) {
    (std::cout << ... << args) << '\n';
}

This function can be called with any number of arguments of any type:

printAll(1, "hello", 3.14);

Under the hood, the compiler generates code for each specific use case. It's a powerful feature but can be tricky, especially when considering the recursive nature of its implementation.

6.2. Alias Templates

Alias templates allow you to create a shorthand or an alias for a longer template declaration. Think of it as a "typedef" but for templates.

For instance, if you frequently use a std::vector of pairs of ints and strings:

template <typename T>
using PairVector = std::vector<std::pair<int, T>>;

Now, instead of writing std::vector<std::pair<int, std::string>>, you can simply use:

PairVector<std::string> myVec;

Alias templates make code cleaner and more readable, especially when dealing with complex nested template types.


Absolutely! The three-way comparison operator (<=>), also known as the spaceship operator, was introduced in C++20. It allows for a consolidated comparison of two objects, returning a value that indicates not just equality or inequality, but also which operand (if any) is greater. Here's a breakdown for your README:


1. Three-Way Comparison Operator (<=>) in C++

1.1 Introduction

The spaceship operator, denoted as <=>, is a new addition to the C++20 standard. It's called the three-way comparison operator because it can return one of three possible results: less than, equal to, or greater than.

1.2 Benefits

  1. Simplification: Instead of defining multiple comparison operators (<, <=, >, >=, ==, !=), you can define a single operator (<=>) and infer the others.
  2. Consistency: Avoids potential inconsistencies that can arise when implementing multiple comparison operators separately.

1.3 How It Works

The <=> operator, when overloaded, returns one of the comparison categories. These categories determine the kind of comparison being made. Let's delve deeper into each category:

1.3.1 std::strong_ordering

This comparison category represents a total order on values. It's most appropriate for types where there's a clear and complete ordering. It can return one of three values:

  • less
  • equal
  • greater

Example: Consider a simple Integer class that wraps an int. For this type, we can determine a strong order:

class Integer {
    int value;
public:
    Integer(int v) : value(v) {}

    std::strong_ordering operator<=>(const Integer& other) const {
        if (value < other.value) return std::strong_ordering::less;
        if (value > other.value) return std::strong_ordering::greater;
        return std::strong_ordering::equal;
    }
};

1.3.2 std::weak_ordering

This represents a total order as well, but there's no distinction between values that are "equivalent" and those that are "equal." This can be useful for scenarios like case-insensitive string comparisons.

Example: Let's say we have a CaseInsensitiveString class:

class CaseInsensitiveString {
    std::string value;
public:
    CaseInsensitiveString(const std::string& v) : value(v) {}

    std::weak_ordering operator<=>(const CaseInsensitiveString& other) const {
        int cmp = strcasecmp(value.c_str(), other.value.c_str());
        if (cmp < 0) return std::weak_ordering::less;
        if (cmp > 0) return std::weak_ordering::greater;
        return std::weak_ordering::equal;
    }
};

In this example, the strings "hello" and "HELLO" are considered equivalent.

1.3.3 std::partial_ordering

Used for types where not all values can be ordered relative to all other values. A classic example is the floating-point, where NaN (Not-a-Number) doesn't have a clear ordering compared to other numbers. std::partial_ordering can return std::unordered in such cases.

Example: Floating-point numbers with the consideration of NaN:

std::partial_ordering Compare(float a, float b) {
    if (std::isnan(a) || std::isnan(b)) return std::partial_ordering::unordered;
    if (a < b) return std::partial_ordering::less;
    if (a > b) return std::partial_ordering::greater;
    return std::partial_ordering::equal;
}

Note

There are also std::strong_equality and std::weak_equality, which are meant for simple equality checks without ordering the operands. They return equal or nonequal and equivalent or nonequivalent respectively. They're useful for types where equality is more nuanced than a simple byte-for-byte comparison.

Summary

Comparison Result strong_ordering weak_ordering partial_ordering strong_equality weak_equality Description Example
less One value is less than the other. 3 < 4 returns less
equal Both values are exactly equal. 3 == 3 returns equal
equivalent Values are considered the same in the context of a comparison but may not be strictly equal. Case-insensitive comparison: "HELLO" is equivalent to "hello"
greater One value is greater than the other. 5 > 3 returns greater
unordered The comparison between values doesn't produce a meaningful result. Comparing NaN with 5.0 returns unordered
nonequal Values are not equal. 5 != 3 returns nonequal
nonequivalent Values are not equivalent. Case-sensitive comparison: "HELLO" is not equivalent to "hello"

The terms nonequal and nonequivalent represent the opposite of equal and equivalent respectively. They're used to specify when two values are distinctly different either in exact value (nonequal) or in the context of a broader comparison (nonequivalent).

1.4 Example

Consider a DenizenIdentifier structure:

#include <compare>

std::strong_ordering DenizenIdentifier::operator<=>(const DenizenIdentifier& other) const
{
    if (m_category == other.m_category)
    {
        return m_instance <=> other.m_instance;
    }

    return static_cast<int>(m_category) <=> static_cast<int>(other.m_category);
}

In the above code:

  • If the m_category of both objects is the same, then the function will return the comparison result of their m_instance.
  • Otherwise, it will compare the m_category of both objects.

1.5 Using <=> with Equality Operator

With the introduction of <=>, it's possible to simplify the definition of the equality operator (==). For instance:

bool DenizenIdentifier::operator==(const DenizenIdentifier& other) const
{
    return *this <=> other == std::strong_ordering::equal;
}

In this example, the == operator checks if the result of the spaceship operator is std::strong_ordering::equal.