Skip to content
C++
Idiom
since C++98
Basic

Named Parameter Idiom

Simulate named function arguments in C++ using method-chaining builders, options structs, and C++20 designated initializers for self-documenting call sites.

Named Parameter Idiomsince C++98

A technique that simulates named function arguments by expressing configuration through a builder's chainable setters or an aggregate struct's fields, making call sites self-documenting and resistant to argument-order bugs.

Overview

C++ passes arguments positionally. When a function accepts more than two or three parameters β€” especially booleans or integers β€” the call site becomes opaque:

cpp
// What does each argument mean?
auto conn = connect("db.host", 5432, true, false, 30, 10, true);

No amount of IDE tooltips fully solves this at code review time. The named parameter idiom attacks the problem at the API level, with two main strategies:

  • Builder (method chaining) β€” a dedicated object accumulates configuration through named setters, then constructs the target. Natural for objects requiring validation or invariant enforcement. C++11 move semantics make this efficient.
  • Options struct β€” a plain aggregate holds configuration with defaults. C++20 designated initializers give it syntax nearly identical to true named parameters.

Choose the options struct for simple configuration bags. Reach for a builder when validation is non-trivial, construction order matters, or the result should be immutable.

Examples

Builder with Method Chaining

Each setter returns *this (C++11). Declare members with defaults so unset fields don't need explicit initialization.

cpp
#include <chrono>
#include <string>
#include <unordered_map>

class HttpRequest {
public:
    HttpRequest& url(std::string u) {
        url_ = std::move(u);    // C++11: move instead of copy
        return *this;
    }
    HttpRequest& method(std::string m) {
        method_ = std::move(m);
        return *this;
    }
    HttpRequest& header(std::string key, std::string val) {
        headers_.emplace(std::move(key), std::move(val));  // C++11
        return *this;
    }
    HttpRequest& timeout(std::chrono::milliseconds t) {    // C++11: chrono
        timeout_ = t;
        return *this;
    }

    Response send();   // executes the request

private:
    std::string url_;
    std::string method_ = "GET";
    std::unordered_map<std::string, std::string> headers_;  // C++11
    std::chrono::milliseconds timeout_{5'000};              // C++14: digit separator
};

// Self-documenting at the call site
auto response = HttpRequest{}
    .url("https://api.example.com/users")
    .method("POST")
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .timeout(std::chrono::seconds{10})  // C++11: duration literals
    .send();

Separate Builder for Immutable Objects

When the constructed object should be immutable post-construction, separate the builder from the product. Mark build() as && (ref-qualifier, C++11) so it can only be called on a temporary or explicitly moved builder β€” preventing accidental reuse.

cpp
class TlsConfig {
public:
    class Builder {
    public:
        Builder& cert_file(std::string path) {
            cert_ = std::move(path);
            return *this;
        }
        Builder& key_file(std::string path) {
            key_ = std::move(path);
            return *this;
        }
        Builder& ca_bundle(std::string path) {
            ca_ = std::move(path);
            return *this;
        }
        Builder& verify_peer(bool v) {
            verify_ = v;
            return *this;
        }

        // rvalue ref-qualifier (C++11): builder is consumed, no second build()
        TlsConfig build() && {
            if (cert_.empty() || key_.empty())
                throw std::invalid_argument{"cert and key are required"};
            return TlsConfig{std::move(cert_), std::move(key_),
                             std::move(ca_), verify_};
        }

    private:
        std::string cert_, key_, ca_;
        bool verify_ = true;
    };

private:
    TlsConfig(std::string c, std::string k, std::string ca, bool v)
        : cert_(std::move(c)), key_(std::move(k)), ca_(std::move(ca)), verify_(v) {}

    const std::string cert_, key_, ca_;
    const bool verify_;
};

auto tls = TlsConfig::Builder{}
    .cert_file("/etc/ssl/server.crt")
    .key_file("/etc/ssl/server.key")
    .ca_bundle("/etc/ssl/ca-bundle.crt")
    .build();

Options Struct with Designated Initializers (C++20)

For configuration without complex validation, an aggregate struct with defaults is less ceremony than a builder:

cpp
struct ConnectOptions {
    std::string host          = "localhost";
    uint16_t    port          = 5432;
    bool        ssl           = false;
    int         connect_timeout_ms = 5'000;   // C++14: digit separators
    int         pool_size     = 10;
    std::string user          = "postgres";
    std::string password;
    std::string application_name;
};

std::shared_ptr<DB> connect(ConnectOptions opts = {});

// C++20: designated initializers β€” members named at the call site
// Unspecified fields get their struct defaults.
auto db = connect({
    .host          = "db.prod.internal",
    .ssl           = true,
    .pool_size     = 50,
    .user          = "app",
    .password      = secret,
    .application_name = "payments-service",
});

Constraint: C++20 designated initializers must appear in the same order as the struct's member declarations. Reordering designators is ill-formed. Pre-C++20, aggregate initialization works without field names β€” rely on defaults and document the struct carefully.

Type-State Builder: Required Parameters at Compile Time

A template-based type-state builder uses a bitfield template parameter to track which required fields have been set. build() is only enabled when all required bits are present.

cpp
template<unsigned Flags = 0>
class ServerBuilder {
    static constexpr unsigned kHost = 1u << 0;
    static constexpr unsigned kPort = 1u << 1;
    static constexpr unsigned kAll  = kHost | kPort;

