1

I have this little test code to show what operations std::vector does on a class A for various operations. I'm a bit disappointed by how many copy operations are done.

Update:

  1. I forgot a noexcept in the move constructor. fixed.
  2. std::initializer lists can't be moved. So I extended std::vector and used template parameter pack to move elements where std::initializer lists is used.

Code looks like this now: https://godbolt.org/z/5xc9sM6r7

#include <iostream>
#include <vector>
#include <source_location>

#define LOG std::cout << "@" << this << ":       \t" << (this->moved ? "moved" : "full") << "\t" << std::source_location::current().function_name() << std::endl
#define DO(x) std::cout << std::endl << "##### "#x" #####" << std::endl; x

template <typename T>
class vector : public std::vector<T> {
  public:
    vector() : std::vector<T>() { }
    template <typename... U>
    vector(U &&...u) {
        std::cout << "parameter pack" << std::endl;
        std::vector<T>::reserve(sizeof...(u));
        ([this](auto &&x){ std::vector<T>::emplace_back(std::move(x));}(u), ...);
    }
// can't delete this or the above doesn't work
//    template <typename U>
//    vector(std::initializer_list<U>) = delete;
};

struct A {
    A(int) { LOG; }
    A(const A &) { LOG; }
    A(A &&other) noexcept { LOG; other.moved = true; }
    A & operator=(const A &) { LOG; return *this; }
    A & operator=(A &&other) noexcept { LOG; other.moved = true; return *this; }
    ~A() { LOG; }
    bool moved{false};
};

int main() {
    DO(A a{A{A{A{A{A{1}}}}}});
    DO(vector<A> v{A{1}});
    DO(v.reserve(4));
    DO(v.emplace_back(1));
    DO(v.emplace_back(A{1}));
    DO({A a{1}; v.back() = a; });
    DO(v.back() = A{1});
    DO(return 0);
}

Output looks like this: https://godbolt.org/z/8YoM6aMGo

##### A a{A{A{A{A{A{1}}}}}} #####
@0x7ffc8a13ef1b:    full    A::A(int)

This is perfect, all the copies are elided and the object is constructed directly in-place where it belongs.

##### vector<A> v{A{1}} #####
@0x7fff4872de5c:    full    A::A(int)
parameter pack
@0xa3eec0:          full    A::A(A&&)
@0x7fff4872de5c:    moved   A::~A()

Still wish this would elide the copy. I'm thinking of this as ((A*)0x18ffec0)->A{A{1}}, loosely speaking.

This still seems to go through an std::initializer_list in some form. It fails if I delete that constructor in vector.

##### v.reserve(4) #####
@0xa3eee0:          full    A::A(A&&)
@0xa3eec0:          moved   A::~A()

This didn't move before because of missing noexcept.

##### v.emplace_back(1) #####
@0xa3eee1:          full    A::A(int)

Finally something works out, a nice in-place construction.

##### v.emplace_back(A{1}) #####
@0x7fff4872de5c:    full    A::A(int)
@0xa3eee2:          full    A::A(A&&)
@0x7fff4872de5c:    moved   A::~A()

Again with the construct + move. Why not construct the A at 0x18ffee2? Oddly enough this doesn't seem to suffer from an std::initializer_list breaking moves.

But couldn't this be made to construct in-place?

##### {A a{1}; v.back() = a; } #####
@0x7ffc8a13ef1c:    full    A::A(int)
@0x18ffee2:         full    A& A::operator=(const A&)
@0x7ffc8a13ef1c:    full    A::~A()

Nothing there the vector can do, must copy.

##### v.back() = A{1} #####
@0x7ffc8a13ef1c:    full    A::A(int)
@0x18ffee2:         full    A& A::operator=(A&&)
@0x7ffc8a13ef1c:    moved   A::~A()

More move semantic.

##### return 0 #####
@0x18ffee0:         full    A::~A()
@0x18ffee1:         full    A::~A()
@0x18ffee2:         full    A::~A()
@0x7ffc8a13ef1b:    full    A::~A()

Destruct of the vector and local variable.

