Loading...
Searching...
No Matches
Ranges

This tutorial introduces the notion of ranges, a C++20 feature that SeqAn3 makes strong use of.

Difficulty | Moderate
Duration | 90 min
Prerequisite tutorials | C++ Concepts
Recommended reading |

# Motivation

Traditionally most generic algorithms in the C++ standard library, like std::sort, take a pair of iterators (e.g. the object returned by begin()). If you want to sort a std::vector v, you have to call std::sort(v.begin(), v.end()) and not std::sort(v). Why was this design with iterators chosen? It is more flexible because it allows e.g.:

- sorting only all elements after the fifth one: std::sort(v.begin() + 5, v.end())
- using non-standard iterators like reverse iterators: std::sort(v.rbegin() + 5, v.rend()) (sorts in reverse order)

But this interface is less intuitive than just calling std::sort on the entity that you wish to sort, and it allows for more mistakes, e.g. mixing two incompatible iterators. C++20 introduces the notion of ranges and provides algorithms that accept such in the namespace std::ranges::, e.g. std::ranges::sort(v) now works if v is range – and vectors are ranges!

What about the two examples that suggest the superiority of the iterator-based approach? In C++20 you can do the following:

- sorting only all elements after the fifth one: std::ranges::sort(std::views::drop(v, 5))
- sorting in reverse order: std::ranges::sort(std::views::reverse(v))

We will discuss later what std::views::reverse(v) does. For now, it is enough to understand that it returns something that appears like a container and that std::ranges::sort can sort it. Later, we will see that this approach offers even more flexibility than working with iterators.

# Ranges

Ranges are an abstraction of "a collection of items", or "something iterable". The most basic definition requires only the existence of begin() and end() on the range.

There are different ways to classify ranges, one way is through the capabilities of its iterator.

## Range concepts

Ranges are typically either input ranges (they can be read from) or output ranges (they can be written to) or both. E.g. a std::vector<int> is both, but a std::vector<int> const would only be an input range.

Input ranges have different strengths that are realised through more refined concepts (i.e. types that model a stronger concept, always also model the weaker one):

Concept | Description
--- | ---
std::ranges::input_range | can be iterated from beginning to end at least once
std::ranges::forward_range | can be iterated from beginning to end multiple times
std::ranges::bidirectional_range | iterator can also move backwards with --
std::ranges::random_access_range | you can jump to elements in constant-time []
std::ranges::contiguous_range | elements are always stored consecutively in memory

For the well-known containers from the standard library, this matrix shows which concepts they model:

| std::forward_list | std::list | std::deque | std::array | std::vector
--- | --- | --- | --- | --- | ---
std::ranges::input_range | ✅ | ✅ | ✅ | ✅ | ✅
std::ranges::forward_range | ✅ | ✅ | ✅ | ✅ | ✅
std::ranges::bidirectional_range |  | ✅ | ✅ | ✅ | ✅
std::ranges::random_access_range |  |  | ✅ | ✅ | ✅
std::ranges::contiguous_range |  |  |  | ✅ | ✅

There are also range concepts that are independent of input or output or one of the above concepts, e.g. std::ranges::sized_range which requires that the size of a range can be computed and in constant time.

## Storage behaviour

Containers are the ranges most well known, they own their elements. SeqAn3 makes use of standard STL containers like std::vector, but also implements some custom containers.

Decorators are ranges that are always defined on another range and decorate/annotate the underlying range with additional information. They do not own the underlying range but can contain member data of their own.

Views are ranges that are usually defined on another range and transform the underlying range via some algorithm or operation. Views do not own any data beyond their algorithm and the time it takes to construct, destruct or copy them should not depend on the number of elements they represent. The algorithm is required to be lazy-evaluated so it is feasible to combine multiple views. More on this below.

If you are confused about decorators vs views, think of decorators as "underlying range + data" and views as "underlying range + algorithm".

The storage behaviour is orthogonal to the range concepts defined by the iterators mentioned above, i.e. you can have a container that satisfies std::ranges::random_access_range (e.g. std::vector does, but std::list does not) and you can have views or decorators that do so or don't. For some combinations of iterator capabilities and storage behaviour there are extra concept definitions, e.g. seqan3::random_access_container.

# Views

As mentioned above, views are a specific kind of range. They are incredibly useful and you will find them throughout the library.

## Lazy-evaluation

A key feature of views is that whatever transformation they apply, they do so at the moment you request an element, not when the view is created.

std::vector vec{1, 2, 3, 4, 5, 6};
auto v = std::views::reverse(vec);