    std::string host_;
    int port_        = 0;
    bool ssl_        = false;
    int max_conn_    = 128;

    template<unsigned F> friend class ServerBuilder;

    // Private: copy state into a new builder with updated Flags
    template<unsigned NewFlags>
    ServerBuilder<NewFlags> rebrand() && {
        ServerBuilder<NewFlags> next;
        next.host_     = std::move(host_);
        next.port_     = port_;
        next.ssl_      = ssl_;
        next.max_conn_ = max_conn_;
        return next;
    }

public:
    [[nodiscard]] ServerBuilder<Flags | kHost> host(std::string h) && {
        host_ = std::move(h);
        return std::move(*this).template rebrand<Flags | kHost>();
    }
    [[nodiscard]] ServerBuilder<Flags | kPort> port(int p) && {
        port_ = p;
        return std::move(*this).template rebrand<Flags | kPort>();
    }
    ServerBuilder& ssl(bool s)       & { ssl_      = s; return *this; }
    ServerBuilder& max_connections(int n) & { max_conn_ = n; return *this; }

    // C++20 requires: only callable when host and port are set
    Server build() && requires ((Flags & kAll) == kAll) {
        return Server{std::move(host_), port_, ssl_, max_conn_};
    }
};

// Compile error if .host() or .port() is missing β€” not a runtime error
auto s = ServerBuilder<>{}
    .host("api.example.com")
    .port(443)
    .ssl(true)
    .build();

The requires clause (C++20) makes build() disappear from the overload set entirely when preconditions aren't met, producing a clear diagnostic rather than a runtime exception.

Best Practices

  • Prefer options structs for pure configuration β€” they have lower overhead and C++20 designated initializers give the same readability as a builder.
  • Use a builder when construction has invariants β€” validate in build(), not in individual setters. Deferring validation means you see the complete picture before throwing.
  • Mark build() as && β€” prevents calling build() twice on the same builder object and signals intent clearly.
  • Return *this by reference, not value β€” returning by value in a setter copies the entire builder state on each call. Return *this or move *this into a new type (type-state pattern only).
  • Tag setter return types with [[nodiscard]] for type-state builders β€” discarding an intermediate builder silently loses required-field tracking.
  • Give the builder a static create() factory when the target type's name is long β€” Server::Builder::create().host(...).build() reads better than Server::Builder{}.host(...).build().

Common Pitfalls

Forgetting return *this β€” a setter that returns void silently breaks the chain. The compiler won't warn; the first chained call simply fails to compile with a confusing message.

Builder aliasing and reuse β€” storing a builder in a variable and calling setters on it is fine for the single-type (non-type-state) builder, but re-calling build() constructs a second object from potentially moved-from state. Mark build() as && to catch this.

Designated initializer ordering β€” C++20 requires designators in declaration order. Swapping .port before .host in the initializer when host is declared first is ill-formed. Some compilers emit only a warning; treat it as an error.

Boolean setters β€” avoid builder.ssl(true). Prefer builder.enable_ssl() / builder.disable_ssl() or an enum. A boolean setter is only marginally better than the positional boolean it replaces.

Options struct as mutable config β€” pass ConnectOptions by value into the consuming function. Callers holding a reference to an options struct and modifying it after the call is a maintenance trap.

Named Parameters via Designated Initializers (C++20)

C++20 designated initializers are the idiomatic named-parameter pattern for most codebases. When a function accepts a struct by value (or const reference), callers can name every argument at the call site without any builder class at all.

cpp
// C++20
struct ConnectionOptions {
    std::string host     = "localhost";
    uint16_t    port     = 5432;
    bool        verbose  = false;
    int         timeout  = 5'000;   // milliseconds
};

// Caller names each argument with dot syntax β€” unset fields keep their defaults
void connect(ConnectionOptions opts);

connect({ .verbose = true, .timeout = 100 });
connect({ .host = "db.prod.internal", .port = 5433, .verbose = false });

The approach eliminates the builder class entirely for pure-configuration use cases. Compare it directly with the method-chaining builder: designated initializers require no extra type, no build() call, and no move machinery; the trade-off is that cross-field validation must happen inside connect() rather than being encapsulated in the builder.

cpp
// C++20 β€” real-world connection example
struct ConnectionOptions {
    std::string host             = "localhost";
    uint16_t    port             = 5432;
    std::string user             = "postgres";
    std::string password;
    bool        ssl              = false;
    int         connect_timeout  = 5'000;
    int         pool_size        = 10;
    std::string application_name = "app";
};

std::shared_ptr<DB> open_db(ConnectionOptions opts = {});

// Named, self-documenting, and impossible to swap host/port by accident
auto db = open_db({
    .host             = "db.prod.internal",
    .port             = 5433,
    .user             = "payments",
    .password         = secret,
    .ssl              = true,
    .pool_size        = 50,
    .application_name = "payments-service",
});

Designators must appear in the same order as the struct's member declarations. Reordering them is ill-formed; treat any compiler warning about ordering as an error.

Chained Builder with Validation

Validation belongs in build(), not in individual setters, because only build() sees the complete, final state. Individual setters may accept values that are locally valid but globally inconsistent β€” for example, a port number in range but conflicting with a protocol choice. Deferring all checks to build() makes the error message accurate and contextual.

cpp
// C++23
#include <expected>
#include <string>
#include <stdexcept>

enum class BuildError { InvalidPort, MissingHost, PortHostConflict };

class ServerBuilder {
public:
    ServerBuilder& set_host(std::string h) {
        host_ = std::move(h);
        return *this;
    }

