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:
- I forgot a
noexcept
in the move constructor. fixed. std::initializer
lists can't be moved. So I extendedstd::vector
and used template parameter pack to move elements wherestd::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?