C++ – Python-style Keyword Args in C++ – good practice or bad idea

cpython

While trying to figure out the optimal ordering for optional parameters to a function recently, I stumbled across this blog post and accompanying GitHub repo, which provides a header for a Pythonic kwargs-like facility in C++. Though I didn't end up using it, I find myself wondering whether this is a good or not in a strongly-typed language. Having worked in Python for a while, I find the notion of a kwargs-like facility in my project very appealing because many of its objects/functions have a number of optional parameters (which cannot be avoided, unfortunately), yielding long lists of constructors that differ by one or two parameters and could be made much more succinct/DRY-ish.

What, if anything, is others' experience with stuff like this? Should it be avoided? Are there guidelines for it? What are the potential problems/pitfalls?

Best Answer

I'm not very much familiar with C++ kwargs but a couple of disadvantages come to mind after skimming their source:

  1. It's a third-party library. Kind of obvious, but still you need to figure a way to integrate it into your project and update the source when the original repo is changed.
  2. They require globally pre-declaring all arguments. The simple example on the blog post has this section which is dead weight:

    #include "kwargs.h"
    
    // these are tags which will uniquely identify the arguments in a parameter
    // pack
    enum Keys {
      c_tag,
      d_tag
    };
    
    // global symbols used as keys in list of kwargs
    kw::Key<c_tag> c_key;
    kw::Key<d_tag> d_key;
    
    // a function taking kwargs parameter pack
    template <typename... Args>
    void foo(int a, int b, Args... kwargs) {
      // first, we construct the parameter pack from the parameter pack
      kw::ParamPack<Args...> params(kwargs...);
    
      ...
    

    Not as concise as the pythonic original.

  3. Potential binary bloat. Your function needs to be a variadic template, so each permutation of parameters will generate the binary code anew. The compiler is often unable to see they differ in trivialities and merge the binaries.
  4. Slower compilation times. Again, your function needs to be a template and the library itself is template-based. There's nothing wrong with templates but compilers need time to parse and instantiate them.

C++ offers native alternatives to achieve the functionality of named parameters:

  1. Struct wrappers. Define your optional parameters as fields of a struct.

    struct foo_args {
        const char* title = "";
        int year = 1900;
        float percent = 0.0;
    };
    
    void foo(int a, int b, const foo_args& args = foo_args())
    {
        printf("title: %s\nyear: %d\npercent: %.2f\n",
            args.title, args.year, args.percent);
    }
    
    int main()
    {
        foo_args args;
        args.title = "foo title";
        args.percent = 99.99;
        foo(1, 2, args);
    
        /* Note: in pure C brace initalizers could be used instead
           but then you loose custom defaults -- non-initialized
           fields are always zero.
    
           foo_args args = { .title = "foo title", .percent = 99.99 };
        */
        return 0;
    }
    
  2. Proxy objects. Arguments are stored in a temporary struct which can be modified with chained setters.

    struct foo {
        // Mandatory arguments
        foo(int a, int b) : _a(a), _b(b) {}
    
        // Optional arguments
        // ('this' is returned for chaining)
        foo& title(const char* title) { _title = title; return *this; }
        foo& year(int year) { _year = year; return *this; }
        foo& percent(float percent) { _percent = percent; return *this; }
    
        // Do the actual call in the destructor.
        // (can be replaced with an explicit call() member function
        // if you're uneasy about doing the work in a destructor) 
        ~foo()
        {
            printf("title: %s\nyear: %d\npercent: %.2f\n", _title, _year, _percent);
        }
    
    private:
        int _a, _b;
        const char* _title = "";
        int _year = 1900;
        float _percent = 0.0;
    };
    
    
    int main()
    {
        // Under the hood:
        //  1. creates a proxy object
        //  2. modifies it with chained setters
        //  3. calls its destructor at the end of the statement
        foo(1, 2).title("foo title").percent(99.99);
    
        return 0;
    }
    

    Note: the boilerplate can be abstracted out to a macro at the expense of readability:

    #define foo_optional_arg(type, name, default_value)  \
        public: foo& name(type name) { _##name = name; return *this; } \
        private: type _##name = default_value
    
    struct foo {
        foo_optional_arg(const char*, title, "");
        foo_optional_arg(int, year, 1900);
        foo_optional_arg(float, percent, 0.0);
    
        ...
    
  3. Variadic functions. This obviously is type-unsafe and requires knowledge of type promotions to get right. It is, however, available in pure C if C++ is not an option.

    #include <stdarg.h>
    
    // Pre-defined argument tags
    enum foo_arg { foo_title, foo_year, foo_percent, foo_end };
    
    void foo_impl(int a, int b, ...)
    {
        const char* title = "";
        int year = 1900;
        float percent = 0.0;
    
        va_list args;
        va_start(args, b);
        for (foo_arg arg = (foo_arg)va_arg(args, int); arg != foo_end;
            arg = (foo_arg)va_arg(args, int))
        {
            switch(arg)
            {
            case foo_title:  title = va_arg(args, const char*); break;
            case foo_year:  year = va_arg(args, int); break;
            case foo_percent:  percent = va_arg(args, double); break;
            }
        }
        va_end(args);
    
        printf("title: %s\nyear: %d\npercent: %.2f\n", title, year, percent);
    }
    
    // A helper macro not to forget the 'end' tag.
    #define foo(a, b, ...) foo_impl((a), (b), ##__VA_ARGS__, foo_end)
    
    int main()
    {
        foo(1, 2, foo_title, "foo title", foo_percent, 99.99);
    
        return 0;
    }
    

    Note: In C++ this can be made type-safe with variadic templates. The run-time overhead will be gone at the expense of slower compilation times and binary bloat.

  4. boost::parameter. Still a third-party library, albeit more established lib than some obscure github repo. Drawbacks: template-heavy.

    #include <boost/parameter/name.hpp>
    #include <boost/parameter/preprocessor.hpp>
    #include <string>
    
    BOOST_PARAMETER_NAME(foo)
    BOOST_PARAMETER_NAME(bar)
    BOOST_PARAMETER_NAME(baz)
    BOOST_PARAMETER_NAME(bonk)
    
    BOOST_PARAMETER_FUNCTION(
        (int),  // the return type of the function, the parentheses are required.
        function_with_named_parameters, // the name of the function.
        tag,  // part of the deep magic. If you use BOOST_PARAMETER_NAME you need to put "tag" here.
        (required // names and types of all required parameters, parentheses are required.
            (foo, (int)) 
            (bar, (float))
        )
        (optional // names, types, and default values of all optional parameters.
            (baz, (bool) , false)
            (bonk, (std::string), "default value")
        ) 
    )
    {
        if (baz && (bar > 1.0)) return foo;
        return bonk.size();
    }
    
    int main()
    {
        function_with_named_parameters(1, 10.0);
        function_with_named_parameters(7, _bar = 3.14);
        function_with_named_parameters( _bar = 0.0, _foo = 42);
        function_with_named_parameters( _bar = 2.5, _bonk= "Hello", _foo = 9);
        function_with_named_parameters(9, 2.5, true, "Hello");
    }
    

On a closing note, I wouldn't use this kwargs library simply because there is a number of good enough alternatives in C++ to achieve the same. I personally would opt for 1. or 2. from the (non-exhaustive) list above.

Related Topic