CodeGym /Courses /C++ SELF /Diving deeper into iterators

Diving deeper into iterators

C++ SELF
Level 21, Lesson 0
Available

1. Introduction

When you are just starting to program, it seems logical: “if I have a std::vector<int> v, let the function take v and do what it needs to do.” And that is true… until you want to process not the whole container but a part: the first 10 elements, elements from 2 to 5, or everything except the first. That’s where the magic (actually, math) of ranges appears.

In the C++ standard library most algorithms think like this: “I don't care what kind of container this is; show me where the beginning and the end are, and I'll walk the elements.” This makes algorithms universal: the same std::find can work with vector, string, array, and even with a “plain” array.

To live like that, the library needs a common language. That language is iterators and the pair of functions begin() / end().

Iterator: a pointer with rules

If we speak plainly, an iterator is a “thing that points to an element of a sequence and can move forward.” It is similar to a pointer but usually safer and smarter: tied to the container and aware of its rules. The idea feels abstract until you try to write “container traversal” in different ways and see that the iterator approach fits the library surprisingly well.

Mini example: iterate a vector with iterators and print elements. It is important to notice three operations here: “take the beginning”, “stop at the end”, “dereference the current element”.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    for (auto it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 10 20 30
}

Note the detail: the condition it != v.end() — this is not “cosmetic” but a safety rule. end() is a boundary, not an element.

2. The range [first, last): include start, exclude end

Now we come to the most important idea of today’s lecture. In the STL a range is usually specified not as “from the first to the last inclusive” but as [first, last): first is included, last is excluded. At first this looks like a quirky programmer tradition, but it turns out to be super‑convenient.

Why is this convenient? Because an “empty range” can be represented by equal iterators: first == last. Because the length of the range is naturally “how many steps from first to last.” And because “the whole container” is just begin()…end() with no special cases.

Let's draw a simple scheme of a range for a vector with three elements:

flowchart LR
    A["begin()"] --> B["element 0"]
    B --> C["element 1"]
    C --> D["element 2"]
    D --> E["end() (past last)"]

end() sits after the last element, like “the slot beyond the finish line.”

Mini example: iterate not the whole vector but only the “tail”, starting from the second element. We take begin(), do ++it once and go until end().

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    auto it = v.begin();
    ++it; // now points to 20

    for (; it != v.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 20 30
}

This is the power of ranges: you can “shift the start”, while the end remains the same.

3. Range boundaries and constant iterators

Why you cannot dereference end()

There are desires in programming that are better left unfulfilled. Dereferencing end() is one of them. end() does not point to an element; it points “past the last.” It is like the address of “the next spot after the last chair” in an auditorium: there is no chair there, but the position in space exists.

If you write *v.end(), you break the iterator contract. In the best case you get garbage data, in the worst case a crash, and in the most amusing case—“it works on my machine.”

The correct approach is simple: before dereferencing an iterator, make sure it is not equal to end().

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    auto it = v.end();
    if (it == v.end()) {
        std::cout << "it is end(), no element here\n";
    }
}

This “check first — use later” pattern will be useful many times, especially when we start getting iterators as the result of algorithms.

begin()/end() and cbegin()/cend(): read‑only only

Containers have begin()/end(), and they often also have cbegin()/cend(). The letter c means const: that is “I promise I will not modify elements.”

This is useful for two reasons. First, the compiler really helps you: if you accidentally try to modify an element, it won't let you. Second, such code is easier to read: you open a function and immediately understand that it won't mutate the container (at least via the iterator).

Mini example: print a vector, but emphasize that this is “read‑only.”

#include <iostream>
#include <vector>

int main() {
    const std::vector<int> v{4, 5, 6};

    for (auto it = v.cbegin(); it != v.cend(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 4 5 6
}

If we tried to do *it = 42;, the compiler would stop us. This is a pleasant moment: the compiler is your strict teacher that prevents cheating.

4. Algorithms and result iterator: example std::find

“Whole container” as a range

Let's fix the main idea of the lecture: when an STL algorithm says “give me data”, it usually expects two iterators—the beginning and the end of the range. And “the whole container” is simply passed as c.begin(), c.end() (or cbegin()/cend()).

This form is so standard that you will see it constantly:

algo(c.begin(), c.end(), ...);

or, if we don't want to modify the container and want to emphasize “reading”:

algo(c.cbegin(), c.cend(), ...);

std::find: an interface sampler

One algorithm already serves as an “interface sampler”—std::find. It searches for a value in a range and returns an iterator: either “where it was found” or last (usually end()) if not found.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{5, 7, 9};

    auto it = std::find(v.begin(), v.end(), 7);
    if (it != v.end()) {
        std::cout << "found: " << *it << '\n'; // found: 7
    } else {
        std::cout << "not found\n";
    }
}

The key point: the result of find must not be dereferenced “blindly”. It may be equal to end().

Result iterator: “position of found” or “not found marker”

Many algorithms in the STL return not an index or a true/false, but a result iterator. This is convenient because an iterator is both a “position” and the ability to do something with the element (read or modify), if allowed by the contract.

The meaning of such a result is usually read as: “here is the place where it was found.” And if not found—“the place equals last.”

Let's look at this on a simple string example. Yes, std::string also provides iterators, and yes—algorithms work with strings almost the same way as with vector.

#include <algorithm>
#include <iostream>
#include <string>

int main() {
    std::string s = "cat";

    auto it = std::find(s.begin(), s.end(), 'a');
    if (it != s.end()) {
        std::cout << "letter: " << *it << '\n'; // letter: a
    }
}

Here it is a “pointer” to a character inside the string. And if the string is not const, we could theoretically replace the found letter (for example, turn "cat" → "cut"). But we'll do that later when we add our tasks model.

5. Mini‑project: TaskTracker and searching by id via iterators

Now we need a common context to develop further: a simple console program that stores tasks. We are not building a “perfect task manager”; we are building a learning ground where it's convenient to practice containers, iterators, and algorithmic style.

Let's start with the Task model. Everything here is as down‑to‑earth as possible: id, a title, and a done flag.

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Now let's write task printing. Important: we accept a const std::vector<Task>&, because printing should not change the list.

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        std::cout << it->id << ". " << it->title << " done=" << it->done << '\n';
    }
}

