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 thetemplate
keyword. For instance, in the above example,T
is a template parameter. -
Function Parameters: These are regular function arguments like
a
andb
in theadd
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:
- Integral values:
- Can be any integral type like
int
,char
,unsigned
, etc.
template <int size>
class Array {
int data[size];
};
- Pointers:
- Can be pointer to objects or functions.
template <int* ptr>
void func() {
// use ptr
};
- References:
- Similar to pointers but refers directly to objects.
template <int& ref>
void func() {
// use ref
};
- Enumerations:
- 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
- Simplification: Instead of defining multiple comparison operators (
<
,<=
,>
,>=
,==
,!=
), you can define a single operator (<=>
) and infer the others. - 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 theirm_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
.