Table of Contents

C++20 Ranges algorithms have an excellent feature called “Projection”. In short, it’s a callable applied on each range element before further processing. In this article, I’ll show you a couple more examples of this handy feature.

Intro  

According to the C++20 Standard: [defns.projection]:

projection: transformation that an algorithm applies before inspecting the values of elements

std::pair<int, std::string_view> pairs[] = {
    {2, "foo"}, {1, "bar"}, {0, "baz"}
};
std::ranges::sort(pairs, std::ranges::less{},
              [](auto const& p) { return p.first; }
);

Above, we have a projection that takes a pair and then extracts only the first member, and this member is used for sorting. In other words we sort whole elements of the array or a collection, but we use obly some part of it for comparison.

By default, each algorithm uses identity projection. For example, here’s an extract from copy_if function signature:

constexpr copy_if_result<I, O>
    copy_if( I first, S last, O result, Pred pred, Proj proj = {} );

Such a projection is then invoked through std::invoke and supports the following transformations:

  • a lambda or other callable object
  • a pointer to a member function
  • a pointer to data member

I described how it works in detail in my other article: C++20 Ranges, Projections, std::invoke and if constexpr - C++ Stories.

Let’s look at some examples to see this feature in action.

Sorting  

Let’s start with something simple like sorting; we can expand the example from the Standard:

#include <algorithm>
#include <ranges>
#include <iostream>

int main() {
    std::pair<int, std::string_view> pairs[] = {
        {2, "foo"}, {1, "bar"}, {0, "baz"}
    };

    // member access:
    std::ranges::sort(pairs, std::ranges::less{}, 
        &std::pair<int, std::string_view>::first);

    // a lambda:
    std::ranges::sort(pairs, std::ranges::less{}, 
        [](auto const& p) { return p.first; });
}

Run @Compiler Explorer.

We can also try sorting structures:

struct Box {
    std::string name;    
    double w = 0.0;
    double h = 0.0;
    double d = 0.0;

    constexpr double volume() const { return w*h*d; }
};

void print(std::string_view intro, const std::vector<Box>& container) {
    std::cout << intro << '\n';
    for (const auto &elem : container)
        std::cout << std::format("{}, volume {}\n", elem.name, elem.volume());
}

int main() {
    const std::vector<Box> container {
        {"large cubic", 10, 10, 10}, {"small cubic", 3, 3, 3},
        {"large long", 10, 2, 2}, {"small", 3, 2, 2}
    };
    
    print("initial", container);

    // the ranges version:
    auto copy = container;   
    std::ranges::sort(copy, {}, &Box::name);    
    print("after sorting by name", copy);           
    std::ranges::sort(copy, {}, &Box::volume);    
    print("after sorting by volume", copy);     
}

Run @Compiler Explorer

And here’s the output:

initial
large cubic, volume 1000
small cubic, volume 27
large long, volume 40
small, volume 12
after sorting by name
large cubic, volume 1000
large long, volume 40
small, volume 12
small cubic, volume 27
after sorting by volume
small, volume 12
small cubic, volume 27
large long, volume 40
large cubic, volume 1000

In the example, I used a simple structure and then passed a pointer to a data member or a pointer to a member function.

Transform  

See the code below:

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

struct Product {
    std::string name_;
    double value_ { 0.0 };
};

int main() {
    std::vector<Product> prods{7, {"Box ", 1.0}};

    // standard version:  
    std::transform(begin(prods), end(prods), begin(prods), 
        [v = 0](const Product &p) mutable {
            return Product { p.name_ + std::to_string(v++), 1.0};
        }
    );
    for (auto &p : prods) std::cout << p.name_ << ", ";
    std::cout << '\n';

    // ranges version:  
    std::ranges::transform(prods, begin(prods), 
        [v = 0](const std::string &n) mutable {
            return Product { n + std::to_string(v++), 1.0};
        }, 
        &Product::name_);
    for (auto &p : prods) std::cout << p.name_ << ", ";
}

Play @Compiler Explorer.

The output:

Box 0, Box 1, Box 2, Box 3, Box 4, Box 5, Box 6, 
Box 00, Box 11, Box 22, Box 33, Box 44, Box 55, Box 66, 

The standard version of the std::transform creates names using the entire Product& p parameter. Then the ranges version takes only the string parameter and expands that name. Notice the different parameters passed into the transformation lambda.

Max element  

In my article about std::format, I needed to find the longest string in a map of names:

const std::map<std::string, std::array<double, 5>> productToOrders{
        { "apples", {100, 200, 50.5, 30, 10}},
        { "bananas", {80, 10, 100, 120, 70}},
        { "carrots", {130, 75, 25, 64.5, 128}},
        { "tomatoes", {70, 100, 170, 80, 90}}
};

Initially, I had the following function:

template <typename T>
size_t MaxKeyLength(const std::map<std::string, T>& m) {
    if (m.empty())
        return 0;
        
	auto res = std::ranges::max_element(std::views::keys(m), 
	[](const auto& a, const auto& b) {
		return a.length() < b.length();
		});
	return (*res).length();
}

The above code uses a custom comparator that looks at a given property from the keys view.

But we can change it to use a projection:

template <typename T>
size_t MaxKeyLength(const std::map<std::string, T>& m) {
    if (m.empty())
        return 0;
    auto res = std::ranges::max_element(std::views::keys(m), 
        std::less{}, &std::string::length // <<
        );
    return (*res).length();
}

See this line:

std::less{}, &std::string::length // <<

I’m using a default comparator and a custom projection that is a pointer to a member function.

See more articles about Ranges on the blog:

Custom algorithm  

Projection is not reserved for Ranges. You can also use it in your code. Here’s an example with a function that prints a container:

void PrintEx(const std::ranges::range auto& container, auto proj) {
	for (size_t i = 0; auto && elem : container)
		std::cout << std::invoke(proj, elem) 
                  << (++i == container.size() ? "\n" : ", ");
};

int main() {
    std::vector<std::pair<int, char>> pairs { {0, 'A'}, {1, 'B'}, {2, 'C'}};
    PrintEx(pairs, &std::pair<int, char>::first);
    PrintEx(pairs, &std::pair<int, char>::second);
}

See @Compiler Explorer

The “magical” part is to use std::invoke on the proj parameter. And that’s all.

And we can also enhance the code with a default template parameter:

template <typename Proj = std::identity>
void PrintEx(const std::ranges::range auto& container, Proj proj = {}) {
	for (size_t i = 0; auto && elem : container)
		std::cout << std::invoke(proj, elem) 
                  << (++i == container.size() ? "\n" : ", ");
};

And now, we can write the following demo:

int main() {
    std::vector<std::pair<int, char>> pairs { {0, 'A'}, {1, 'B'}, {2, 'C'}};
    PrintEx(pairs, &std::pair<int, char>::first);
    PrintEx(pairs, &std::pair<int, char>::second);

    std::vector numbers {1, 2, 3, 4, 5};
    PrintEx(numbers); // default proj
    PrintEx(numbers, [](auto& v) { return v*v;});

    std::map<std::string, int> words { {"Hello", 1}, {"World", 2 }};
    PrintEx(words, [](auto& keyVal){ return keyVal.first;});
    PrintEx(words, &std::map<std::string, int>::value_type::second);
}

Play with code @Compiler Explorer.

Summary  

Projections are handy “transformers” that you can apply on each range element before it’s sent to an algorithm. Once you learn the basics, you can use them to write shorter and more compact code.

Back to you

  • Have you tried Projections?
  • Do you use ranges in your projects?

Join the discussion below.