ryanhaining / cppitertools

Implementation of python itertools and builtin iteration functions for C++17
https://twitter.com/cppitertools
BSD 2-Clause "Simplified" License
1.37k stars 115 forks source link

Does iter::enumerate copy lvalue collections? #74

Closed cleeff closed 4 years ago

cleeff commented 4 years ago

The example for enumerate looks like this:

std::vector<int> vec{2,4,6,8};
for (auto&& p : enumerate(vec)) { /* ... */ }

The documentation says that no copy of vec is produced here and I'm wondering how this works. In the implementation of iter::impl::Enumerable it looks like the class holds the container by value:

template <typename Container, typename Index>
class iter::impl::Enumerable {
 private:
  Container container_;  // <--- Is this type std::vector<int> in the above example?
  const Index start_;

  friend EnumerateFn;

  // Value constructor for use only in the enumerate function
  Enumerable(Container&& container, Index start)
      : container_(std::forward<Container>(container)), start_{start} {} // <-- moves for rvalues but wouldn't it copy for the example above?
ryanhaining commented 4 years ago

Great question.

The short answer is that if you pass an lvalue to enumerate then the Container type in Enumerable is an lvalue reference. So in this example Container is std::vector<int>&. The std::forward<Container>(container) call thus only moves rvalues, no copies of the lvalue.

The long answer: there's a lot of layers of templates in the real cppitertools code, but here is a simpler example that demonstrates the technique:

#include <iostream>

namespace {
// a class that announces all its creation, copy, and destruction operators
class Talker {
  public:
    Talker() {
      std::cout << "ctor\n";
    }

    Talker(const Talker&) {
      std::cout << "copy ctor\n";
    }

    Talker& operator=(const Talker&) {
      std::cout << "copy assign\n";
      return *this;
    }

    ~Talker() {
      std::cout << "dtor\n";
    }
};

template <typename T>
class Holder {
  private:
    T t_;
  public:
    Holder(T t) : t_{std::forward<T>(t)} {}
};

// if arg is an lvalue, T is an lvalue reference
// and Holder<T> has an lvalue reference as well
template <typename T>
Holder<T> make_holder(T&& arg) {
  return {std::forward<T>(arg)};
}

}

int main() {
  Talker talker;
  auto h = make_holder(talker); // T will be Talker&
}

live link

I'm not sure how much you know about forwarding references, but specifying T&& as make_holder's parameter's type means that if you pass an lvalue, the type T resolves to Talker&, so now you effectively have Talker& && as the type for arg which by reference collapsing rules turns into Talker&. The return type is then Holder<Talker&>, so the Holder template gets instantiated as

class Holder {
  private:
    Talker& t_;
  public:
    Holder(Talker& t) : t_{std::forward<Talker&>(t)} {}
};

so there's no copies. If you change the main to instead do make_holder(T{}); you will see copy constructor calls in the output. live example

The call to enumerate is doing this (through a call operator in one of the template template bases here), and all the tools that take sequences follow this pattern in all sensible cases.

Does that make sense?

cleeff commented 4 years ago

Wow, thanks a lot for the super detailed explanation. Learned something new about C++ :-)