Here v is a view; creating it neither changes vec, nor does v store any elements. The time it takes to construct v and its size in memory is independent of the size of vec.

std::vector vec{1, 2, 3, 4, 5, 6};
auto v = std::views::reverse(vec);
std::cout << *v.begin() << '\n';

This will print "6", but the important thing is that resolving the first element of v to the last element of vec happens on-demand. This guarantees that views can be used as flexibly as iterators, but it also means that if the view performs an expensive transformation, it will have to do so repeatedly if the same element is requested multiple times.

## Combinability

You may have wondered why we wrote

auto v = std::views::reverse(vec);

and not

std::views::reverse v{vec};

That's because std::views::reverse is not the view itself, it's an adaptor that takes the underlying range (in our case the vector) and returns a view object over the vector. The exact type of this view is hidden behind the auto statement. This has the advantage that we don't need to worry about the template arguments of the view type. The adaptor has another, very useful feature: it can be chained with other adaptors!

std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | std::views::reverse | std::views::drop(2);
std::cout << *v.begin() << '\n';

What will this print?

Hint
It will print "4".

In the above example, the vector is "piped" (similar to the Unix command line) into the reverse adaptor and then into the drop adaptor and a combined view object is returned. Note that accessing the 0th element of the view is still lazy, determining which element it maps to happens at the time of access.

#### Assignment 1: Fun with views I

Look up the documentation of std::views::transform and std::views::filter. Both take an invocable object as parameter, e.g. a lambda function. std::views::transform applies the lambda on each element in the underlying range and std::views::filter filter "removes" those elements that its lambda function evaluates to false for.

What does this imply for argument types and return types of the lambda functions?

Hint
The transform's lambda should return something of the same type as the input and the filter's lambda should return true or false!

Task: Create a view on std::vector vec{1, 2, 3, 4, 5, 6}; that filters out all uneven numbers and squares the remaining (even) values, i.e.

std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | // ...?
std::cout << *v.begin() << '\n'; // should print 4
std::cout
std::vector

Solution
// SPDX-FileCopyrightText: 2006-2025 Knut Reinert & Freie Universität Berlin
// SPDX-FileCopyrightText: 2016-2025 Knut Reinert & MPI für molekulare Genetik
// SPDX-License-Identifier: CC0-1.0
#include <iostream>
#include <ranges> // include all of the standard library's views
#include <vector>
int main()
{
std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec
| std::views::filter(
[](auto const i)
{
return i % 2 == 0;
})
| std::views::transform(
[](auto const i)
{
return i * i;
});
std::cout << *v.begin() << '\n'; // prints 4
}
iostream

## View concepts

Views are a specific kind of range that is formalised in the std::ranges::view concept. Every view returned by a view adaptor models this concept, but which other range concepts are modelled by a view?

It depends on the underlying range and also the view itself. With few exceptions, views don't model more/stronger range concepts than their underlying range (other than std::ranges::view) and they try to preserve as much of the underlying range's concepts as possible. For instance, the view returned by std::views::reverse models std::ranges::random_access_range (and weaker concepts) if the underlying range also models the respective concept. It never models std::ranges::contiguous_range because the third element of the view is not located immediately after the second in memory (but instead before the second).

Perhaps surprising to some, many views also model std::ranges::output_range if the underlying range does, i.e. views are not read-only:

std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | std::views::reverse | std::views::drop(2);
std::cout << *v.begin() << '\n';
*v.begin() = 42; // now vec == {1, 2, 3, 42, 5, 6 } !!

#### Assignment 2: Fun with views II

Have a look at the solution to the previous assignment (filter+transform). Which of the following concepts do you think v models?

Concept | yes/no?
--- | ---
std::ranges::input_range |
std::ranges::forward_range |
std::ranges::bidirectional_range |
std::ranges::random_access_range |
std::ranges::contiguous_range |
std::ranges::view |
std::ranges::sized_range |
std::ranges::output_range |

Solution

Concept | yes/no?
--- | ---
std::ranges::input_range | ✅
std::ranges::forward_range | ✅
std::ranges::bidirectional_range | ✅
std::ranges::random_access_range |
std::ranges::contiguous_range |
std::ranges::view | ✅
std::ranges::sized_range |
std::ranges::output_range |