Note it->id. This is a convenient notation instead of (*it).id. When your iterator behaves like a pointer, -> becomes very pleasant.

Find task by id: return an iterator

Now let's write a function “find task by id”. We'll intentionally make it STL‑style: it returns an iterator. If not found—we return tasks.end().

This is very close to how standard algorithms behave: “here is the result iterator, decide what to do next.”

#include <vector>

std::vector<Task>::iterator find_task_by_id(std::vector<Task>& tasks, int id) {
    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        if (it->id == id) return it;
    }
    return tasks.end();
}

The function is short, but it teaches three things at once: “range traversal”, “search”, and “not found marker”. Most importantly—its interface is already compatible with how we'll think next: an iterator as a result.

Usage: check first, then use

Let's demonstrate how to use this function safely: check it != end(), and only then modify the task.

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Learn C++", false}, {2, "Sleep", false}};

    auto it = find_task_by_id(tasks, 2);
    if (it != tasks.end()) {
        it->done = true;
    }

    print_tasks(tasks);
    // 1. Learn C++ done=0
    // 2. Sleep done=1
}

And here it becomes obvious why a result iterator can sometimes be more convenient than an index. We found the element and immediately changed it without calculating “which index it is”, without doing a second pass, and without holding extra variables.

Const search: const_iterator and cend()

Sometimes you need a read‑only search version. Then return const_iterator and use cbegin()/cend().

#include <vector>

std::vector<Task>::const_iterator find_task_by_id(const std::vector<Task>& tasks, int id) {
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        if (it->id == id) return it;
    }
    return tasks.cend();
}

The meaning is the same, but the contract is clearer: “I am not going to modify tasks.” And the compiler will enforce that.

6. Index vs iterator: when to choose which

Many beginners want to ask: “why not use an index? it is simpler.” An index is indeed simpler for vector, but as soon as you go beyond vector, an index stops being universal. A string has an index, an array has an index, but many data structures do not have a meaningful index, while they do have an iterator.

To fix the picture, keep a comparison table. It does not require memorizing, but it helps the brain stop confusing “position” and “iterator”.

What we compare Index Iterator
Works the same for different containers No (depends on the container) Yes (this is the “universal interface”)
Can you specify a “slice of container” Possible, but often awkward Natural: [first, last)
What does “search” return Usually an index or -1 An iterator or end()
Can you accidentally go out of bounds Very easily Easy if you dereference end(), but the check pattern saves you
“Not found” marker idea -1 / size() / something else end() / last (unified style)

STL chose iterators because they enable constructing universal algorithms. And begin()/end() are the way to connect a container to that world.

7. Common mistakes when working with begin()/end() and iterators

Mistake #1: dereferencing end() “hoping for the best”.
The most frequent problem looks like this: “I called find, got it, and immediately write *it.” If the element wasn't found, it will equal end(), and dereferencing end() breaks the contract. It is cured by one discipline: first if (it != end()), then access the element.

Mistake #2: comparing iterators from different containers.
Sometimes by inattention people write something like if (it == other.end()). Iterators may be compared only within the same container (or within the same range). In the best case the code won't compile; in the worst case you'll get very strange logic. Remember the rule: “an iterator knows where it came from.”

Mistake #3: confusing “iterator” with “index”.
An iterator is not a number. You cannot “just print it as a position”, you cannot add arbitrary values to it (in general), and you cannot expect it to behave like an int. If you really need the position—that is a separate logical step, but in algorithmic style it's often unnecessary: the iterator is sufficient to read/modify the element.

Mistake #4: using begin()/end() where you promised “read‑only”.
If a function should only print or check, and you take begin() (a non‑const iterator), the code becomes less protected: someone later might “accidentally” add an element modification—and the compiler will be silent. The habit of writing cbegin()/cend() in reader functions makes the code stronger, especially in large projects.

Mistake #5: trying to “fix” everything with const without understanding.
Sometimes a student sees an error, adds const everywhere, and it “stops compiling differently.” If you make a container const, you won't be able to call algorithms/functions that want to change order or values. This is not a “compiler quirk”; it's contract protection. Before adding const, ask yourself: “Do I really want to forbid modifications—or am I just trying to silence an error?”

1
Task
C++ SELF, level 21, lesson 0
Locked
Printing the report
Printing the report
1
Task
C++ SELF, level 21, lesson 0
Locked
Cipher repair
Cipher repair
1
Task
C++ SELF, level 21, lesson 0
Locked
Range Sum
Range Sum
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION