Implementing Ranges for Custom Types
Learn to implement iterators in custom types, and make them compatible with range-based techniques.
In the previous lesson, we introduced how types that implement appropriate begin()
and end()
methods can be considered ranges and therefore become compatible with range-based techniques.
Often, we'd like to give our custom, user-defined types this capability too. For example, we might have a Party
type that contains a collection of Player
objects.
We'd like to be able to iterate over those players using a range-based for loop:
#include "Party.h"
int main() {
Party MyParty;
for (const auto& Player : MyParty) {
// ...
}
}
In this lesson, we'll show how we can implement this capability.
Adding Iterators to our Custom Types
In the next two lessons, we will see an example where we implement iterators for our types entirely from scratch. This gives us full control over the implementation, and how our iterators behave.
But, in most cases, this isn't necessary. When we're implementing a custom collection, two things are often true:
- Behind the scenes, our collection is letting some other type manage storage, such as a
std::vector
- That type implements iterators, and we want our iterators to behave the same way
We'll focus on this simpler scenario in this lesson. A typical Party
class might look like this, where we're relying on a private std::vector
to manage the storage:
#include <vector>
class Player {};
class Party {
public:
// Party-specific logic
void AddMember() {};
void StartQuest() {};
void Disband() {};
void SetLeader() {};
private:
// Underlying Collection
std::vector<Player> PartyMembers;
};
To make our party iterable, we can just expose the begin()
and end()
methods of our underlying std::vector
.
Note, that our begin()
and end()
methods must be made public
for our container to be considered a range:
#include <vector>
class Player {};
class Party {
public:
// Party-specific logic
// ...
// Iterators
auto begin() {
return PartyMembers.begin();
}
auto end() {
return PartyMembers.end();
}
private:
// Underlying Collection
std::vector<Player> PartyMembers;
};
Iterator Types
Above, the return type of our new functions was set to auto
. This is typical but if we ever need to be specific about the type of an iterator from the standard library containers, there's a static iterator
property that we can use:
vector<Player>::iterator begin() {
return PartyMembers.begin();
}
vector<Player>::iterator end() {
return PartyMembers.end();
}
Asserting a Type is a Range
If our type is intended to be a range, it's useful to statically assert this using the range concepts we introduced in the previous lesson.
This ensures that if some future change to our type causes it to violate the range specification, we're alerted.
Below, we assert our type is a random access range. This is the case because the iterators we use are from a std::vector
, which is a container that supports random access:
#include <vector>
#include <ranges>
class Player {};
class Party { /*...*/ }
static_assert(
std::ranges::random_access_range<Party>);
In this case, our type is also a contiguous range. A contiguous range is simply a random access range, with the additional guarantee that adjacent elements are also adjacent in memory.
This is how arrays like std::vector
work, so our type also satisfies this stricter concept:
static_assert(
std::ranges::contiguous_range<Party>);
Using Range-Based For Loops
Now that our type implements all the range requirements, the objects instantiated from the class are now ranges.
This allows us to use them in range-based techniques, such as a range-based for loop:
int main() {
Party MyParty;
for (const auto& Player : MyParty) {
// ...do things
}
}
A complete example is available here:
#include <vector>
#include <iostream>
#include <utility>
class Player {
public:
Player(std::string Name) : mName(Name) {}
std::string GetName() const { return mName; }
private:
std::string mName;
};
class Party {
public:
void AddMember(const std::string& NewMember) {
PartyMembers.emplace_back(NewMember);
}
// Iterators
auto begin() {
return PartyMembers.begin();
}
auto end() {
return PartyMembers.end();
}
private:
std::vector<Player> PartyMembers;
};
int main() {
Party MyParty;
MyParty.AddMember("Legolas");
MyParty.AddMember("Gimli");
MyParty.AddMember("Frodo");
for (const auto& Player : MyParty) {
std::cout << Player.GetName() << '\n';
}
}
Legolas
Gimli
Frodo
Summary
In this lesson, we explored how to create a range using a custom type by implementing begin()
and end()
methods.
We also discussed how to assert that a type correctly implements the range requirements by combining static_assert
and concepts from the standard library <ranges>
header.
Key Takeaways:
- Custom types can be valid ranges by implementing
begin()
andend()
methods. When they encapsulate a standard library container likestd::vector
, this can be done simply by returning that container's iterators. - The
begin()
andend()
methods must be public for a type to be considered a range. - Using
auto
for the return type ofbegin()
andend()
is common, but exact iterator types can also be explicitly specified. - If our type is required to be a range, we should statically assert that it is.
- Once a type is confirmed as a range, it can be used with with range-based techniques, such as range-based for loops.
Defining Ranges using Sentinels
An alternative way of defining ranges, and why we sometimes need to use them