Surprised? Let's have a closer look at the std::views::filter view. The filter view only returns the value of the underlying range for which the given predicate evaluates to true. To know which value is an element of the filter view, the view has to look at each of them. Thus, it must scan the underlying range value-by-value and cannot jump to an arbitrary location in constant time, since it cannot know how many elements it had to skip without looking at them. Accordingly, the std::views::filter preserves only std::ranges::bidirectional_range because it can scan the text in reverse order as well. Since the view cannot guarantee that the values lie in contiguous memory, it also cannot preserve std::ranges::contiguous_range. Similarly, the view cannot model std::ranges::sized_range as it cannot determine the number of values not filtered out in constant time.

The transform, on the other hand, produces a new element on every access (the result of the multiplication), therefore v is not a std::ranges::output_range, you cannot assign values to its elements. Note that this prevents modelling the std::ranges::contiguous_range as well because values are created on-demand and are not stored in memory at all.

We provide overview tables for all our view adaptors that document which concepts are modelled by the views they return.

## Views in the standard library and in SeqAn

The standard library in C++20 provides a number of useful views and SeqAn provides many views, as well. Most views provided by SeqAn3 are specific to biological operations, like seqan3::views::trim_quality which trims sequences based on the quality or seqan3::views::complement which generates the complement of a nucleotide sequence. But SeqAn3 also provides some general purpose views.

Have a look at the views-submodule to get an overview of SeqAn's views and also read through the detailed description on that page now that you had a more gentle introduction.

#### Assignment 3: Fun with views III

Create a small program that

1. reads a string from the command line (first argument to the program)
2. "converts" the string to a range of seqan3::dna5 (Bonus: throw an exception if loss of information occurs)
3. prints the string and its reverse complement
4. prints the six-frame translation of the string

Use views to implement steps 2.-4.

Solution
// SPDX-FileCopyrightText: 2006-2025 Knut Reinert & Freie Universität Berlin
// SPDX-FileCopyrightText: 2016-2025 Knut Reinert & MPI für molekulare Genetik
// SPDX-License-Identifier: CC0-1.0
#include <ranges> // include all of the standard library's views
#include <seqan3/alphabet/views/all.hpp> // include all of SeqAn's views
#include <seqan3/argument_parser/all.hpp> // optional: include the argument_parser
#include <seqan3/core/debug_stream.hpp>
int main(int argc, char ** argv)
{
// We use the seqan3::argument_parser which was introduced in the second chapter
// of the tutorial: "Parsing command line arguments with SeqAn".
seqan3::argument_parser myparser{"Assignment-3", argc, argv}; // initialize
std::string s{};
myparser.add_positional_option(s, "Please specify the DNA string.");
try
{
myparser.parse();
}
catch (seqan3::argument_parser_error const & ext) // the user did something wrong
{
std::cerr << "[PARSER ERROR]" << ext.what() << '\n'; // you can customize your error message
return 0;
}
auto s_as_dna = s | seqan3::views::char_to<seqan3::dna5>;
// Bonus:
//auto s_as_dna = s | std::views::transform([] (char const c)
//{
// return seqan3::assign_char_strictly_to(c, seqan3::dna5{});
//});
seqan3::debug_stream << "Original: " << s_as_dna << '\n';
seqan3::debug_stream << "RevComp: " << (s_as_dna | std::views::reverse | seqan3::views::complement) << '\n';
seqan3::debug_stream << "Frames: " << (s_as_dna | seqan3::views::translate) << '\n';
}
all.hpp
Meta-header for the Alphabet / Views submodule .
all.hpp
Meta-header for the Argument Parser module .
std::string
std::cerr
seqan3::argument_parser_error
Argument parser exception that is thrown whenever there is an error while parsing the command line ar...
Definition exceptions.hpp:39
seqan3::argument_parser
The SeqAn command line parser.
Definition argument_parser.hpp:147
seqan3::argument_parser::add_positional_option
void add_positional_option(option_type &value, std::string const &desc, validator_type option_validator=validator_type{})
Adds a positional option to the seqan3::argument_parser.
Definition argument_parser.hpp:314
seqan3::argument_parser::parse
void parse()
Initiates the actual command line parsing.
Definition argument_parser.hpp:404
debug_stream.hpp
Provides seqan3::debug_stream and related types.
seqan3::views::translate
constexpr auto translate
A view that translates nucleotide into aminoacid alphabet with 1, 2, 3 or 6 frames.
Definition translate.hpp:800
seqan3::views::complement
auto const complement
A view that converts a range of nucleotides to their complement.
Definition complement.hpp:64
seqan3::views::char_to
auto const char_to
A view over an alphabet, given a range of characters.
Definition char_to.hpp:64
seqan3::debug_stream
debug_stream_type debug_stream
A global instance of seqan3::debug_stream_type.
Definition debug_stream.hpp:38
std::runtime_error::what
T what(T... args)

