How can I implement a lazy version of a cartesian product function in modern C++ that allows for iteration over combinations without computing all of them upfront? I currently have a function that generates the complete cartesian product, but I want to modify it to support lazy evaluation so I can interrupt the iteration mid-way if needed.
Lazy cartesian product implementation in modern C++ can be achieved using C++20 coroutines with generators or custom iterator classes, allowing you to iterate over combinations without computing them all upfront and enabling mid-iteration interruption. These approaches leverage lazy evaluation to generate each combination on-demand, significantly improving memory efficiency and performance when working with large or infinite product spaces.
Contents
- Understanding Lazy Cartesian Product
- C++20 Coroutine Approach
- Iterator-Based Approach
- Performance Considerations
- Complete Implementation Examples
- Usage Comparison
- Best Practices
Understanding Lazy Cartesian Product
A lazy cartesian product generates combinations one at a time rather than precomputing the entire result set. This approach offers several key advantages:
- Memory efficiency: Only one combination exists in memory at any given time
- Early termination: You can stop iteration mid-way without computing remaining combinations
- Performance: No upfront computation cost for unused combinations
- Scalability: Can handle very large or even infinite product spaces
As noted in the Stack Overflow discussion, traditional eager implementations compute all combinations upfront, which becomes impractical for large input sizes.
The fundamental difference between eager and lazy approaches lies in when computation occurs. Eager implementations calculate all combinations before iteration begins, while lazy implementations calculate each combination only when requested.
C++20 Coroutine Approach
C++20 coroutines provide elegant support for lazy evaluation through the std::generator type. This approach allows you to write sequential-looking code that produces values lazily.
Basic Generator Implementation
#include <coroutine>
#include <vector>
#include <ranges>
#include <numeric>
template<typename T>
std::generator<std::vector<T>> cartesian_product_lazy(
const std::vector<std::vector<T>>& inputs) {
if (inputs.empty()) {
co_return;
}
// Initialize indices
std::vector<size_t> indices(inputs.size(), 0);
std::vector<T> current;
while (true) {
// Build current combination
current.clear();
for (size_t i = 0; i < inputs.size(); ++i) {
current.push_back(inputs[i][indices[i]]);
}
co_yield current;
// Increment indices (like an odometer)
bool carry = true;
for (long i = indices.size() - 1; i >= 0 && carry; --i) {
if (indices[i] + 1 < inputs[i].size()) {
indices[i]++;
carry = false;
} else {
indices[i] = 0;
}
}
if (carry) {
break; // All combinations generated
}
}
}
Using C++20 Ranges
The C++20 ranges library provides a cartesian_product_view, but it has limitations for lazy iteration. A coroutine-based approach offers more control.
As explained in the cppreference documentation, std::generator “generates a sequence of elements by repeatedly resuming the coroutine from which it was returned. Each time a co_yield statement is evaluated, the coroutine produces one element of the sequence.”
Iterator-Based Approach
For environments where coroutines aren’t available or preferred, an iterator-based approach provides similar benefits. The GitHub repository by cwzx demonstrates this concept well.
Lazy Cartesian Product Iterator
template<typename T>
class cartesian_product_iterator {
private:
std::vector<std::vector<T>> inputs;
std::vector<size_t> indices;
bool is_end;
void increment_indices() {
bool carry = true;
for (long i = indices.size() - 1; i >= 0 && carry; --i) {
if (indices[i] + 1 < inputs[i].size()) {
indices[i]++;
carry = false;
} else {
indices[i] = 0;
}
}
is_end = carry;
}
public:
using value_type = std::vector<T>;
using difference_type = ptrdiff_t;
using pointer = const std::vector<T>*;
using reference = const std::vector<T>&;
using iterator_category = std::input_iterator_tag;
cartesian_product_iterator(
const std::vector<std::vector<T>>& inputs,
bool is_end = false)
: inputs(inputs), indices(inputs.size(), 0), is_end(is_end) {
if (!inputs.empty() && is_end) {
// Set to end state
std::fill(indices.begin(), indices.end(), 0);
}
}
reference operator*() const {
static thread_local std::vector<T> current;
current.clear();
for (size_t i = 0; i < inputs.size(); ++i) {
current.push_back(inputs[i][indices[i]]);
}
return current;
}
pointer operator->() const {
return &(*(*this));
}
cartesian_product_iterator& operator++() {
increment_indices();
return *this;
}
cartesian_product_iterator operator++(int) {
auto temp = *this;
++(*this);
return temp;
}
bool operator==(const cartesian_product_iterator& other) const {
if (is_end != other.is_end) return false;
if (is_end) return true; // Both are end iterators
return inputs == other.inputs && indices == other.indices;
}
bool operator!=(const cartesian_product_iterator& other) const {
return !(*this == other);
}
};
template<typename T>
class cartesian_product_range {
private:
std::vector<std::vector<T>> inputs;
public:
cartesian_product_range(const std::vector<std::vector<T>>& inputs)
: inputs(inputs) {}
auto begin() const {
return cartesian_product_iterator<T>(inputs, false);
}
auto end() const {
return cartesian_product_iterator<T>(inputs, true);
}
};
// Factory function
template<typename T>
cartesian_product_range<T> make_cartesian_product(
const std::vector<std::vector<T>>& inputs) {
return cartesian_product_range<T>(inputs);
}
Performance Considerations
When choosing between coroutine and iterator approaches, several performance factors should be considered:
Memory Usage
- Coroutine approach: Uses heap allocation for coroutine frame
- Iterator approach: Minimal overhead, typically O(1) additional memory
As noted in the Stack Overflow discussion, “coroutines use the heap (not stack) so even just copy of the parameters are not going to be easily optimised by the compiler.”
Compilation Time
- Coroutine implementations may increase compilation time
- Iterator approaches are more traditional and compile faster
Optimization Potential
The Code Review Stack Exchange discussion highlights that “the compiler relies on the ‘nestedness’ of loops to optimize. The product_iterator removes this, so the compiler is unable to reason about it as perfectly.”
However, modern compilers are increasingly sophisticated at optimizing iterator-based code.
Complete Implementation Examples
Coroutine-Based Complete Example
#include <iostream>
#include <vector>
#include <coroutine>
#include <ranges>
template<typename T>
std::generator<std::vector<T>> lazy_cartesian_product(
const std::vector<std::vector<T>>& inputs) {
if (inputs.empty()) {
co_return;
}
std::vector<size_t> indices(inputs.size(), 0);
std::vector<T> current;
while (true) {
// Build current combination
current.clear();
for (size_t i = 0; i < inputs.size(); ++i) {
current.push_back(inputs[i][indices[i]]);
}
co_yield current;
// Increment indices
bool carry = true;
for (long i = indices.size() - 1; i >= 0 && carry; --i) {
if (indices[i] + 1 < inputs[i].size()) {
indices[i]++;
carry = false;
} else {
indices[i] = 0;
}
}
if (carry) {
break;
}
}
}
// Usage example
void demonstrate_lazy_cartesian() {
std::vector<std::vector<int>> inputs = {
{1, 2, 3},
{4, 5},
{6, 7, 8, 9}
};
std::cout << "Lazy Cartesian Product (first 5 combinations):\n";
size_t count = 0;
for (const auto& combo : lazy_cartesian_product(inputs)) {
if (count++ >= 5) break; // Early termination
std::cout << "[";
for (size_t i = 0; i < combo.size(); ++i) {
if (i > 0) std::cout << ", ";
std::cout << combo[i];
}
std::cout << "]\n";
}
}
Iterator-Based Complete Example
template<typename T>
class cartesian_product_iterator {
// ... (implementation from earlier) ...
};
template<typename T>
class cartesian_product_range {
// ... (implementation from earlier) ...
};
// Usage example
void demonstrate_iterator_cartesian() {
std::vector<std::vector<std::string>> inputs = {
{"a", "b", "c"},
{"x", "y"},
{"1", "2", "3"}
};
std::cout << "Iterator-based Cartesian Product (first 4 combinations):\n";
size_t count = 0;
for (const auto& combo : make_cartesian_product(inputs)) {
if (count++ >= 4) break; // Early termination
std::cout << "[";
for (size_t i = 0; i < combo.size(); ++i) {
if (i > 0) std::cout << ", ";
std::cout << combo[i];
}
std::cout << "]\n";
}
}
Usage Comparison
Eager vs Lazy Implementation
Here’s a comparison between traditional eager and lazy approaches:
// Eager implementation (traditional)
std::vector<std::vector<int>> eager_cartesian_product(
const std::vector<std::vector<int>>& inputs) {
std::vector<std::vector<int>> result;
if (inputs.empty()) return result;
std::vector<size_t> indices(inputs.size(), 0);
std::vector<int> current;
while (true) {
current.clear();
for (size_t i = 0; i < inputs.size(); ++i) {
current.push_back(inputs[i][indices[i]]);
}
result.push_back(current);
// Increment indices
bool carry = true;
for (long i = indices.size() - 1; i >= 0 && carry; --i) {
if (indices[i] + 1 < inputs[i].size()) {
indices[i]++;
carry = false;
} else {
indices[i] = 0;
}
}
if (carry) break;
}
return result;
}
// Usage comparison
void compare_approaches() {
std::vector<std::vector<int>> inputs = {
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10},
{11, 12, 13, 14, 15}
};
std::cout << "Eager approach (computes all "
<< (inputs[0].size() * inputs[1].size() * inputs[2].size())
<< " combinations):\n";
auto eager_result = eager_cartesian_product(inputs);
// Only process first 3 combinations
for (size_t i = 0; i < 3 && i < eager_result.size(); ++i) {
std::cout << "[";
for (size_t j = 0; j < eager_result[i].size(); ++j) {
if (j > 0) std::cout << ", ";
std::cout << eager_result[i][j];
}
std::cout << "]\n";
}
std::cout << "\nLazy approach (computes only needed combinations):\n";
size_t count = 0;
for (const auto& combo : lazy_cartesian_product(inputs)) {
if (count++ >= 3) break; // Only compute what we need
std::cout << "[";
for (size_t i = 0; i < combo.size(); ++i) {
if (i > 0) std::cout << ", ";
std::cout << combo[i];
}
std::cout << "]\n";
}
}
Memory Usage Comparison
For the input with 5×5×5 = 125 combinations:
- Eager approach: Stores all 125 combinations in memory simultaneously
- Lazy approach: Stores only 1 combination at a time, plus metadata
This makes lazy approaches particularly valuable for larger product spaces.
Best Practices
Choosing Between Approaches
-
Use coroutines when:
- You’re using C++20 or later
- Code readability is a priority
- You need the most concise implementation
-
Use iterators when:
- You need maximum performance
- You’re working in environments without coroutine support
- You need compatibility with existing iterator-based algorithms
Error Handling
Both approaches should handle edge cases:
- Empty input containers
- Single-element containers
- Containers with different sizes
Template Considerations
Make sure your implementations:
- Work with any copyable type T
- Handle const-correctness properly
- Support move semantics where beneficial
Testing
Test thoroughly with:
- Various input sizes
- Different element types
- Edge cases (empty inputs, single elements)
- Performance benchmarks for large inputs
Conclusion
Implementing a lazy cartesian product in modern C++ provides significant advantages over traditional eager approaches, particularly for large input spaces or when early termination is needed. The C++20 coroutine approach offers clean, readable code while the iterator-based approach provides maximum performance and compatibility.
Key takeaways:
- Lazy evaluation eliminates upfront computation costs and memory overhead
- C++20 coroutines provide an elegant solution with
std::generator - Iterator-based approaches offer performance benefits and broader compatibility
- Early termination is naturally supported without wasting computation
- Memory efficiency is dramatically improved compared to eager implementations
For most modern C++ projects using C++20 or later, the coroutine-based approach is recommended for its readability and expressiveness. In performance-critical scenarios or when working with older C++ standards, the iterator-based approach remains an excellent choice.
Both implementations enable you to iterate over cartesian product combinations without computing them all upfront, giving you the flexibility to stop iteration whenever needed while maintaining optimal resource usage.
Sources
- Lazy iteration on cartesian product - Stack Overflow
- Lazy iterators library - GitHub
- Product iterator - GitHub
- Generalized Cartesian Product - Code Review Stack Exchange
- Simple generic cartesian product in C++ - Code Review Stack Exchange
- Cartesian Product using Iterators and Variadic Templates - Stack Overflow
- Implement lazy generator as forward_iterator in C++ - Stack Overflow
- C++20 Coroutines - A First Overview - MC++ BLOG
- Coroutines (C++20) - cppreference.com
- std::generator - cppreference.com