C++ Road Map

The introduction of lambda expressions in C++11 and making STL algorithms callable on compiler-time in C++17 significantly improved the readability and performance of C++ codes. In addition to that, new features such as move semantics and RVO were also important developments for improving the performance of C++ code.

However, the fact that chained operations were still performed separately was one of the C++’s biggest weaknesses. The solutions offered by high-level languages such as C#’s “LINQ” and Java’s “Streams” and functional languages such as Haskell in this area were a pressure for C++.

int main()
{
  std::vector<int> ints{ 1, 2, 3, 4, 5, 6, 7, 8, 9 };
  std::vector<int> result;                         // (1)

  std::copy_if(ints.begin(), ints.end(),           // (2)
               std::back_inserter(result),
               [](int i) { return i % 2 == 0; });

  std::for_each(result.begin(), result.end(),      // (3)
                [](int& v) { v *= 2; });
}

When you run two simple operations like the above using algorithms in the standard library, caused performance and memory problems for large data sets, as two different loops were executed and temporary objects were required between operations.The reason for this is that the STL algorithms use (Eager evaluation), which means that they are evaluated immediately where they are defined. That means, they cannot be run together on a single data set to avoid creating temporary objects.

Ranges Library

The Ranges library, which comes with the C++20 standard, enables operations on data sets to be completed in the most performant way possible, thanks to its (lazy evaluation) architecture and new algorithms introduced within the library.

Not: For more information on lazy evaluation, please see my article here.

The Ranges library actually introduced by Eric Niebler on 2015 as an open source project. Adding this library into C++ standard has made the STL algorithms more flexible and powerfull as well as the language. When used with the Coroutine library, the C++ language offers the opportunity to develop fully functional and asynchronous programs and closes the gap with functional languages such as Haskell.

Eric Niebler RangeV3 Library

Let’s take a closer look at the new Ranges library and rewrite the code we wrote at the beginning of the article using the Ranges library:

int main()
{
  std::vector<int> ints{ 1, 2, 3, 4, 5, 6, 7, 8, 9 };

  // (1)
  auto result = ints  
    | std::views::filter([] (auto i) { return i % 2 == 0; })  
    | std::views::transform([] (auto i) { return i *= 2; });
  
  // (2)
  std::cout << "typename of result: " << typeid(result).name() << "\n";
  
  // (3)
  for (auto i : result) {
    std::cout << "even numbers with doubled: " << i << "\n";
  }
}