# Containers

Containers are ranges that own their data. SeqAn3 uses the standard library containers, like std::vector and std::list to store elements. For certain use-cases we have introduced our own containers, though.

All standard library containers model std::ranges::forward_range (see above), but we have introduced container concepts that encompass more of a containers interface. Have a look at the API documentation of seqan3::container and unfold the inheritance diagram. What can you learn about the different refinements and their relation to the range concepts?

## The bitpacked sequence

If you followed the alphabet tutorial closely, you will know that seqan3::dna4 needs only two bits to represent its state. However, single objects are always at least a byte (eight bits) big in C++. To store sequences of small alphabets more space-efficiently, we have developed seqan3::bitpacked_sequence.

Open the API documentation of seqan3::bitpacked_sequence, display the inheritance diagram and read through the interface overview and the detailed description.

#### Assignment 4: The bitpacked sequence

Create a small program that asks the user for a size and then creates a vector of seqan3::dna4 of that size. Add an argument parser flag that allows the user to decide whether std::vector or seqan3::bitpacked_sequence is used. After creating the vector, print its size.

Measure and compare the amount of main memory that your program uses depending on the vector implementation. On Linux-based systems use /usr/bin/time -v <program> <args> and look for "Maximum resident set size". (Not to be confused with the built-in Bash time command! So use the full path /usr/bin/time)

On macOS and BSD use /usr/bin/time -l <program> <args> and look for "maximum resident set size".

Note
  This command displays the peak memory usage and only gives you a first impression. You can use [valgrind] (https://valgrind.org/docs/manual/ms-manual.html) if you want to have a more detailed analysis of your memory consumption.

Solution
// SPDX-FileCopyrightText: 2006-2025 Knut Reinert & Freie Universität Berlin
// SPDX-FileCopyrightText: 2016-2025 Knut Reinert & MPI für molekulare Genetik
// SPDX-License-Identifier: CC0-1.0
#include <vector>
#include <seqan3/alphabet/container/bitpacked_sequence.hpp> // include bitpacked sequence
#include <seqan3/alphabet/nucleotide/dna4.hpp>
#include <seqan3/argument_parser/all.hpp> // include argument parser
#include <seqan3/core/debug_stream.hpp> // for debug_stream
int main(int argc, char ** argv)
{
using namespace seqan3::literals;
seqan3::argument_parser myparser("Vector-implementations-comparison", argc, argv);
size_t size{};
bool use_bitvector{};
myparser.add_positional_option(size, "Size of vector");
myparser.add_flag(use_bitvector, 'b', "bitvector", "Use bitvector instead of vector");
try
{
myparser.parse();
}
catch (seqan3::argument_parser_error const & ext) // catch user errors
{
seqan3::debug_stream << "[Error] " << ext.what() << "\n";
return -1;
}
if (use_bitvector)
{
seqan3::bitpacked_sequence<seqan3::dna4> vector;
vector.resize(size, 'A'_dna4);
seqan3::debug_stream << "Allocated seqan3::bitpacked_sequence<seqan3::dna4> of size " << vector.size() << '\n';
}
else
{
std::vector<seqan3::dna4> vector{size};
seqan3::debug_stream << "Allocated std::vector<seqan3::dna4> of size " << vector.size() << '\n';
}
}
bitpacked_sequence.hpp
Provides seqan3::bitpacked_sequence.
seqan3::argument_parser::add_flag
void add_flag(bool &value, char const short_id, std::string const &long_id, std::string const &desc, option_spec const spec=option_spec::standard)
Adds a flag to the seqan3::argument_parser.
Definition argument_parser.hpp:270
seqan3::bitpacked_sequence
A space-optimised version of std::vector that compresses multiple letters into a single byte.
Definition bitpacked_sequence.hpp:62
seqan3::bitpacked_sequence::resize
void resize(size_type const count)
Resizes the container to contain count elements.
Definition bitpacked_sequence.hpp:979
seqan3::bitpacked_sequence::size
size_type size() const noexcept
Returns the number of elements in the container, i.e. std::distance(begin(), end()).
Definition bitpacked_sequence.hpp:613
dna4.hpp
Provides seqan3::dna4, container aliases and string literals.
seqan3::pack_traits::size
constexpr size_t size
The size of a type pack.
Definition type_pack/traits.hpp:143
seqan3::literals
The SeqAn namespace for literals.
std::vector::size
T size(T... args)
Hide me