I'm about to implement something with C++ variadic templates, but I haven't seen it used often, and so I don't know whether this absence of pattern is a red-flag. Does what I'm about to do seem reasonable, or is there something I haven't considered and will regret?
Let's say I have an existing structure with a constructor and default values:
struct foo {
foo(int a = 1, double b = 2.0)
: _a(a), _b(b) {}
private:
int _a;
double _b;
};
I have two problems with this:
- If I want to specify
b
, I must specifya
.- The alternative is to add a
foo(double)
constructor.
- The alternative is to add a
- If I want to add a new member
float c
, then users must specify the values forb
anda
.- The alternative is to add
foo(float)
,foo(double, float)
,foo(int, float)
,foo(int, double, float)
, but the number of overloads increases byn!
.
- The alternative is to add
My solution is to use variadic tempaltes:
struct foo {
template<typename... Args>
foo(Args&&... args)
: _a(1), _b(2.0) {
setup(std::forward<Args>(args)...);
}
private:
template<typename... Args>
void setup(int a, Args&&... args) {
_a = a;
setup(std::forward<Args>(args)...);
}
template<typename... Args>
void setup(double b, Args&&... args) {
_b = b;
setup(std::forward<Args>(args)...);
}
void setup() {}
int _a;
double _b ;
};
At first it looks more complicated, but for each new argument we want to support, we just need to add one more setup()
and change no other code. It means order is arbitrary, and we just construct using whatever types matter to us. I can make the change, and still be API-compatible, avoiding any re-writes. I like it, but I don't understand why I don't see it in more places. I must be missing something.
Disadvantages that I can see:
- Every argument must be a unique type: but this might actually enforce some best-practices of passing specific types (
Speed
instead of adouble
, orName
instead of astring
). - Defined arguments are initialized twice (once in the initializer list, once in the
setup()
).
The only case where I've seen this used is in the superceeded boost::process::v1
In the documentation for the replacing library boost::process::v2, the author writes:
In process v1 one can define partial settings in the constructor of the process, which has lead to a small DSL.
child c{exe="test", args+="--help", std_in < null(), env["FOO"] += "BAR"};
While this looks fancy at first, it really does not scale well with more parameters. For process v2, the interfaces is simple:
extern std::unordered_map<std::string, std::string> my_env; extern asio::io_context ctx; process proc(ctx, "./test", {"--help"}, process_io{nullptr, {}, {}}, process_environment(my_env));
Every initializer addresses one logical component (e.g. stdio) instead of multiple ones accumulating. Furthermore, every process has a path and arguments, instead of a confusing mixture of cmd-style and exe-args that can be randomly spread out.
I'm not sure if this is a warning that should influence my design.
I'm about to implement something with C++ variadic templates, but I haven't seen it used often, and so I don't know whether this absence of pattern is a red-flag. Does what I'm about to do seem reasonable, or is there something I haven't considered and will regret?
Let's say I have an existing structure with a constructor and default values:
struct foo {
foo(int a = 1, double b = 2.0)
: _a(a), _b(b) {}
private:
int _a;
double _b;
};
I have two problems with this:
- If I want to specify
b
, I must specifya
.- The alternative is to add a
foo(double)
constructor.
- The alternative is to add a
- If I want to add a new member
float c
, then users must specify the values forb
anda
.- The alternative is to add
foo(float)
,foo(double, float)
,foo(int, float)
,foo(int, double, float)
, but the number of overloads increases byn!
.
- The alternative is to add
My solution is to use variadic tempaltes:
struct foo {
template<typename... Args>
foo(Args&&... args)
: _a(1), _b(2.0) {
setup(std::forward<Args>(args)...);
}
private:
template<typename... Args>
void setup(int a, Args&&... args) {
_a = a;
setup(std::forward<Args>(args)...);
}
template<typename... Args>
void setup(double b, Args&&... args) {
_b = b;
setup(std::forward<Args>(args)...);
}
void setup() {}
int _a;
double _b ;
};
At first it looks more complicated, but for each new argument we want to support, we just need to add one more setup()
and change no other code. It means order is arbitrary, and we just construct using whatever types matter to us. I can make the change, and still be API-compatible, avoiding any re-writes. I like it, but I don't understand why I don't see it in more places. I must be missing something.
Disadvantages that I can see:
- Every argument must be a unique type: but this might actually enforce some best-practices of passing specific types (
Speed
instead of adouble
, orName
instead of astring
). - Defined arguments are initialized twice (once in the initializer list, once in the
setup()
).
The only case where I've seen this used is in the superceeded boost::process::v1
In the documentation for the replacing library boost::process::v2, the author writes:
In process v1 one can define partial settings in the constructor of the process, which has lead to a small DSL.
child c{exe="test", args+="--help", std_in < null(), env["FOO"] += "BAR"};
While this looks fancy at first, it really does not scale well with more parameters. For process v2, the interfaces is simple:
extern std::unordered_map<std::string, std::string> my_env; extern asio::io_context ctx; process proc(ctx, "./test", {"--help"}, process_io{nullptr, {}, {}}, process_environment(my_env));
Every initializer addresses one logical component (e.g. stdio) instead of multiple ones accumulating. Furthermore, every process has a path and arguments, instead of a confusing mixture of cmd-style and exe-args that can be randomly spread out.
I'm not sure if this is a warning that should influence my design.
Share Improve this question edited Mar 3 at 14:57 Stewart asked Mar 3 at 14:46 StewartStewart 5,0824 gold badges37 silver badges71 bronze badges 16 | Show 11 more comments1 Answer
Reset to default 0You can devise a builder pattern that uses aggregates as inputs:
class foo{
public:
struct init{int a=1; int b=2;};
foo(init ini)
: a{ini.a}
, b{ini.b} {};
foo() : foo{init{}} {};
private:
int a, b;
};
foo a5 = foo::init{.a=5};
assert((a5.b==2));
foo b6 {{.b=6}};
assert((a6.a==1));
foo def;
assert((def.b==2 and def.a==1));
In this snippet, the aggregate type foo::init
is a simple constructor builder for foo
. It uses aggregate and designated initialization. Its members have default initializer values. The downsides are double braces and explicit overload of delegating default constructor. Similar patter can be devised for any function requiring named arguments. The other limitation is that in presence of another overload with single argument, you may need to be verbose:
foo::foo(other_type);
foo from_init {foo::init{.a{7},.b{8}};
foo from_other {other_type{}};
Final restriction is that init::a
and init::b
must appear in the same order as first declared; init{.b{},.a{}}
results in compile error, due to order violation.
Another option would be to define a type per argument - which IMHO - doesn't scale well.
发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745088466a4610547.html
.a = 1
or leavesa
to the default,1
, I would say that there's a design flaw. No user would expect that specifying1
or leaving it to the default1
has different meaning. – Ted Lyngmo Commented Mar 3 at 15:09foo{1, 2, 3.2, -1, 0.1);
and actual result is:foo(-1, 0.1)
. There are many way to approach this problem, but you have not provided details which could be used to select the best one. For example iffoo
is something more complex, builder pattern should solve this problem nicely. – Marek R Commented Mar 3 at 15:23