Update 2: Using (...) instead of `{...}``to initialize skips the initializer list and goes straight to the template pack (as you can check if you delete the initializer list constructor):

##### vector<A> u(1,2,3,4) #####
parameter pack
@0x205dec0:         full    A::A(int)
@0x205dec1:         full    A::A(int)
@0x205dec2:         full    A::A(int)
@0x205dec3:         full    A::A(int)

##### vector<A> v(A{1}) #####
@0x7ffcd497202c:    full    A::A(int)
parameter pack
@0x205dee0:         full    A::A(A&&)
@0x7ffcd497202c:    moved   A::~A()

Doesn't help to achieve in-place construction though when a temporary object is given as argument.


Update 3: If you strip away all the in between layers and container logic I'm left with this simple bit of code:

A *p = static_cast<A*>(operator new[](sizeof(A), static_cast<std::align_val_t>(alignof(A))));
std::construct_at(p, A{1});

This gives the following output:

##### std::construct_at(p, A{1}) #####
@0x7ffe94fe5220:    full    A::A(int)
@0xa70eb0:          full    A::A(A&&)
@0x7ffe94fe5220:    moved   A::~A()

std::construct_at calls the move constructor for A a the given address. So it's just doing A{A{1}} except the address of the outer A is given as argument instead of some place on the stack the compiler choose.

So I'm really asking why std::construct_at(p, A{1}) and A{A{1}} aren't optimized the same way. Does the standard maybe already allow optimizing it? Or can we change the code or standard so it does optimize the same way?

Goswin von Brederlow
  • 11,875
  • 2
  • 24
  • 42
  • 3
    `initializer_list` does not move. – 康桓瑋 May 20 '22 at 01:41
  • I've updated the question with the missing noexcept and extended std::vector with a template parameter pack constructor. – Goswin von Brederlow May 20 '22 at 02:48
  • Possible Dupe of [std::vector initialization move/copy constructor of the element](https://stackoverflow.com/questions/24793019/stdvector-initialization-move-copy-constructor-of-the-element) – Jason May 20 '22 at 03:26
  • @AnoopRana Not a dupe of that any more. The question starts where the other ended. I want to get one step further eliding even more. – Goswin von Brederlow May 20 '22 at 14:02
  • Because copy elision requires knowledge of the address of the target address in the calling context, copy elision for `std::construct_at(p, A{1})` would only work if `std::construct_at` is inlined. Even then, there can be evaluations sequenced before the construction in the final location in the function, but after the construction of the temporary. If copy elision is applied, will the construction be sequenced before or after these evaluations? I think there will be quite a few of such questions to consider if that was to be allowed. `A{A{1}}` doesn't have these problems. – user17732522 May 20 '22 at 20:52
  • (`std::construct` specifically is of course not an issue, but I am taking it as an example for any other function call being passed a `A` by reference to copy/move-construct from.) – user17732522 May 20 '22 at 20:56
  • `std::construct_at` is a template. So it's always known to the compiler unless you have some specializations hidden away in object files. I looked at my STL and it just calls placement new. As for ordering even if there were some statements before the construction the address of the construction is known at the start of the function. And I believe copy elision works by having the callee write to the address of the caller. I would say the ordering is clear, call A{1} first and the later construction disappears because it's elided. – Goswin von Brederlow May 20 '22 at 21:03
  • FYI: If I replace the construct_at with the placement new call by hand the optimization happens. So its the forwarding of the argument to placement new that is causing the move construct. I want NVAO - non value argument optimization. – Goswin von Brederlow May 21 '22 at 00:09
  • Not that it helps removing the `move`s, but you could simplify your fold expression by removing the lambda: `(this->emplace_back(std::forward(u)), ...);` (and you should use `forward` instead of `move` there anyway). – Ted Lyngmo May 30 '22 at 20:29

1 Answers1

0
std::vector<A> v{A{1}}

This invokes the constructor that takes a std::initializer_list as a parameter. std::initializer_list provides access only to const values in the initializer list, which can't be moved.

v.reserve(4)

No moving here because your move constructor is not noexcept. You only declared your move-assignment operator as noexcept but you forgot about the move constructor. vector also requires a noexcept move constructor before it will employ move semantics when reallocating.

Declare your move constructor as noexcept and this instance, and the remaining ones that involve reallocation, should now employ move semantics.

Sam Varshavchik
  • 114,536
  • 5
  • 94
  • 148