I just watched a presentation by Nicolai Josuttis “The Nightmare of Move Semantics for Trivial Classes”
In the presentation he shows how to build a "perfect customer class", so that it's constructor accepts up to all three arguments with as few mallocs as possible.
In cases like he presents I use an idiom (shown below), that he didn't consider in the presentation, nor did I find it elsewhere, yet I think it is suitable in both terms of performance and usage. It uses private inheritance of a struct, that has the members. The constructor template uses a parameter pack to do the job. So I wonder: is there anything wrong with this approach? Should I expect some problems in performance or usage? Any other pitfalls? Is this a known idiom that I just missed? (But why it wasn't there?)
Here is the code for the class:
#include <string>
#include <utility>
struct CustomerData
{
std::string first;
std::string last;
int id;
};
class Customer: private CustomerData
{
public:
// Constructor template
template<typename... Args>
Customer(Args... args):
CustomerData{std::move(args)...}
{
}
};
Edit: 1) POD changed to struct (POD containing string is not a POD, thanks to a remark by Daniel Langr), 2) added an explanatory sentence if one doesn't want to watch the whole video
Edit 2: std::forward changed to std::move, thanks to remark of Jarod42
CodePudding user response:
When I extend the example with a "logging string"
#include <iostream>
#include <string>
#include <utility>
class CustomString {
public:
CustomString(const char *) {
std::cout << "CustomString(const char*)\n"; }
CustomString(const std::string &) {
std::cout << "CustomString(const std::string&)\n"; }
CustomString(std::string &&) {
std::cout << "CustomString(std::string&&)\n"; }
CustomString(const CustomString &) {
std::cout << "CustomString(const CustomString&)\n"; }
CustomString(CustomString &&) {
std::cout << "CustomString(CustomString&&)\n"; }
};
struct CustomerData
{
CustomString first;
CustomString last;
int id;
};
class Customer: private CustomerData
{
public:
// Constructor template
template<typename... Args>
Customer(Args... args):
CustomerData{std::move(args)...}
{
}
};
int main()
{
std::cout << "John Doe\n";
Customer c{"John", "Doe", 1};
std::cout << "Jane\n";
CustomString jane{"Jane"};
std::cout << "Jane Doe\n";
Customer d{jane, "Doe", 1};
std::cout << "move Jane Doe\n";
Customer e{std::move(jane), "Doe", 1};
}
it gives this output
John Doe
CustomString(const char*)
CustomString(const char*)
Jane
CustomString(const char*)
Jane Doe
CustomString(const CustomString&)
CustomString(CustomString&&)
CustomString(const char*)
move Jane Doe
CustomString(CustomString&&)
CustomString(CustomString&&)
CustomString(const char*)
In the second and third example, you can see an additional move, which doesn't happen in the talk's version. This might or might not make a (small) difference.
CodePudding user response:
template <typename Args>
Customer(Args... args) :
CustomerData{std::move(args)...}
{
}
is comparable to
Customer(std::string first, std::string last = "", int id = -1) :
first(std::move(first)), last(std::move(last)), id(id)
{}
(Which is the preferred variant from the video, but not the most efficient, as there is some (up to 4) extra move constructor)
Default values (which is also a concern in the video) can be handled directly in your data structure.
Performance are the same.
Your version is less verbose, but also less self documenting.
As for most template (forwarding reference in particular), it disallows initializer_list (test case not used/shown in the video):
Customer c({42, 'a'}, {it1, it2}, 51);
Your version is not SFINAE friendly: std::is_constructible_v<Customer, float, std::vector<int>>
would give incorrect answer.
In the same way, example from video about inheritance:
Vip vip{/*..*/};
Customer c(vip); // Would select wrongly you constructor
You can add SFINAE/requires
to handle those cases though.
I would compare with another alternative
template <typename... Args>
// requires(is_aggregate_constructible_v<CustomerData, Args&&...>)
Customer(Args&&... args) : CustomerData{std::forward<Args>(args)...}
{
}
which would be comparable to
template <typename S1, typename S2 = std::string>
// requires (std::is_constructible_v<std::string, S1&&>
&& std::is_constructible_v<std::string, S2&&>)
Customer(S1&& first, S2&& last = "", int id = -1) :
first(std::forward<S1>(first)),
second(std::forward<S2>(second)),
id(id),
{}
The most efficient alternative.
Again,
- Same performance
- less verbose and potentially less self documenting.
Here, both versions are template, and both versions have the issue with the construction with the initializer list.
To conclude, your version without requires
has some pitfalls.
If you add the requires
, then going to the proposed alternative (forwarding reference instead of by value) would be more efficient.