Previously, we implemented iterators for our custom container simply by forwarding the iterators from the underlying type to which we were deferring storage.
Below, our custom type is using a std::array
to store its objects, so we can just forward any calls to begin()
and end()
to that underlying array:
class Party {
public:
std::array<Player, 3> Array;
auto begin() { return Array.begin(); }
auto end() { return Array.end(); }
};
In this lesson, we will instead see how we can add iterators to our custom container ourselves, building them from scratch. This has three advantages:
Our starting point is below. We have a custom Party
type that contains three Player
objects, which we’re calling A
, B
, and C
class Player {
public:
std::string Name;
};
class Party {
public:
Party(Player A, Player B,
Player C) : A{A}, B{B}, C{C}{}
Player A, B, C;
};
Our main
function instantiates a Party
, and we would like to be able to perform a range-based for loop to iterate over the Player
objects it contains:
#include <iostream>
class Player {/*...*/};
class Party {
public:
Party(Player A, Player B,
Player C) : A{A}, B{B}, C{C}{}
Player A, B, C;
};
int main(){
Party Party{
Player{"Anna"}, Player{"Roderick"},
Player{"Bob"}};
for (Player& P : Party) {
std::cout << P.Name << ", ";
}
}
If we run the previous code, we’re likely to get a compilation error similar to:
'begin': no matching overloaded function found
This is expected - we’re trying to use a range-based for loop with a Party
object, and Party
is not yet a range.
As we covered earlier, for a type to be a range, it needs to implement a begin()
method which returns an iterator, and an end()
method which returns a sentinel - which is often also an iterator.
In this case, we’re building a custom iterator, so we need to define a new custom type. When an iterator is designed to work with a specific container, it is common for that iterator type to be defined within the container’s class or struct:
class Party {
public:
struct Iterator {
// ...
};
};
This has an effect similar to namespacing - our iterator type will be available through the scope resolution operator, as Party::Iterator
Let's build out some initial scaffolding for our iterator. It will need two data members - the Party
object it is iterating, and the Player
object it’s pointing at within that party.
We’ll represent the Player
as an index, where 0
maps to the Player
called A
, 1
maps to B
, and 2
maps to C
We’ll also need a constructor to initialize these members:
struct Iterator {
Iterator(Party* ptr, size_t idx) :
Party(ptr), idx(idx){}
private:
size_t idx;
Party* Party;
};
Next, let's add begin()
and end()
methods to our Party
that returns appropriate iterators. The begin()
method returns an iterator with an idx
of 0
, - that is, pointing at the Player
called A
The end()
method will return an iterator with an idx
of 3
- that is, pointing "past the end" of our container, in the same way we’ve seen with other container types.
Putting all this together, our code now looks like this:
#include <iostream>
class Player {/*...*/};
class Party {
public:
Party(Player A, Player B,
Player C) : A{A}, B{B}, C{C}{}
Player A, B, C;
struct Iterator {
Iterator(Party* ptr, size_t idx) :
Party(ptr), idx(idx){}
private:
size_t idx;
Party* Party;
};
Iterator begin(){ return Iterator(this, 0); }
Iterator end(){ return Iterator(this, 3); }
};
int main(){/*...*/};
Our main
function hasn’t changed, but it can now find the begin()
and end()
methods of our Party
container.
As such, it can create iterators from the type we defined, but that type is missing some required operators. The compilation error is likely to include messages like the following:
unary '++': 'Party::Iterator' does not define this operator
binary '!=': 'Party::Iterator' does not define this operator
cannot convert from 'Party::Iterator' to 'Player &'
To summarise:
++
operator!=
operator. The loop will continue as long as Iterator != Party.end()
, so our iterator objects must be able to compare themselves to the sentinel type returned from Party.end()
Player
our iterator is pointing at, through the dereferencing operator *
.So, we need to implement all three of these capabilities.
++
OperatorIn this case, we only need the prefix variation of the ++
operator:
Iterator& operator++(){
idx++;
return *this;
}
We’ll implement the postfix ++
operator later in this lesson.
==
OperatorWhilst we specifically need the !=
operator, as of C++20, explicitly defining this operator is unnecessary.
If we instead define the ==
operator, the compiler can handle the !=
operator for us automatically. We cover this in more detail later in the course:
For the ==
operator, we can consider two iterators equal if they are iterating over the same Party
, and are pointing at the same Player
within that Party
- ie, they have the same idx
:
bool operator==(const Iterator& b) const{
return Party == b.Party && idx == b.idx;
}
If we’re writing code to be compiled to specifications before C++20, we should additionally implement the !=
operator manually:
bool operator!=(const Iterator& b) const{
return !(*this == b);
}
*
operatorThe *
operator is how our iterator generates a reference to the object it is pointing at.
The implementation of this operator is going to vary greatly based on how the associated container stores its data.
Within our contrived example, we can just use three if
statements, that map the idx
values of 0
, 1
, and 2
to their respective player within the Party
we’re iterating.
Player& operator*() const{
if (idx == 0) return Party->A;
if (idx == 1) return Party->B;
if (idx == 2) return Party->C;
throw std::out_of_range{
"Parties can only have 3 players"};
}
We’ve additionally thrown an exception, in case an iterator is ever dereferenced with any other idx
value. Below, we try to dereference the past-the-end iterator, which will have an idx
of 3
:
try { *Party.end(); }
catch (std::exception& e) {
std::cout << e.what();
}
Parties can only have 3 players
With all these operators implemented, our code now looks like this:
#include <iostream>
class Player {/*...*/};
class Party {
public:
Party(Player A, Player B,
Player C) : A{A}, B{B}, C{C}{}
Player A, B, C;
struct Iterator {
Iterator(Party* ptr, size_t idx) :
Party(ptr), idx(idx){}
Player& operator*() const{
if (idx == 0) return Party->A;
if (idx == 1) return Party->B;
if (idx == 2) return Party->C;
throw std::out_of_range{
"Parties can only have 3 players"};
}
Iterator& operator++(){
idx++;
return *this;
}
bool operator==(const Iterator& b) const{
return Party == b.Party && idx == b.idx;
}
private:
size_t idx;
Party* Party;
};
Iterator begin(){ return Iterator(this, 0); }
Iterator end(){ return Iterator(this, 3); }
};
int main(){/*...*/};
Additionally, it compiles and runs as we expect:
Anna, Roderick, Bob,
However, our iterator is not yet fully complete. Whilst it is sufficient for use in a range-based for loop, it won’t work in many other iterator-based contexts.
There are some more things we need to add before it is a fully-fledged iterator type.
We’ve discussed how iterators provide a generalized way of traversing through containers. This allows us to write algorithms and other code that can interact with containers, without necessarily knowing the type of those containers.
However, we’ve also seen that not all containers support the same forms of traversal. For example:
std::forward_list
is designed only to support traversal in a single direction. From each element, we can only access the next element in the sequence.std::list
can support traversal in both directions. We can move either forward or backward as neededstd::vector
and std::deque
support a pattern called random access. We can freely jump to whatever part of the container we wish, typically using the subscript operator []
This presents a problem as, by design, generic code using iterators does not inherently know what access pattern the underlying container supports.
Perhaps our algorithm requires random access, but the container doesn’t support it. Perhaps the algorithm has two implementations - an efficient variation that requires random access, but a slower variation that only requires forward traversal.
We manage this by categorizing the iterators associated with our container.
The main way we communicate the access patterns our iterator supports is by imagining them as fitting within one of several categories. For example:
std::forward_iterator
supports traversal in a single direction. This is the type of iterator returned by containers such as a std::forward_list
std::bidirectional_iterator
supports traversal in either direction. This is the type of iterator returned by containers such as a std::list
std::random_access_iterator
supports access to any arbitrary collection member. This is the type of iterator returned by containers such as a std::deque
std::contiguous_iterator
supports the same access pattern as a random access iterator, with the additional requirement that elements adjacent in the container are also adjacent in memory. This is the type of iterator returned by array containers such as std::vector
Similar to class inheritance, we can imagine these iterator categories existing in a hierarchy. The more advanced iterators expand on their more basic ancestors.
So, for example, a bidirectional iterator has all the capabilities of a forward iterator, so it effectively is a forward iterator. Any code that expects a forward iterator would also work when given a bidirectional iterator.
For the same reason, a random access iterator is both a forward iterator and a bidirectional iterator, and a contiguous iterator is all of the above.
There are two main ways we categorize our iterator. The first way is by ensuring our iterator satisfies a C++20 concept. The standard library identifiers we listed above, such as std::forward_iterator
are all concepts. Later in this lesson, we ensure our iterator satisfies this concept.
The second way we categorize our iterator is through a "tag". This takes the form of defining an iterator_category
type within our iterator, by aliasing a standard library type. The standard library tags have the same identifiers as their respective concept, with the addition of a _tag
suffix:
struct Iterator {
using iterator_category =
std::forward_iterator_tag;
// ...
};
This gives any other code an easy, compile-time way to determine what type of iterator it’s dealing with. It just needs to check the Iterator::iterator_category
member.
It’s also common to add several more using
statements to our iterators, to provide easy access to other information that is commonly required when writing templates that use iterators.
The other standard aliases are:
value_type
: The type of object our iterator is pointing at - Player
, in this caseelement_type
: Another alias for value_type
- again this is Player
in this casereference
: The reference form of value_type
- Player&
, in this casepointer
: The pointer form of value_type
- Player*
in this casedifference_type
: The type used for pointer arithmetic - in most cases, this can be set to std::ptrdiff_t
We’ve implemented all these aliases below:
#include <iostream>
class Player {/*...*/};
class Party {
public:
Party(Player A, Player B,
Player C) : A{A}, B{B}, C{C}{}
Player A, B, C;
struct Iterator {
using iterator_category =
std::forward_iterator_tag;
using value_type = Player;
using element_type = Player;
using pointer = Player*;
using reference = Player&;
using difference_type = std::ptrdiff_t;
Iterator(Party* ptr, size_t idx) :
Party(ptr), idx(idx){}
Player& operator*() const{
if (idx == 0) return Party->A;
if (idx == 1) return Party->B;
if (idx == 2) return Party->C;
throw std::out_of_range{
"Parties can only have 3 players"};
}
Iterator& operator++(){
idx++;
return *this;
}
bool operator==(const Iterator& b) const{
return Party == b.Party && idx == b.idx;
}
private:
size_t idx;
Party* Party;
};
Iterator begin(){ return Iterator(this, 0); }
Iterator end(){ return Iterator(this, 3); }
};
int main(){/*...*/};
This allows other ours and other code to easily work with our iterators at compile time. Below, we use the new value_type
alias to easily determine what our iterator type points at:
template <typename T1, typename T2>
void LogIfType(T2&& Iter){
if constexpr (std::same_as<
T1, typename T2::value_type>) {
std::cout << (*Iter).Name;
}
}
int main(){
Party Players{
Player{"Anna"}, Player{"Roderick"},
Player{"Bob"}};
LogIfType<Player>(Players.begin());
LogIfType<Party>(Players.begin());
}
Anna
We can also use those aliases within our iterator struct if preferred. For example, the following code:
using value_type = Player;
using element_type = Player;
using pointer = Player*;
using reference = Player&;
Could be written as:
using value_type = Player;
using element_type = value_type;
using pointer = value_type*;
using reference = value_type&;
And a method such as:
Player& operator*() const{
// ...
}
Could be written as:
reference operator*() const{
// ...
}
C++20 introduced concepts, which we covered in a dedicated lesson:
For algorithms and other code written from C++20 onwards, concepts tend to be the main way we identify the category of iterator. For example, a type is a forward iterator if it satisfies the std::forward_iterator
concept. If it does, an expression like the following will return true
:
std::forward_iterator<Party::Iterator>
The easiest way to determine if our type meets the requirements is to statically assert that the previous expression returns true
:
static_assert(
std::forward_iterator<Party::Iterator>
);
When we add this assertion, we’re likely to find our iterator does not currently satisfy the concept. Our compiler should hopefully generate helpful feedback telling us why.
As of the C++23 specification, there are two reasons our iterator does not yet satisfy the concept:
std::incremental
is false because Iterator++ is not supportedOur iterator implements the prefix ++
operator, but we also need to overload the postfix ++
variation.
When our type already implements the prefix ++
operator, there tends to be a repeatable pattern in implementing the postfix variation.
It involves copying the current object to a temporary variable, incrementing the current object using the prefix operator, and then returning the copy. It looks like this:
Iterator operator++(int){
Iterator tmp = *this;
++(*this);
return tmp;
}
If this is not clear, we introduced the unary increment operators, and how to overload them in more detail earlier in the course:
std::default_initializable
is falseThe std::incremental
concept also requires our iterator to be default-constructible. Because our iterator has a user-defined constructor, the default constructor has been deleted. We can re-add it easily:
Iterator() = default;
Party::Iterator
is useless?A default constructed member of our Iterator
type is not immediately useful - after all, we don’t know what Player
it points to, or even which Party
it is iterating.
But the concept doesn’t require a default-constructed iterator to be immediately useful - it just requires it to be an option. This option provides flexibility when creating code that works with those iterators.
For example, an algorithm might want to initialize an iterator, but only assign a value to it later in the process, perhaps from a nested scope:
Party::Iterator SomeIterator;
// ...
if (SomeBoolean){
SomeIterator = SomeParty.begin();
}
By making our iterator default-constructible, we allow for that option.
With the additions of the static_assert
, postfix ++
operator, and default constructor, our forward iterator is complete. It looks like this:
#include <iostream>
class Player {/*...*/};
class Party {
public:
Party(Player A, Player B,
Player C) : A{A}, B{B}, C{C}{}
Player A, B, C;
struct Iterator {
using iterator_category =
std::forward_iterator_tag;
using element_type = Player;
using value_type = Player;
using pointer = Player*;
using reference = Player&;
using difference_type = std::ptrdiff_t;
Iterator() = default;
Iterator(Party* ptr, size_t idx) :
Party(ptr), idx(idx){}
Player& operator*() const{
if (idx == 0) return Party->A;
if (idx == 1) return Party->B;
if (idx == 2) return Party->C;
throw std::out_of_range{
"Parties can only have 3 players"};
}
Iterator& operator++(){
idx++;
return *this;
}
Iterator operator++(int){
Iterator tmp = *this;
++(*this);
return tmp;
}
bool operator==(const Iterator& b) const{
return Party == b.Party && idx == b.idx;
}
private:
size_t idx;
Party* Party;
};
static_assert(std::forward_iterator<Iterator>);
Iterator begin(){ return Iterator(this, 0); }
Iterator end(){ return Iterator(this, 3); }
};
int main(){/*...*/};
->
OperatorWhilst not required by the concept, it is generally expected that iterators implement the ->
operator, providing similar functionality to what it does when used with pointers.
Specifically, the ->
operator provides a friendlier way to access members of the object being pointed at:
Party::Iterator A{Party.begin()};
// Without -> Operator:
(*A).Name;
// With -> Operator:
A->Name;
In this example, we could implement the ->
operator by calling the *
operator we already implemented. This returns a reference to the Player
our iterator is pointing at:
operator*()
We can then get the address of that Player
using the &
operator, and return
it. So our ->
iterator can look something like this:
Player* operator->() const{
return &operator*();
}
Because our type has a begin()
method that returns a forward iterator, and an end()
method that returns a sentinel, our type is also a range.
Specifically, it is a forward range, and we can additionally add a static assertion for this if we wish:
#include <iostream>
#include <ranges>
class Player {/*...*/};
class Party {/*...*/};
static_assert(std::ranges::forward_range<Party>);
We covered range concepts in more detail at the start of this chapter:
We may want to adapt our forward iterator to a more capable form, such as a bidirectional iterator or random access iterator. This simply involves updating the iterator_category
tag and adding the additional operators that the category requires.
For example, a std::bidirectional_iterator
requires us to implement the unary --
operators, to allow our iterator to move to the previous element;
auto LastElement { --Container.end() };
The std::random_access_iterator
requires us to implement the subscript operator []
, as well as binary arithmetic operators such as -
and +
. These allow our iterator to move freely around the container:
Container.begin() + 5;
We can ensure we meet the requirements, or find out what our type is missing, in the same way we did for std::forward_iterator
. That is, we pass our type to the concept we want to implement, and statically assert that we’re meeting the requirements:
static_assert(std::bidirectional_iterator
<Party::Iterator>);
static_assert(std::random_access_iterator
<Party::Iterator>);
In this lesson, we explored the process of creating custom iterators for our types, leveraging C++20 concepts. The key things we learned included:
++
, ==
, *
, and ->
.std::forward_iterator
to ensure our types satisfy the requirements.A detailed guide to implementing a custom iterator type from scratch, using modern recommended techniques
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.