A view in C++ is typically a lightweight range that provides lazy access to a sequence of elements without copying them.
Views such as std::ranges::take_view and std::ranges::drop_view eliminate the need for manual iterator arithmetic. Others, such as std::ranges::filter_view and std::ranges::transform_view, can replace many explicit loops and combinations of standard algorithms.
These new types cover many common operations on ranges, including transformation, filtering, and subrange selection.
However, when the standard views and their compositions are not enough, a non-trivial operation may require a custom view implementation.
And that is exactly the focus of this article: explaining how to create a custom view that is compatible with the standard ranges library.
But this article is not limited to a simple tutorial. By explaining how to implement a custom view, it also explores the key concepts behind the ranges library and provides a deeper understanding of how views work.
The Demonstration Example
To make the explanation more practical, I chose SAX-like XML parsing as the core example.
The idea is to implement a view that takes an XML string and lazily produces a sequence of tokens:
- element starts,
- element ends,
- text nodes.
I called this view XmlSaxView.
To keep the example focused, the view supports only a limited subset of XML. It doesn’t handle:
- newlines,
- extra whitespace,
- CDATA sections,
- element attributes.
It can process a simple string like this:
<book><title>Hello coroutines</title><author>Someone</author></book>
A similar exercise is implemented in How to Make Coroutines Practical for C++ Projects.
Compare the coroutine-based implementation from that article with the view-based implementation presented here to better understand the difference between C++ generators and views.
This article covers:
- views,
- iterators,
- sentinels.C++20 views are not limited to the adaptors provided by the standard library. When an operation cannot be expressed through their composition, you can implement a custom view.
This article demonstrates the process by building a lazy, SAX-like XML parser. Along the way, it explains how views, iterators, and sentinels work together, which standard concepts they must satisfy, and how to make a custom view compatible with range-based loops and standard views.
The source code is availiable here: https://godbolt.org/z/s8j7aaPEn
What a View Really Is
Conceptually, a view is a range. In other words, it should provide begin() and end() operations, just like any other range.
However, unlike an arbitrary range, a view is expected to be cheap to move. If it is copyable, copying should also have constant-time complexity.
Types such as std::span and std::string_view follow this model. They don’t own their elements and instead store a pointer, sometimes together with a size, to the underlying data. Therefore, copying or moving them is independent of the number of elements.
A view doesn’t have to be copyable. If it owns some state that is cheap to move but expensive to copy, it can be made move-only. However, simply disabling copy operations doesn’t automatically turn an arbitrary owning range into a valid view. It still has to satisfy the semantic requirements of std::ranges::view.
Because the difference between a range and a view is largely semantic, a custom type has to explicitly opt in to the std::ranges::view concept.
The simplest way to do this is to inherit from std::ranges::view_base. Another option is to specialize std::ranges::enable_view for the custom type.
The standard library also provides std::ranges::view_interface, a helper class for views that should expose additional container-like operations.
Depending on the capabilities of the view, it can provide methods such as:
empty()andfront()for forward ranges,back()for bidirectional common ranges,operator[]for random-access ranges,data()for contiguous ranges,size()when the distance between the iterator and sentinel can be calculated directly.
For this example, those additional methods don’t provide much value, so XmlSaxView inherits directly from std::ranges::view_base.
Therefore, XmlSaxView should:
- provide
begin()andend()operations, - be movable with constant-time complexity,
- have constant-time copying because this particular view is copyable,
- explicitly opt in to the
std::ranges::viewconcept.
This makes the implementation of XmlSaxView quite simple:
class XmlSaxView: public std::ranges::view_base {
public:
explicit XmlSaxView(std::string_view xml): m_xml(xml) {}
public:
XmlSaxViewIterator begin() const {
return XmlSaxViewIterator(m_xml);
}
XmlSaxViewSentinel end() const {
return XmlSaxViewSentinel{};
}
private:
std::string_view m_xml;
};
XmlSaxView stores the source XML and creates the iterator and sentinel. The actual parsing logic is implemented mostly by the iterator.
Because the view stores a std::string_view, it doesn’t own the XML data. The original string must remain alive for the entire lifetime of the view and its iterators.
You may also notice that begin() and end() return different types. The reason for that is explained in the next section.
Interested in practical C++, CMake, and software architecture?
Subscribe to receive engineering notes and deep dives into modern C++ by email.
From complexity to essence in C++
Operations Supported by the Iterator
The typical use case is to iterate over all tokens in the XML document until the end is reached:
auto view = XmlSaxView{some_xml};
for (auto it = view.begin(); it != view.end(); ++it) {
auto token = *it;
}
To support this loop, XmlSaxViewIterator has to provide several operations:
- incrementing (
++it): find and store the next token in the XML document, - comparison with the sentinel (
it != view.end()): check whether the end of the document has been reached, - dereferencing (
*it): return the current token.
XmlSaxViewSentinel represents the end of the range.
Traditional iterator pairs usually use the same type for both the current position and the end position. C++20 Ranges allow end() to return a different type called a sentinel.
For XmlSaxView, the sentinel can be an empty structure:
struct XmlSaxViewSentinel {};
It doesn’t need to store a parsing position. Its only purpose is to participate in comparisons with XmlSaxViewIterator. The comparison operator checks the state of the iterator and determines whether it has reached the end of the XML document.
Using a separate sentinel keeps the end representation simple: instead of constructing a special iterator that represents the end, the range returns a lightweight marker object.
XmlSaxViewIterator Implementation
XmlSaxViewIterator should implement all the operations described in the previous section:
struct XmlEvent {
enum class Type {
StartElement,
EndElement,
Text
};
Type type{};
std::string_view value{};
};
struct XmlSaxViewSentinel {};
class XmlSaxViewIterator{
public:
using value_type = XmlEvent;
using difference_type = std::ptrdiff_t;
using iterator_concept = std::forward_iterator_tag;
using iterator_category = std::forward_iterator_tag;
public:
XmlSaxViewIterator() = default;
explicit XmlSaxViewIterator(std::string_view xml): m_xml(find_next_tag(xml)){}
public:
XmlSaxViewIterator operator++(int){
auto res = *this;
m_xml = find_next_tag(m_xml);
return res;
}
XmlSaxViewIterator& operator++(){
m_xml = find_next_tag(m_xml);
return *this;
}
XmlEvent operator*() const {
return m_event;
}
bool operator==(const XmlSaxViewIterator& other) const {
return m_xml == other.m_xml &&
m_event.type == other.m_event.type &&
m_event.value == other.m_event.value;
}
bool operator==(XmlSaxViewSentinel) const {
return m_xml.empty() && m_event.value.empty();
}
private:
std::string_view find_next_tag(std::string_view xml);
private:
XmlEvent m_event;
std::string_view m_xml;
};
To be compatible with the standard iterator concepts, the iterator exposes several associated types:
value_type,difference_type,iterator_concept,iterator_category.
iterator_concept specifies the iterator concept used by C++20 Ranges. iterator_category provides compatibility with algorithms based on the older iterator model.
The iterator also defines:
- a default constructor,
- equality comparison with another iterator,
- prefix and postfix increment operators,
- a dereference operator.
These operations are required for XmlSaxViewIterator to satisfy the std::forward_iterator concept.
The implementation can be verified at compile time:
static_assert(std::forward_iterator<XmlSaxViewIterator>);
static_assert(
std::sentinel_for<
XmlSaxViewSentinel,
XmlSaxViewIterator
>
);
A view doesn’t always require a forward iterator. Some views can work with input iterators. However, this iterator supports multi-pass iteration: its copies store independent positions and can be incremented independently.
The comparison with XmlSaxViewSentinel doesn’t compare two iterator positions. Instead, it determines whether the iterator has reached the end of the document:
bool XmlSaxViewIterator::operator==(XmlSaxViewSentinel) const {
return m_xml.empty() && m_event.value.empty();
}
XmlSaxViewIterator defines only equality operators, while the examples use the inequality operation. Since C++20, the compiler can rewrite an expression such as iterator != sentinel using the available operator==.
The iterator itself is responsible only for moving through the XML document and returning the current event. The actual search for the next event is implemented by find_next_event().
This method updates m_event and returns the part of the XML document that remains to be processed:
std::string_view XmlSaxViewIterator::find_next_tag(std:: string_view xml)
{
if (xml.empty() && m_event.value.empty()) {
throw std:: runtime_error("Empty xml document is provided");
}
if(xml.empty()){
m_event.value = {};
}
auto open_pos = xml.find('<');
if (open_pos == std:: string_view::npos) {
auto text = trim(xml);
if (!text.empty()) {
m_event = XmlEvent{
.type = XmlEvent:: Type:: Text,
.value = text
};
}
return {};
}
if (open_pos > 0) {
auto text = xml.substr(0, open_pos);
m_event = XmlEvent{
.type = XmlEvent:: Type:: Text,
.value = text
};
xml.remove_prefix(open_pos);
return xml;
}
auto close_pos = xml.find('>');
if (close_pos == std:: string_view::npos) {
throw std:: runtime_error("unterminated tag");
}
auto tag = xml.substr(1, close_pos - 1);
tag = trim(tag);
if (tag.empty()) {
throw std:: runtime_error("empty tag");
}
if (tag.front() == '/') {
tag.remove_prefix(1);
tag = trim(tag);
m_event = XmlEvent{
.type = XmlEvent:: Type:: EndElement,
.value = tag
};
} else {
m_event = XmlEvent{
.type = XmlEvent:: Type:: StartElement,
.value = tag
};
}
xml.remove_prefix(close_pos + 1);
return xml;
}
The trim() function should return a std::string_view. This allows every event to refer directly to a part of the original XML document without allocating or copying strings.
Creating a temporary std::string would be incorrect:
.value = std::string{text}
XmlEvent::value is a std::string_view, so it would refer to a temporary string destroyed immediately after the expression. That would leave the event with a dangling view.
The main idea behind XmlSaxViewIterator is to:
- calculate the next XML event when the iterator is incremented,
- store the current event inside the iterator,
- return the stored event when the iterator is dereferenced.
This has two implications:
- The iterator calculates the first event during construction.
- The user can retrieve the current event multiple times without parsing it again.
The implementation is still lazy because each subsequent event is calculated only when the iterator is incremented.
This article focuses on one specific C++20 feature.
My book No More Helloworlds — Build a Real C++ App takes the same practical approach to building a complete modern C++ application.
Usage Example
At this point, the view is ready.
You can construct it from an XML document and iterate over the generated events:
void iterate_over_events(XmlSaxView view)
{
std::cout << "iterate_over_events\n";
for (auto event : view) {
switch (event.type) {
case XmlEvent::Type::StartElement:
std::cout << "START: " << event.value << '\n';
break;
case XmlEvent::Type::EndElement:
std::cout << "END: " << event.value << '\n';
break;
case XmlEvent::Type::Text:
std::cout << "TEXT: [" << event.value << "]\n";
break;
}
}
}
XmlSaxView can also be used as the source range for other views, such as std::ranges::filter_view:
void find_all_start_elements(XmlSaxView view)
{
std::cout << "find_all_start_elements\n";
auto filtered_view = std::ranges::filter_view{
view,
[](const XmlEvent& event) {
return event.type == XmlEvent::Type::StartElement;
}
};
for (auto event : filtered_view) {
std::cout << "found start tag: " << event.value << '\n';
}
}
This example demonstrates that XmlSaxView is compatible with the standard views and can be composed with them.
It can also be used on the left-hand side of the pipe operator:
void find_all_start_elements(XmlSaxView view)
{
auto filtered_view =
view
| std::views::filter([](const XmlEvent& event) {
return event.type == XmlEvent::Type::StartElement;
});
for (auto event : filtered_view) {
std::cout << "found start tag: " << event.value << '\n';
}
}
However, XmlSaxView itself cannot yet be used as a range adaptor on the right-hand side of the pipe:
auto events = xml | views::xml_sax;
Final Thoughts
Custom views may look complicated at first because they combine several C++ concepts: ranges, iterators, sentinels, lazy evaluation, and object lifetime.
However, the implementation becomes much easier once these responsibilities are separated.
The view itself usually stores the source range and creates the iterator and sentinel. The iterator performs the actual work, stores the current value, and calculates the next one only when it is incremented. The sentinel simply represents the end of the range.
A short plan for implementing a custom view looks like this:
- Define the produced sequence.
Decide what values the view returns and when they should be calculated. - Choose the ownership model.
Determine whether the view owns its source or only refers to external data. Make the lifetime requirements explicit. - Implement the iterator.
Add dereferencing, incrementing, associated iterator types, and equality comparison when required by the selected iterator concept. - Implement the sentinel.
It may be a separate lightweight type that only checks whether the iterator has reached the end. - Implement the view.
Store the source range, providebegin()andend(), and opt in to thestd::ranges::viewconcept usingstd::ranges::view_baseorstd::ranges::view_interface. - Verify the concepts.
Usestatic_assertto check the iterator, sentinel, range, and view requirements:
static_assert(std::forward_iterator<XmlSaxViewIterator>);
static_assert(
std::sentinel_for<
XmlSaxViewSentinel,
XmlSaxViewIterator
>
);
static_assert(std::ranges::view<XmlSaxView>);
Before implementing a custom view, it is still worth checking whether the required operation can be expressed as a composition of existing standard views. A custom view introduces additional iterator and lifetime rules that have to be handled correctly.
When the standard adaptors are not enough, however, a custom view provides a clean way to expose a lazy operation as a regular range. Once implemented, it can be used in range-based loops, passed to standard views, and composed into reusable processing pipelines.