Abstract
Right now, this will not compile:
struct Foo { int a, b; }
struct Bar : Foo { using Foo::Foo; }
auto bar = Bar{1, 2}; // ERROR
Which is confusing and unintuitive. This paper proposes an improvement to this behaviour.
Motivation
C++ has a reputation for having burrs and sharp edges. It is not just the big-ticket items that are issues here, it’s hundreds of small nuisances that add up over time. These impact every workflow and, the developer’s experience. I want to, through this paper, improve just one of those burrs.
Problems
We are encouraging poor code
When a constructor is inherited from an aggregate, the object cannot be constructed with a non-default value:
template <typename T>
struct Inherit : T { using T::T; };
struct Aggregate { int a; int b; };
// @clang | error: no matching constructor for initialization of 'Inherit<Aggregate>'
auto item = Inherit<Aggregate>{1, 2};
A developer has several options here, one of which is to fall back on the still available default initialization, and manually assign values to each data member, like this:
auto data = Inherit<Aggregate>{};
tag.a = 1;
tag.b = 2;
When a user is forced towards code like this, they surrender compiler provided checks such as -Wmissing-field-initializers
.
We are increasing friction on designated initializer usage
Ordinarily, brace elision allows us to construct base classes avoiding a soup of braces. Designated initializers unfortunately, do not avoid brace soup:
struct Foo { int a, b, c; };
struct Bar : Foo {};
auto item = Bar{ { .a = 1, .b = 2, .c = 3 } }; // braces!
Proposal
The goal of this proposal will be to address the problems outlined in previous sections. To do that, we must allow non-default initialization of aggregates with inherited constructors.
Here I have two ways to allow that, each will use the following example:
template <typename T>
struct B
{
T a;
T b;
T c;
};
template <typename T>
struct W : B<T>
{
using B::B;
};
Option A: Ignoring inherited constructors entirely
Make using B::B
a no-op when B
is an aggregate. In this way, non-default initialization is still possible.
Disadvantages
-
An extra layer of braceness is still required for use of designated initializers.
-
Explicit initialization of the base class is required for use with deduction.
-
Inconsistent with inherited constructors, where all members but the inherited one are default constructed.
Option B: Inherit initialization
When B
is an aggregate and W
includes using B::B
; all bases and data members of W
will be default initialized, except for B
which will be initialized with the arguments applied at construction.
Advantages
-
Template deduction may be able to be applied from the base to the parent without the additional layer of braceness. In this case, all template arguments of the derived class must be deducible from the base class.
-
Designated initializers work out of the box, without extra layers of braceness.
-
Just like the inherited constructor case, the inherited type is constructed with the provided arguments and others default constructed.
Example
// works: direct initialization of a, b, c.
W<int> a{ 1, 2, 3 };
// works: as aggregate initialization was inherited.
W<int> b{ .a = 1, .b = 2, .c = 3 };
// works: the same way that `W{ .a=1, .b=2, .c=3 }` would be deduced.
W c{ .a = 1, .b = 2, .c = 3 };
// works: the same way that `W{W{ .a=1, .b=2, .c=3 }}` works.
W d{ W{ .a = 1, .b = 2, .c = 3 } };
// error: the same way that `W<int>{{ .a=1, .b=2, .c=3 }}` is an error.
W<int> e{ { .a = 1, .b = 2, .c = 3 } };
// error: unable to deduce `T`.
W f{ { .a = 1, .b = 2, .c = 3 } };
Preferred option
In addition to the types found in Problems, we need some additional types to compare the options:
template <typename T>
struct Plain : T {};
struct NonAggregate { NonAggregate(int, int) {} };
Now we can compare:
Current: T=NonAggregate |
Option A: T=Aggregate |
Option B: T=Aggregate |
|
---|---|---|---|
|
✓ |
✓ |
✓ |
|
✓ |
✓[1] |
✓ |
|
✗ |
✓ |
✗ |
Option B presents identical behaviour to that of the T=NonAggregate
case, and with the potential additional benefits it is my preferred option.
Patterns
With new functionality, some patterns will emerge, this section explores just two examples Improving type safety and Invariants after construction.
Improving type safety
Trivial wrappers of types can be used to improve type safety in systems. Wrappers range from those used for dimensional analysis, to those that prevent accidental swapping of parameters.
Example
To implement such a wrapper, one may be tempted to do the following:
template <typename T, auto Tag>
struct Tag : T
{
using T::T;
... omitted for brevity ...
};
struct Aggregate { int a, b, c; };
This, for non-aggregates will allow construction of the object, as-if it were a T
. However, if T
were an aggregate, the resulting object can only be default constructed. When used as Tag<Aggregate, 0>
, it would be impossible to construct the data members with non-default values. This is unintuitive behaviour.
Tag<Aggregate, 0> tag{}; // compiles
// @gcc | error: no matching function for call to
// 'Tag<Aggregate, 0>::Tag(<brace-enclosed initializer list>)'
// @clang | error: no matching constructor for initialization of
// 'Tag<Aggregate, 0>'
Tag<Aggregate, 0> tag{1, 2, 3}; // this proposal!
Being unable to provide values at construction leads to here again We are encouraging poor code:
Tag<Aggregate, 0> tag{};
tag.a = 1;
tag.b = 2;
tag.c = 3;
Invariants after construction
It is standard practice to ensure data members with invariants are private, at the moment to achieve this with an aggregate, composition would be required.
With composition, this can be achieved but there is a cost:
-
Loss of direct initialization, preventing the construction of immovable types[2].
-
Taking an aggregate as an argument of a constructor requires an additional layer of braceness, increasing conative load with no benefit(example:
{{.a=1, .b=2, .c=3}}
).
Example
Here I show the issue with immovable types, specifically that composition does not work, nor do inherited constructors:
struct Immovable
{
int value;
Immovable(int v) : value(v) {}
Immovable(Immovable&&)=delete;
Immovable(Immovable const &)=delete;
Immovable& operator=(Immovable&&)=delete;
Immovable& operator=(Immovable const &)=delete;
};
struct W { Immovable a, b, c; };
template <typename T>
struct Inheritance : private T { using T::T; };
Inheritance<W> b{ .a = 1, .b = 2, .c = 3 }; // this proposal!
With this, we can hold an invariant from the point of construction onwards, with the added ergonomics of designated initializers at construction.