Care is needed to use C++ std::optional with non-trivial objects

We often have to represent in software a value that might be missing. Different programming languages have abstraction for this purpose.

A recent version of C++ (C++17) introduces std::optional templates. It is kind of neat. You can write code that prints a string, or a warning if no string is available as follows:

void f(std::optional<std::string> s) {
  std::cout << s.value_or("no string") << std::endl;
}

I expect std::optional to be relatively efficient when working with trivial objects (an integer, a std::string_view, etc.). However if you want to avoid copying your objects, you should use std::optional with care.

Let us consider this example:

A f(std::optional<A> z) {
    return z.value_or(A());
}

A g() { 
    A a("message"); 
    auto z = std::optional<A>(a); 
    return f(z); 
}

How many instances of the string class are constructed when call the function g()? It is up to five instances:

  1. At the start of the function we construct one instance of A.
  2. We then copy-construct this instance when passing it to std::optional<A>.
  3. Passing std::optional<A> to the function f involves another copy.
  4. In the value_or construction, we have a default construction of A (which is wasteful work in this instance).
  5. Finally, we copy construct an instance of A when the call to value_or terminates.

It is possible for an optimizing compiler to elide some or all of the superfluous constructions, especially if you can inline the functions, so that the compiler can see the useless work and prune it. However, in general, you may encounter inefficient code.

I do not recommend against using std::optional. There are effective strategies to avoid the copies. Just apply some care if performance is a concern.

Published by

Daniel Lemire

A computer science professor at the University of Quebec (TELUQ).

12 thoughts on “Care is needed to use C++ std::optional with non-trivial objects”

  1. Most of this seems quite expected, just like what you get with std::tuple or any other abstraction embedding one or several values of type T.

    The value_or() issue is avoided by making default construction cheap, which is a good idea in general anyway, and easily achievable for purely “data” classes (but may not be possible if embedding something like a mutex).

  2. I agree with your sentiment.

    Rule of thumb (for me) is pointer types are nullable and therefore inherently ‘optional’ – thus the need for something that specifically isn’t a pointer type.

    I actually forgot this today, so your blurb is quite timely for me.

  3. The std::optional template is exactly as efficient or inefficient as any other value type. It works well with objects implementing move-only semantics.

    1. It works well with objects implementing move-only semantics.

      Here is a code sample with such a class:

      #include <optional>
      
      struct A {
          A() = default;
          A(const A&) = delete;
          A(A&&) = default;
      
      };
      
      
      A f() {
          A a;
          std::optional<A> z(std::move(a));
          return std::move(z.value());
      }
      

  4. smart pointers can efficiently accommodate the optional pattern for everything other than primitives and rvalues.

    This creates an issue when templating code because neither style is optimal in all cases.

  5. The 5th case will not make a copy in most use cases (e.g., A a = g(); is guranteed to *not* make a copy). And other comments are right too — there’s nothing special about std::optional. You will get the similar behaviour with just std::string or any other value type.

  6. Several times I have already wished for an emplacing value_or(), i.e., one that accepts ctor arguments and constructs the alternative iff the optional is empty.

    Of course one can write that oneself as a free function, but it would be nice to have it in the std::lib.

  7. I don’t think this has much to do with optional but more to do with pass by value vs reference. I think most of the superfluous copies could have been avoided but passing const T& vs T.

  8. I’m going to assume that the last example should include f(z)

    In Rust it is actually really clear what happens. It’s a move. No copies involved:

    fn f(z: Option<A>) -&gt; A {
    // the use of the lambda avoids constructing the default unless z is None
    return z.unwrap_or_else(|| A::default());
    }

    fn g() -&gt; A {
    let a = A::new(“message”.into());

    // move a
    let z = Some(a);

    // move z
    return f(z);
    }

    fn main() {
    let _ = g();
    }

    // no copy / clone
    struct A {
        c: String,
    }

    impl Default for A {
    fn default() -&gt; A {
    return A {
                c: “default”.into()
    };
    }
    }

    impl A {
    fn new(s: String) -&gt; A {
    A {
                c: s
    }
    }
    }

  9. Useful article. Would be interesting to see the assembly output if you put on godbolt, with optimization. Then can see how many copies are really made.

Leave a Reply

Your email address will not be published. The comment form expects plain text. If you need to format your text, you can use HTML elements such strong, blockquote, cite, code and em. For formatting code as HTML automatically, I recommend tohtml.com.

You may subscribe to this blog by email.