The usage of the Range library is provided by some special operators and helper adaptors called (view) objects, as shown in the code above. In the Range library, the logic ’|’ operator is used to use data sets or combine multiple range operations; the return type of this operator is the view adapter which created by the library for the given/combined operations, and the result of all operations is calculated through these object.

  1. By using the adapters in the Range library, a (view object) is created that result of the combination of two different operations, indicating that we want to get the doubled versions of the even numbers in the ints data set.

  2. When we look at the type of the (view object) we created by combining different operations, we see that it is a special class that contains all the operations that we gave in the combination:

     typename of result: struct ranges::transform_view<struct ranges::filter_view<struct ranges::ref_view<class std::vector<int,class std::allocator<int> > >,class `void __cdecl print_range_type(void)'::`2'::<lambda_1> >,class `void __cdecl print_range_type(void)'::`2'::<lambda_2> >
    
  3. The code prints the results by executing the algorithm composition in the result view object one step at a time in a for loop. Because the algorithms defined for data sets can be executed one step at a time, it is also possible to make the code asynchronous using the C++20 coroutine library.

The main features that come with the Ranges library in the C++20 standard are:

  • Access Providers

           
    begin rbegin cbegin crbegin
    end rend cend crend
    size data cdata empty
  • Range Consepts

           
    range common_range input_range bidirectional_range
    borrowed_range view output_range random_access_range
    sized_range viewable_range forward_range contiguous_range
  • Range Generators
    • empty : It is used to create an empty range object.

          std::ranges::empty_view<int> e;
          static_assert(std::ranges::empty(e));
          static_assert(0 == e.size());
      
    • single : It is used to create a single-element range object.
    • iota : It is used to create a range object increasing from the specified number.

        int main()
        {
          for (int i : std::ranges::iota_view{1, 10})
            std::cout << i << ' ';
          std::cout << '\n';
      
          for (int i : std::views::iota(1, 10))
            std::cout << i << ' ';
          std::cout << '\n';
      
          for (int i : std::views::iota(1) | std::views::take(9))
            std::cout << i << ' ';
          std::cout << '\n';
        }
      
      Output:
        1 2 3 4 5 6 7 8 9
        1 2 3 4 5 6 7 8 9
        1 2 3 4 5 6 7 8 9
      
    • counted : It is used to create a range object that takes a specified number of elements from another range.

        int main()
        {
          int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
          for(int i : std::views::counted(a, 3))
            std::cout << i << ' ';
          std::cout << '\n';
      
          const auto il = {1, 2, 3, 4, 5};
          for (int i : std::views::counted(il.begin() + 1, 3))
            std::cout << i << ' ';
          std::cout << '\n';
        }
      
      Output:
        1 2 3
        2 3 4
      
  • Range Adapters
    • all : It is used to create a range object that holds all the elements of the given object.

        int main()
        {
          std::vector<int> v{0,1,2,3,4,5};
          for(int n : std::views::all(v) | std::views::take(2) ) {
            std::cout << n << ' ';
          }
        }
      
      Output:
        0 1
      
    • ref_view : It is used to create a range object that will have reference of the another range.

        const std::string s{"cosmos"};
      
        const std::ranges::take_view tv{s, 3};
        const std::ranges::ref_view rv{tv};
      
        std::cout
          << std::boolalpha
          << "call empty() : " << rv.empty() << '\n'
          << "call size()  : " << rv.size() << '\n'
          << "call begin() : " << *rv.begin() << '\n'
          << "call end()   : " << *(rv.end()-1) << '\n'
          << "call data()  : " << rv.data() << '\n'
          << "call base()  : " << rv.base().size() << '\n' // ~> tv.size()
          << "range-for    : ";
      
        for (const auto c: rv) { std::cout << c; }
        std::cout << '\n';
      
      Output:
        call empty() : false
        call size()  : 3
        call begin() : c
        call end()   : s
        call data()  : cosmos
        call base()  : 3
        range-for    : cos
      
    • filter : It is used to create a range object that filters with the given comparator.
    • transform : It is used to create a range object that transforms the objects in the range.

        int main()
        {
          std::vector<int> ints{0, 1, 2, 3, 4, 5};
          auto even = [](int i) { return 0 == i % 2; };
          auto square = [](int i) { return i * i; };
      
          for (int i : ints | std::views::filter(even) | std::views::transform(square)) {
            std::cout << i << ' ';
          }
        }
      
      Output:
        0 4 16
      
    • take : It is used to create a range object that shows the desired number of elements within the range.
    • take_while : It is used to create a range object that displays elements within the Range as long as the given condition is met.

        auto print = [](char x) { std::cout << x; };
      
        int main()
        {
          constexpr char pi[] { '3', '.', '1', '4', '1', '5', '9', '2', '6', '5' };
      
          std::ranges::for_each(pi | std::ranges::views::take(8), print);
          std::cout << '\n';
          std::ranges::for_each(std::ranges::take_view{pi, 4}, print);
          std::cout << '\n';
      
          for (int year : std::views::iota(2017)
                        | std::views::take_while([](int y) { return y <= 2020; })) {
            std::cout << year << ' ';
          }
          std::cout << '\n';
        }
      
      Output:
        3.141592
        3.14
        2017 2018 2019 2020
      
    • drop : It is used to create a range object that ignores the given number of elements in the range.
    • drop_while : It is used to create a range object that ignores range elements as long as the condition given in the range is met.

        int main()
        {
          std::vector nums = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
      
          for (int i : nums | std::views::drop(2))
            std::cout << i << ' ';
          std::cout << '\n';
      
          for (int i : std::views::iota(1, 10) | std::views::drop(2))
            std::cout << i << ' ';
          std::cout << '\n';
      
          static constexpr auto v = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
          for (int n : v | std::views::drop_while([](int i) { return i < 3; })) {
              std::cout << n << ' ';
          }
        }
      
      Output:
        3 4 5 6 7 8 9
        3 4 5 6 7 8 9
        3 4 5 6 7 8 9
      
    • reverse : It is used to create a range object that returns the elements in the reverse order.

        int main()
        {
          static constexpr auto il = {3, 1, 4, 1, 5, 9};
          std::ranges::reverse_view rv {il};
      
          for (int i : rv)
              std::cout << i << ' ';
          std::cout << '\n';
      
          for(int i : il | std::views::reverse)
              std::cout << i << ' ';
        }
      
      Output:
        9 5 1 4 1 3
        9 5 1 4 1 3
      

Range Library vs STL Algorithms

Range kütüphanesinin gerçek gücünü karşılaştırmak ve karmaşık algoritmaların nasıl sade kod parçaları ile kodlanabileceğini görmek için örnek bir problem üzerinden ilerleyelim. Problemin çözümünü hem STL algoritmaları, hem de range kütüphanesi ile kodlayarak, iki yaklaşımın performansını ve koda olan etkisini inceleyelim.

Let’s proceed with an example problem to compare the real power of the Range library and see how complex algorithms can be coded with simple code snippets. Let’s code the solution to the problem with both STL algorithms and the Range library, and examine the performance and impact of the two approaches on the code.

The Problem

Let’s develop an application that makes some operations on the grades of the students. And to make the operations a bit more complicated, we will also change the math grade of the students which has an average grade of 80 or higher. Lastly, we will list the student’s name and new average grade.

// All information has been kept in a single structure for the maintaining simplicity in the example.
struct student
{
  string name;
  string surname;
  float math, physics, science, history, sports, ...;
  float average() const { return ...; }
};

struct student_w_average
{
  std::string_view name;
  float average_grade{};
};

When we want to write our code using the algorithms in the standard library, we need to create a separate temporary list for the students who passed the average from the list given to us, change the math grade of the students in the new list, and create a list with a separate data structure that holds the new average grade and the student’s name. Which means:

void solve_with_stl(const vector<student>& students)
{
  vector<reference_wrapper<const student>> students_above_average;        // (1)
  copy_if(begin(students), end(students),
          back_inserter(students_above_average),
          [](const auto& s)
          {
            return s.average() >= 80.f;
          });
  
  vector<student_w_average> result_list;                                  // (2)
  result_list.resize(students_above_average.size());
  transform(begin(students_above_average), end(students_above_average),
            begin(result_list),
            [](const auto& student_ref)
            {
              auto& s = student_ref.get();                                // (3)
              const auto new_average = mean_of(60.f, s.physics,
                  s.science, s.history, s.sports, s.geometry, s.music);
              return student_w_average{ s.name, new_average };
            });

  for (const auto& s : result_list) {                                     // (4)
    std::cout
      << "student: " << s.name
      << "\tnew average: " << s.average_grade << "\n";
  }
}
  1. We need to change math degree of the students above the average by taking them from the main list and placing them in a separate list. To do this, we create a separate list that holds references to the students in the main list, and copy the references of the desired students using the copy_if function

    Note: The reference_wrapper<const student> type actually means const student&. Since, copying the Student class would more expensive in terms of memory management than copying its reference, using its reference here will be more optimal.

  2. We create a second list to store the students in a separate data structure with their new average grades. As we have already calculated the number of students that will be stored in the new list, we can pre-initialize the size of the vector to elide the resizing of the vector capacity as we insert the data.

  3. We call transform function and fill in our new data structure using the information in the students_above_average list; while doing this, we need to access the student reference again from the reference holder object (student_ref: reference_wrapper<const student>) we are using. Afterwards, the average is calculated with the new math grade and the result data structure is returned

  4. Finally, after all calculations, we print the names of the students with their new average grades on the console.

The code we wrote for filtering and converting data is surprisingly long and complex, isn’t it? Moreover, the use of reference holders and the creation of multiple temporary lists can lead to errors in our code in the future.


Now let’s write the same code with the range library that comes with C++20:

void solve_with_ranges(const vector<student>& students)
{
  auto result = students
    | std::views::filter([](const student& s)
      { return s.average() >= 80.f; })                                  // (1)
    | std::views::transform([](const student& s)
      {
        auto new_average = mean_of(60.f, s.physics,                     // (2)
            s.science, s.history, s.sports, s.geometry, s.music);
        return student_w_average{ s.name, new_average };
      });

  for (const auto& s : result)                                          // (3)
  {
    std::cout
      << "student: " << s.name
      << "\tnew average: " << s.average_grade << "\n";
  }
}
  1. We define the range adapter that will filter out above average students

  2. We define the transform object that will return the result data structure by calculating the new average for the students that passes from the filter. Therefore, we prepare the algorithm that requested from us.

  3. Finally, we print the names of the students with their new average grades on the console. Despite of the previous example, this time the calculations and the transformation is happining while we iterate through the result list. Each time, when the for loop request for new result, range library executes the adapters we have created and return the next possible transformed student or ends the loop.

Both implementations produce the same results. The STL algorithm solution requires the use of temporary objects, while the range library solution does not. This is because the range library uses a lazy approach architecture and allows adapter objects to be combined.

The range library, which comes with the new C++20 standard, improves the readability and performance of C++ code, taking the language to the next level. When the capabilities of the Range and Coroutine libraries are combined, applications can be made asynchronous and more powerfull seamlessly. This power that C++ provides in the field of functional programming offers the same performance as performance-oriented and functional programming languages such as Haskell.


All the codes for the above example is shared below.