10
\$\begingroup\$

I'm preparing for entry-level C++ developer interview and decided to implement some of std:: members. Here's my implementation of std::optional. I would be grateful for any feedback to help me improve my implementation.

#include <memory> #include <type_traits> #include <utility> #include <cassert> template<typename T> struct optional { optional() noexcept : _engaged(false) {} optional(T&& value) noexcept : _value(std::move(value)), _engaged(true) {} optional(optional&& rhs) noexcept : _engaged(std::exchange(rhs._engaged, false)) { if (_engaged) { std::construct_at(std::addressof(_value), std::move(rhs._value)); } } optional& operator=(optional&& rhs) noexcept { if (this != &rhs) { if (rhs._engaged) { if (_engaged) { _value = std::move(rhs._value); } else { std::construct_at(std::addressof(_value), std::move(rhs._value)); _engaged = true; } } else { reset(); } rhs.reset(); } return *this; } optional(const optional& rhs) noexcept : _engaged(rhs._engaged) { if (_engaged) { std::construct_at(std::addressof(_value), rhs._value); } } optional& operator=(const optional& rhs) noexcept { if (this != &rhs) { if (rhs._engaged) { if (_engaged) { _value = rhs._value; } else { std::construct_at(std::addressof(_value), rhs._value); _engaged = true; } } else { reset(); } } return *this; } ~optional() noexcept { reset(); } [[nodiscard]] T& operator*() noexcept { assert(_engaged && "Attempting to access a disengaged optional!"); return _value; } [[nodiscard]] const T& operator*() const noexcept { assert(_engaged && "Attempting to access a disengaged optional!"); return _value; } [[nodiscard]] T* operator->() noexcept { assert(_engaged && "Attempting to dereference a disengaged optional!"); return std::addressof(_value); } [[nodiscard]] const T* operator->() const noexcept { assert(_engaged && "Attempting to dereference a disengaged optional!"); return std::addressof(_value); } operator bool() const noexcept { return _engaged; } bool has_value() const noexcept { return _engaged; } template<typename... Args> T& emplace(Args... args) { reset(); std::construct_at(std::addressof(_value), std::forward<Args>(args)...); _engaged = true; return _value; } template<typename U> [[nodiscard]] T value_or(U&& other_value) const & noexcept requires(std::is_copy_constructible_v<T> && std::is_convertible_v<U&&, T>) { return _engaged ? _value : static_cast<T>(std::forward<U>(other_value)); } template<typename U> [[nodiscard]] T value_or(U&& other_value) const && noexcept requires(std::is_move_constructible_v<T> && std::is_convertible_v<U&&, T>) { return _engaged ? std::move(_value) : static_cast<T>(std::forward<U>(other_value)); } T& value() & noexcept { return _value; } T&& value() && noexcept { return std::move(_value); } void reset() noexcept { if (_engaged) { if constexpr (!std::is_trivially_destructible_v<T>) { _value.~T(); } _engaged = false; } } private: union { std::byte _null_state; T _value; }; bool _engaged; }; 
\$\endgroup\$
0

    3 Answers 3

    13
    \$\begingroup\$

    Here is the list of issues I've found in the implementation in no particular order:

    The operator bool should be explicit, otherwise it's allowed to implicitly compare the optional with a boolean, which is a source of bugs.

    The value() functions are expected to throw if a value is not stored.

    The class should define value_type just as std::optional does.

    The class is always advertised as copy-constructible and copy-assignable, but in practice, it is not when T is not copy-constructible or copy-assignable. Meaning that all SFINAE and concept checks will always assume it is copy-constructible and copy-assignable even when it isn't. std::optional even treats non-move-constructible and non-move-assignable classes properly at least according to documentation in cppreference.

    Copy constructors and copy assignment are usually not needed to be noexcept. You also lack converting-copy-assignment and converting-move-assignment, i.e.,

    template< class U > optional& operator=( const optional<U>& other ); 

    Your move-constructor and move-assignment don't match what std::optional does. std::optional doesn't perform a "steal," it only forward the move operation onto the underlying object.

    std::optional has swap function.

    Where is the analogy of std::nullopt_t and std::nullopt? Or at least their usage in the class?

    Also, see constructors (7) and (8) in the link, you don't have them implemented.

    You should adequately restrict the emplace function to only being available when T is constructible from the arguments.

    The class ought to have comparison operations when T does.

    In C++23, the class got monadic operations or_else, transform, and_then.

    \$\endgroup\$
    2
    • 1
      \$\begingroup\$Looks like we spotted a lot of the same things. But some good catches that I missed - great answer!\$\endgroup\$CommentedMar 9 at 8:15
    • \$\begingroup\$@TobySpeight, oh, I missed that it lacked a constructor from T const&; that's an essential one. Good find.\$\endgroup\$
      – ALX23z
      CommentedMar 9 at 9:43
    10
    \$\begingroup\$

    As an interviewer, I'm interested in your development process. So it's really valuable to see evidence of how you tested your code, preferably as a suite of unit tests. Without those, not only will I need to work harder, but I'll also be much more wary of the correctness.

    Evidence of testing is of course helpful in any code review, so I encourage you to always present the tests along with the code. When reviewers can see what's been proved to be functionally correct (and more importantly, what has been missed from testing), then they can spend more time on the aspects that benefit from human inspection.


    When implementing a type with a published interface, I believe it's important to implement exactly the interface that is specified. In this case, we've failed at pretty much the first hurdle:

    optional(T&& value) noexcept; 

    This accepts only an rvalue, so there's no match here:

     int a = 0; optional<int> oa = a; // compilation error 

    The standard type's constructor accepts a forwarding reference, like this:

    /* simplified */ template<class U> optional(U&& v); 

    Here, U can bind to any value or reference type, such as the lvalue a above.


    Consider giving _engaged an in-class initialiser. Then we could write the default constructor as optional() noexcept = default.


    It's good to see noexcept being used. However, I think it has been sprinkled a little too liberally. For example, the constructors assign a T&& to the _value member, which can throw unless std::std::is_nothrow_move_constructible_v<T>.

    Similarly, the assignment operators of std::optional are specified to be noexcept(is_move_constructible_v<T> && is_move_assignable_v<T>).


    In the specification, pretty much all of the interface is declared constexpr, but this code makes no attempt at that.


    Another departure from specification is that conversions to T& or T&& are required to throw std::bad_optional_access if there's no contained value, but we just have the same UB as in operator*().


    We're completely missing an implementation corresponding to std::optional::swap().

    We're also missing the non-member parts of the interface (relational operators, make_optional(), nullopt_t and std::swap<optional>).


    I find it surprising that we use std::construct_at() to create values, but use plain ~T() rather than the consistent std::destroy_at() in our reset().


    A final note: be careful with identifiers beginning with underscore, particularly in places that also use the C language. While you might be fully conversant with the rules, your interviewer might not be; given the prospect of an in-depth language-lawyer argument, it might be better to deflect that well in advance.

    \$\endgroup\$
      6
      \$\begingroup\$
       if constexpr (!std::is_trivially_destructible_v<T>) { _value.~T(); } 

      why would you "if ~T() does nothing, skip calling ~T()"?

       _engaged = false; 

      should you _engaged = false before or after you destroy the object? During destruction, of the contained object in an optional, should the optional appear engaged or not?

      if (this != &rhs) { 

      what goes wrong with your assignment implementations if this is false? I think nothing. Lets simplify!

      optional& operator=(const optional& rhs) /*& would be good, but std lacks it */ noexcept(/* needs a condition*/) { if (rhs._engaged && _engaged) { _value = rhs._value; // self assigment is ok } else if (rhs._engaged) { // impossible for self-assignment // the construction case: std::construct_at(std::addressof(_value), rhs._value); _engaged = true; } else { reset(); } return *this; } 

      I think that is simpler? It also means that if T=T is non-trivial for the same object T, we actually call it.

      For this to be noexcept, you need all of destruction, assignment and construction to be noexcept.

      T& emplace(Args... args) { 

      this should be Args&&... to perfect forward. In addition, I personally would make it the central point of creating T objects.

      To do this,

      bool _engaged = false; 

      so that it is in good shape "by default"; DRY, say it once.

      optional(T&& value) noexcept : _value(std::move(value)), _engaged(true) {} 

      becomes

      optional(T&& value) noexcept { emplace(std::move(value)); } 

      we then decide that the other end lives in a simplified reset():

      void reset() noexcept(/* check that ~T is safe*/) { if (_engaged) { _value.~T(); _engaged = false; } } 

      and now no other code touches _engaged. This fixes a subtle bug in your move ctor, where you set _engagedbefore you construct the object!

      optional(optional&& rhs) noexcept(/* proper condition here*/) { if (rhs._engaged) { emplace(std::move(*rhs)); } } 

      plus, you don't steal the object, you just move it on move-construct.

      optional& operator=(optional&& rhs) noexcept { if (*this && rhs) { **this = std::move(*rhs); } else if (rhs) { emplace(std::move(*rhs)); } else { reset(); } return *this; } 

      we are now using nice, well documented APIs that are easy to understand to implement a harder function like operator=.

      template<typename U> [[nodiscard]] T value_or(U&& other_value) const & noexcept requires(std::is_copy_constructible_v<T> && std::is_convertible_v<U&&, T>) { return *this?**this:static_cast<T>(std::forward<U>(other_value)); } 

      the result is an optional that uses low-level std functions like construct_at fewer times, and accesses _value directly in 5 functions and _engaged in 3.

      (You need _engaged in emplace and in reset and in has_value; everything else can call those 3. For _value, you'll need it in emplace, reset, and get methods for &, const& and &&; everything else can call those 5.)

      I believe you can do away with all but 1 level of {} indentation in your functions as well, which makes them easier to understand.

      After you do all of this, and implement the rest of it (the external API), and unit tests, then you should start with performance testing.

      In the code above we have values initialized with one value that are immediately set to another value. The thing is that if the code can be locally understood and the compiler can show that nobody could see the first value, it is capable of skipping that first initialization.

      Avoiding stuff like this is often a false optimization; making code more complex to optimize a case that the compiler can handle for you.

      \$\endgroup\$
      4
      • \$\begingroup\$Did you miss the ! in your first observation? That code only calls the destructor if it's not trivial. That said, the test seems pointless - why would we not want to call a trivial destructor?\$\endgroup\$CommentedMar 11 at 9:22
      • \$\begingroup\$@TobySpeight I am saying it should just be _value.~T(); - why the test at all? If it does nothing, just do it anyway because you know it does nothing. Using 2 lines to say "don't do something if it is a noop" instead of ... just doing the noop is a bad idea? It clutters up the code needlessly, wastes brain power figuring out if there is a typo in the check, wastes brainpower asking "why is this check here? Oh, for absolutely no reason". Code is communication - saying thing with no meaning is bad.\$\endgroup\$
        – Yakk
        CommentedMar 19 at 14:35
      • \$\begingroup\$Yes, agreed. I think I got confused by the multiple negatives there and misunderstood, because looking at it now I'm thinking exactly the same as you. If the destructor does nothing, there's really no reason not to call it.\$\endgroup\$CommentedMar 19 at 14:46
      • \$\begingroup\$I think I just became a good demo of your "wastes brainpower" argument. This took valuable thinking time that it didn't really need to!\$\endgroup\$CommentedMar 19 at 14:47

      Start asking to get answers

      Find the answer to your question by asking.

      Ask question

      Explore related questions

      See similar questions with these tags.