c++ - Replacing default parameters with variadic templates - Stack Overflow

I'm about to implement something with C++ variadic templates, but I haven't seen it used ofte

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:

  1. If I want to specify b, I must specify a.
    • The alternative is to add a foo(double) constructor.
  2. If I want to add a new member float c, then users must specify the values for b and a.
    • The alternative is to add foo(float), foo(double, float), foo(int, float), foo(int, double, float), but the number of overloads increases by n!.

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 a double, or Name instead of a string).
  • 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:

  1. If I want to specify b, I must specify a.
    • The alternative is to add a foo(double) constructor.
  2. If I want to add a new member float c, then users must specify the values for b and a.
    • The alternative is to add foo(float), foo(double, float), foo(int, float), foo(int, double, float), but the number of overloads increases by n!.

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 a double, or Name instead of a string).
  • 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
  • 2 An alternative could be to only make one constructor which accepts any combination of user-provided parameters example. That way you can have any mix of types you'd like – Ted Lyngmo Commented Mar 3 at 14:56
  • 1 If you don't mind making the class an aggregate, designated initializers sounds like it would help. To bad it's not a feature non-aggregate classes can use. Maybe once we get reflection. – NathanOliver Commented Mar 3 at 15:01
  • 3 If the behavior is changed depending on if the user supplies .a = 1 or leaves a to the default, 1, I would say that there's a design flaw. No user would expect that specifying 1 or leaving it to the default 1 has different meaning. – Ted Lyngmo Commented Mar 3 at 15:09
  • 1 Do not over-engineer this. Note your proposal will work with following usage: foo{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 if foo is something more complex, builder pattern should solve this problem nicely. – Marek R Commented Mar 3 at 15:23
  • 1 "but I don't understand why I don't see it in more places.". As you said, unique type constraint, added complexity; In addition, template (so code in header, error message generally harder to read, limited completion/IDE help), we got some kind of coherency by respecting an order. There are other simple alternative as set value afterward, or more complicated/verbose as Builder. And finally, it is really an issue to have to give parameter in correct order? – Jarod42 Commented Mar 3 at 15:25
 |  Show 11 more comments

1 Answer 1

Reset to default 0

You 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

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信