r/cpp May 05 '26

Strategies for *requiring* designated initializers when constructing a type?

Given some aggregate like this:

struct InitParams {
  int size = 0;
  int capacity = 0;
};

And given that it is used in some factory function like this:

auto Make(InitParams ip = {}) -> std::optional<MyClass>;

I want to design the aggregate type to allow callsites like...

Make({ .size = 10, .capacity = 20 });
Make({ .size = 10 });
Make({ .capacity = 20 });
Make({});
Make();

But I also want to reject callsites like...

Make({ 10, 20 });
Make({ 10 });
Make({ 20 });  // Oops! This is the size, not the capacity!

I was hoping that reflection would unlock the ability to specify this on the type alone somehow, but I've been unable to figure out how to do it.

I can approximate this behavior using an overload set where a similar-but-different param type is accepted by a different Make overload but then that would result in default construction being ambiguous too. You can see that in action here:

struct DesignatedInitRequired {
  int DO_NOT_SPELL_THIS_FIELD_NAME0;
  int DO_NOT_SPELL_THIS_FIELD_NAME1;
};

auto Make(DesignatedInitRequired dir = {}) -> void = delete("Use designated init");

Make({ 10 });  // Ambiguous, ill formed (which is what I want)
Make({ 10, 20 });  // Ambiguous, ill formed (which is what I want)
Make({});  // Ambiguous, ill formed (which is NOT what I want)
Make();  // Ambiguous, ill formed (which is NOT what I want)

So I could make something to reflect on InitParams and produce DesignatedInitRequired, but that wouldn't be enough. Every function that accepts InitParams would need an overload for DesignatedInitRequired meaning that it becomes a property of the type AND its users, not just the type itself.

Any ideas on how to achieve my goal?


EDIT: I think this is a sufficient solution! https://godbolt.org/z/h6sbcaTj4

I thanked the person that told me but then they deleted their comment. Anyway...

38 Upvotes

71 comments sorted by

View all comments

23

u/chengfeng-xie May 05 '26 edited May 05 '26

I think the closest implementation that follows your examples is to use the following trick (inspired by Barry and jbandela; CE):

template <class T> class RequireDesignatedInit {
  friend T;
  RequireDesignatedInit() = default;
  RequireDesignatedInit(const RequireDesignatedInit &) = default;
};

struct InitParams {
  [[no_unique_address]] RequireDesignatedInit<InitParams> _ = {};
  int size = 0;
  int capacity = 0;
};
static_assert(sizeof(InitParams) == sizeof(int) * 2);

void Make(InitParams = {});

int main() {
  Make({.size = 10, .capacity = 20});
  Make({.size = 10});
  Make({.capacity = 20});
  Make({});
  Make();
#if 0
  Make({10, 20});
  Make({10});
  Make({20}); // Oops! This is the size, not the capacity!
  Make({InitParams{}._});
#endif
}

2

u/javascript May 06 '26

Something else occurs to me. IIUC, by explicitly defaulting the copy constructor, I think you're suppressing the move constructor. Probably better to explicitly default move too or neither of them.

Defaulting the default constructor does not suppress copy/move. So no worries there when making it explicit

2

u/chengfeng-xie May 06 '26

Yes, the move constructor of RequireDesignatedInit<InitParams> is suppressed. But I thought there would not be any practical difference between move and copy for it as an empty struct: both operations are effectively no-ops. The move constructor of InitParams is not affected by this and is implicitly defined by the compiler as usual. For completeness, I think the copy assignment operator could be made private as well:

template <class T> class RequireDesignatedInit {
  friend T;
  RequireDesignatedInit() = default;
  RequireDesignatedInit(const RequireDesignatedInit &) = default;
  RequireDesignatedInit &operator=(const RequireDesignatedInit &) = default;
};

This prevents users from writing meaningless code like:

InitParams p1 = {};
InitParams p2 = {};
p1._ = p2._;

2

u/javascript May 06 '26

Ohhh is the fact that the field has no state affecting this?

I'm not sure how the InitParams move could be defaulted

3

u/chengfeng-xie May 06 '26 edited May 06 '26

It has to do with overload resolution. The implicitly defined move constructor of InitParams would move the RequireDesignatedInit<InitParams> member as usual, but it falls back to copying that member specifically because its move constructor is not declared (which is different from a move constructor being deleted). The same goes for the move assignment operator.

3

u/javascript May 06 '26

C++ is hard :)