Is it possible to have a "named constructor" return a privately-constructed, non-moveable, non-copyable std::optional<T>?

Viewed 306

I mostly work on system-level C++ projects that don't allow exceptions to be thrown, but RAII is (rightfully) strongly encouraged. Right now, we handle the lack of failing constructors using infamous tricks many C++ programmers are familiar with, like:

  1. Trivial constructor followed by a call to bool init(Args...) to do the hard stuff
  2. Real constructor followed by checking bool is_valid() const
  3. Heap-allocating with static unique_ptr<MyType> create(Args...)

Of course, these all have drawbacks (heap allocation, invalid and "moved" states, etc).

My company is finally updating compilers and will allow glorious C++17 to be used. Since C++17 features std::optional<T> and, most importantly, mandatory copy elision, I was hoping I could greatly simplify all our classes into something that would look like this:

class MyType {
 public:
  static std::optional<MyType> create() {
    // If any of the hard stuff fails, return std::nullopt
    return std::optional<MyType>(std::in_place, 5, 'c');
  }
  ~MyType() {
    // Cleanup mArg0 and mArg1, which are always valid if the object exists
  }

  // ... class functionality ...

  // Disable default constructor, move, and copy.
  // None of these are needed because mandatory copy elision
  // allows the static function above to return rvalue without
  // copy or move operations
  MyType() = delete;

  MyType(const MyType&) = delete;
  MyType(MyType&&) = delete;

  MyType& operator=(const MyType&) = delete;
  MyType& operator=(MyType&&) = delete;

private:
  MyType(ArgT0 arg0, ArgT1 arg1) : mArg0(arg0), mArg1(arg1) {}
  ArgT0 mArg0;
  ArgT1 mArg1;
};

Notice how nice this is: Static function ensures all the hard stuff is done before the object is ever created, lack of default ctor/move means object never exists in an invalid or moved state, private constructor ensures user can't accidentally skip the named ctor.

Unfortunately, because the ctor is private, the std::is_constructable_t<MyType> check fails and therefore the in_place constructor of optional is SFINAE'd out.

This code works if I do one of 2 things, neither of which I want to:

  1. Make the ctor public (But now users of the class can accidentally circumvent the named ctor)
  2. Allow the move operations (But now I have to deal with invalidated objects)

I have also tried this, but it doesn't work because std::optional required a move operator for this to work:

static std::optional<MyType> create() {
  // If any of the hard stuff fails, return std::nullopt
  return std::optional<MyType>(MyType(5, 'c'));
}

Is there some trick or incantation I may be missing to get this to work, or have I hit the limits of what C++17 will allow?

Thanks!

1 Answers

If you want to make any indirect object construction work (emplace in its various forms, in_place constructors of optional, make_shared, etc) , the constructor in question must be public. You can make a constructor public without allowing all public use by using something called a private key.

Basically, you create a type (call it Key) whose default constructor is private. The class has no members, nor does it do anything. It declares that MyType is a friend of Key; this means that only members of MyType can construct one.

Now, make all of MyType's constructors public, but they all take a Key const& as the first parameter. This means that in theory anyone could call them, but in practice only someone who has a Key instance can actually call them. Members of MyType can create such an instance, and they can pass those instances to optional's in_place constructor or any other indirect mechanism. This effectively gives the indirect construction mechanism private access to the constructor.

This is a standard idiom for dealing with forwarding of private access to a type. Indeed, one could hypothetically write a generic key<T> type like this:

template<typename T>
class key
{
private:
  key() = default;
  key(int) {} //Not an aggregate

  friend T;
};

One small note. Because of an annoyance of C++11 pre-C++20, any type with no members and no constructors other than defaulted/deleted copy/move/default constructors is considered an aggregate. This is true even if you explicitly = default its default constructor. As such, that type can undergo aggregate initialization, which has no public/private distinction. That is, anybody could call your private-key constructors by doing this: MyType({}, <params>);.

To avoid this, you will need to give Key an additional (private) constructor or otherwise prevent it from being an aggregate.

Related