    ServerBuilder& set_port(int p) {
        port_ = p;      // range check deferred to build()
        return *this;
    }

    ServerBuilder& set_ssl(bool s) {
        ssl_ = s;
        return *this;
    }

    // C++23: std::expected carries either the result or a typed error
    std::expected<Server, BuildError> build() && {
        if (host_.empty())
            return std::unexpected{BuildError::MissingHost};
        if (port_ < 1 || port_ > 65535)
            return std::unexpected{BuildError::InvalidPort};
        if (ssl_ && port_ == 80)
            return std::unexpected{BuildError::PortHostConflict};
        return Server{std::move(host_), port_, ssl_};
    }

private:
    std::string host_;
    int  port_ = 443;
    bool ssl_  = true;
};

// Caller handles the error explicitly β€” no exceptions, no silent failures
auto result = ServerBuilder{}
    .set_host("api.example.com")
    .set_port(443)
    .set_ssl(true)
    .build();

if (result)
    result->listen();
else
    log_error(result.error());

For pre-C++23 codebases, build() can throw std::invalid_argument instead. The key design principle is unchanged: collect all configuration first, then validate the whole picture in one place.

Named Parameters in Template Functions

Passing a struct with designated initializers cleanly extends the named-parameter idiom to template functions. The alternative β€” multiple defaulted template or function parameters β€” forces callers to specify earlier arguments even when they only want to change the last one.

cpp
// C++20 β€” options struct gives every parameter a name and a default
struct SerializeOptions {
    bool pretty_print  = false;
    bool include_nulls = true;
    int  indent_spaces = 2;
    bool sort_keys     = false;
};

template<typename T>
std::string serialize(const T& val, SerializeOptions opts = {});

// All defaults β€” zero-argument call still works
auto compact = serialize(my_obj);

// Only change the parameters that matter
auto pretty  = serialize(my_obj, { .pretty_print = true, .indent_spaces = 4 });
auto sorted  = serialize(my_obj, { .sort_keys = true });

Contrast this with the unwieldy multi-parameter alternative that forces callers to repeat defaults for every argument they do not intend to override:

cpp
// C++11 β€” caller must supply every positional argument before the one they care about
template<typename T>
std::string serialize(const T& val,
                      bool pretty_print  = false,
                      bool include_nulls = true,
                      int  indent_spaces = 2,
                      bool sort_keys     = false);

// To set sort_keys=true, every preceding default must be repeated explicitly
auto sorted = serialize(my_obj, false, true, 2, true);

Struct default member initializers (C++11) ensure that a caller who passes {} gets exactly the documented defaults without any extra code. The single struct type also provides a natural documentation anchor and can be forward-declared across translation units.

Comparison: Named Parameter Idioms

C++20 designated initializers are the cleanest modern approach for pure configuration; builder chains remain the right tool when construction has non-trivial validation or ordering constraints. The table below summarizes the trade-offs:

ApproachCall-site syntaxCompile-time checkRuntime validationExtra type neededC++ standard
Builder chain.setHost("x").setPort(80).build()Only with type-state builderIn build()Yes β€” builder classC++98/11
Struct + designated init{ .host = "x", .port = 80 }Member types onlyIn consuming fnYes β€” options structC++20
Tag dispatchhost{"x"}, port{80}Tag typesIn constructorYes β€” one tag per paramC++11
std::optional parametersstd::nullopt, std::nullopt, 80PartialIn function bodyNoC++17

Tag dispatch (host{"x"}, port{80}) achieves true positional independence but requires one wrapper type per parameter β€” practical only for APIs with a small, stable parameter set. std::optional parameters avoid extra types but merely rename positional arguments; callers still supply std::nullopt placeholders for every skipped argument.

Named Parameters in Test Frameworks

Named parameters dramatically improve test readability when constructing complex fixtures. A test that spells out exactly which fields differ from the default is far easier to understand at a glance than one that passes a long positional argument list.

cpp
// Test builder pattern β€” each setter names what it configures
auto response = make_request()
    .with_method("POST")
    .with_url("/api/v1/users")
    .with_header("Content-Type", "application/json")
    .with_header("Authorization", "Bearer " + test_token)
    .with_body(R"({"name":"Alice","role":"admin"})")
    .send();

EXPECT_EQ(response.status(), 201);
EXPECT_TRUE(response.json()["id"].is_number());

GoogleTest's EXPECT_CALL fluent matchers follow the same pattern β€” EXPECT_CALL(mock, Method(arg)).Times(1).WillOnce(Return(val)) chains named expectations rather than encoding them in positional arguments. The same philosophy applies to fixture construction: use a builder so each test expresses only the parameters relevant to its assertion.

cpp
// C++20 β€” designated initializer test fixtures are even more concise
struct RequestFixture {
    std::string method  = "GET";
    std::string url     = "/ping";
    std::string body;
    std::string content_type = "application/json";
    bool        authenticated = true;
};

Response execute(RequestFixture f = {});

// Test reads like a spec
TEST(ApiTest, UnauthenticatedPostReturns401) {
    auto r = execute({ .method = "POST", .url = "/api/secret", .authenticated = false });
    EXPECT_EQ(r.status(), 401);
}

Named parameters in tests serve as living documentation: a failing test immediately tells the reader which configuration triggered the failure, without requiring them to decode positional argument lists.

Named Parameters vs std::format Named Args

C++20 std::format does not support named arguments. Unlike Python's str.format(name=value) or Rust's format!("{name}"), every std::format placeholder is positional ({0}, {1}, or bare {}). The comparison below clarifies what C++20 offers and what it does not:

cpp
// C++20 β€” positional placeholders only; no {host} or {port} syntax
#include <format>

std::string msg = std::format("connecting to {}:{}", host, port);      // positional
std::string msg = std::format("connecting to {0}:{1}", host, port);    // explicit index
// std::format("connecting to {host}:{port}", host, port) β€” ill-formed in C++20

The idiomatic C++20 workaround is to pre-populate a struct with named fields, which achieves the "named substitution" effect via designated initializers even though the format string itself is positional:

cpp
// C++20 β€” struct fields carry the names; format string references by position
struct ConnMsg {
    std::string host = "localhost";
    uint16_t    port = 5432;
    std::string user = "app";
};

ConnMsg ctx{ .host = "db.prod.internal", .port = 5433, .user = "payments" };
auto log_line = std::format("[{}] {}@{}:{}", timestamp(), ctx.user, ctx.host, ctx.port);

For runtime format strings, std::vformat accepts a std::format_args pack assembled at runtime:

cpp
// C++20 β€” runtime format string selection
std::string fmt_str = load_format_from_config();
std::string result  = std::vformat(fmt_str,
    std::make_format_args(ctx.host, ctx.port, ctx.user));

The absence of named format arguments is a deliberate C++20 design decision, not an oversight β€” named args would require embedding string keys into the format-args pack, complicating the type system. A proposal for named format arguments (std::format("{host}", ...)) is under active discussion for a future standard.

See Also

  • std::initializer_list β€” aggregate initialization mechanics underlying options structs
  • CRTP β€” use CRTP to avoid repeating setter boilerplate in builder hierarchies
  • Policy-Based Design β€” compile-time parameterization complementary to runtime builders