67 comments

[ 2.9 ms ] story [ 134 ms ] thread
The "care" seems to just be "thing that looks like a value type is a value type."

I'm not sure why the author would have ever expected anything different?

> I'm not sure why the author would have ever expected anything different?

Perhaps the message is aimed at less experienced C++ programmers?

Fair point, but I didn’t gather that the author expected anything different; I think he simply wanted to highlight a behavior that may not be that obvious to programmers who aren’t expert C++ programmers (i.e., most programmers).
But why focus on optional then instead of just passing anything by-value? And why not actually dive into when/where this could be of a concern and how to recognize it?

It seems like an article aimed neither at beginners nor experts.

Yeah, that's the whole point of std::optional. Pointers (including smart ones) have the possibility of being empty/pointing-to-nothing built in. std::optional adds it to value types.

To avoid tons of unnecessary copies use move semantics, which std::optional of course supports.

The (typical C++ stdlib) problem here is that things that look deceptively simple on the surface may result in a surprising amount of activity under the hood (e.g. you need to know how std::string is implemented internally, and that a copy operation may or may not be expensive, maybe depending on the length of the payload, compiler and stdlib implementation, etc etc...).

And while the fabled 'zero cost abstraction' often is able to clean up the mess (at the cost of high build times), a small and innocent looking change in the surface code may throw things out of whack again and suddenly you have an unexpected performance regression.

Most languages don't let you reason about such things. You just copy things around with no clue or care if it is fast or slow.

Most problems don't need that level of reasoning, but when it matters C++ gives you a lot of power to ensure you can control when it happens.

I mean, 9x out of 10, the very reason to use a low-level language like C++ is precisely because you want or need to reason about such things, no?
Hopefully. There are other low level tasks (device drivers) that you might not care, but need C++'s ability to get close to the hardware for, but in general C++ is for when you need both abstractions and the ability to control performance. (Rust, C, ada, and other such languages also play in this space as well, which is best is not something I can comment on)
This is why I love the simplicity of C (C99 in particular). I remember back when I started learning C++, after years of experience writing C, I was admittedly turned on by the cool features like smart pointers, standard data structures, etc., but once I read/wrote any significant amount of C++ code, the “deceptively simple” code quickly turned me off and had me running back to C. Suddenly, nothing was what it seemed. With C, I could jump into a codebase, and within a few hours/days, I’d have decent enough understanding of what’s going on, whereas C++ had me constantly wondering . . .

- If certain operators were being overloaded

- How memory was being allocated in different sections, libraries, etc.

- The different semantics of the various ways you can do the same thing (e.g., constructors)

. . . the list goes on and on and on.

I’m not trying to crap on C++, but for a low-level language, the simplicity of C is simply unmatched!

Other than operator overloads, you have all those same problems with C. Only with the extra "benefit" that the compiler won't help you with jack shit.

C is not a simple language. You're either naive or lying to yourself if you think it is.

But in this case C isn't even any different, so this tangent is irrelevant anyway. If you take a struct by value in C it, drum roll, copies it!

Sorry, I beg to differ. C is a simple language. The overwhelming majority of issues I’ve encountered writing C code haven’t had anything to do with the language; most issues have turned out to be due to compiler, library, architecture, kernel, OS, etc.-related differences, peculiarities, and/or issues. C++, on the other hand, makes it incredibly easy to shoot yourself in the foot at the language level.
> Sorry, I beg to differ. C is a simple language.

The problem with C is that it so simple that it is simple to complain about it.

Other languages hide their quirks deeper down and are harder to complain about.

> But in this case C isn't even any different, so this tangent is irrelevant anyway. If you take a struct by value in C it, drum roll, copies it!

You’re absolutely correct, but I also don’t have to worry about copy versus move semantics and all that other jazz in C — the mental model is simply value copies or pointer copies, that’s it.

By the time you actually add enough libraries to be able to get something done, you have to worry about the same concepts, the names are just different and it's less consistent since the language doesn't help or have a position on how it works.

C is a simple language the same way my car is simple if I take the engine out.

What a false equivalence. I don’t understand the need to pretend like C++ isn’t a minefield of complexity (especially compared to C). I don’t hate the language and C++ programmers tend to be some of the smartest programmers I know/encounter, but can we please not be deliberately obtuse here?
C is a minefield of complexity, though, why are you so desperate to pretend it isn't? The reality is that much of the language complexity of things like C++ or Rust is because the problem space is actually complex. C's language just doesn't help you with the inherent complexity. In fact it actively encourages bugs as a result. That's not a simple language, it's a bad one. It was fine for the constraints of the time, but it's not anymore.

C is easily the least defensible language choice of the commonly used languages. There's so many just obscenely better options it's not even funny. See also why even "intentionally C-like" languages like Zig are growing increasing complexity https://github.com/ziglang/zig/issues/7769 Or why things like D's betterC adds all the same "objectionable" stuff C++ does - RAII, operator overloading, etc...

> I don’t understand the need to pretend like C++ isn’t a minefield of complexity (especially compared to C).

C++ is a minefield. So is C though. Comparatively they're pretty much in the same boat for me. Sorry I guess?

What a weird analogy.

The truth is that you can't explore a C++ codebase without actually stepping through it with the debugger. You literally can't see all the places where you end up. A mechanism to do the type resolution is needed, and that is a much, much bigger mental load.

In C you can see each and every jump by default (unless someone does terrible hidden macro magic etc). Maybe add a code browser for convenience. You can often get a pretty good idea of a codebase just by browsing it. These are definitely diferent qualities.

What's to say for C++, it's harder to misuse libraries because you don't have to understand libraries as well, and some constraints to using them are better enforced, because of RAII and other mechanisms. But as Stroustroup said himself, "C++ makes it harder, but when you do it blows your whole leg off". It's far from impossible to misuse C++, and when you do, it can be much harder to find out what's wrong.

Anecdotally, in C++ it's also much easier to build a castle that ends up being a labyrinth.

> C is not a simple language

In comparison to C++ it is. C++ isn't a strict superset of C, but for all intents and purposes, it supports all the nasty warts of C PLUS a million other foot guns added on with no guidance whatsoever on which features are "safe" and "modern".

Just a short list of additional complexities in C++: constructors/destructors, operator overloading, templates, constexpr/consteval, copy constructors/move constructors/perfect forwarding etc, smart pointers (when do you use smart versus raw? Herb Stutter has some guidelines on this), lambdas, massive std lib, etc.

> But in this case C isn't even any different, so this tangent is irrelevant anyway.

But C is different... if you pass by value in C what's going to happen? It copies the bits. What about C++? Drum roll please... Who the heck knows. It could call some copy constructor, or it could just copy the bits, or it could recursively call copy constructors on all the nested objects etc. You literally cannot tell in C++ without looking at the implementation or already knowing what's going to happen.

You're right, of course. In C I don't even know if the code works at all, whereas I can be reasonably confident it does in C++.

But hey, it's apparently more important to know it's a memcpy than to know it works! Such simplicity! I'm so glad a built-in operator has just no expectations of being useful or correct, what a truly wonderful language.

You're shifting the goalpost here. You said:

> C is not a simple language

In response to the claim that:

> I’m not trying to crap on C++, but for a low-level language, the simplicity of C is simply unmatched!

C is simpler than C++, for the reasons I outlined above. This has no correlation on how correct the code is or if it works. If you want to argue that, go ahead but that's a different argument.

> But hey, it's apparently more important to know it's a memcpy than to know it works! Such simplicity! I'm so glad a built-in operator has just no expectations of being useful or correct, what a truly wonderful language.

Great, you can be facetious all you want, it doesn't add or detract to your claim that C is not simple in comparison to C++.

And by the way, I use C++ and prefer it to C. I love the fact that you can use templates in C++ for type safety when it comes to generic containers and use destructors to help with resource management etc. But let's not kid ourselves, C is simpler than C++ and trying to argue against that is pointless.

C is a simpler compiler and language spec but I think where we're disagreeing is on whether or not it's simpler to use. I'm saying it's not simpler to use, not that it's not simpler to make a compiler for it

C is not simple to use. It's full of non-obvious gotchas with little to no tooling support to aid with handling the vast complexity that the problem space actually contains. To such an extent that, yes, I think the end result is using C correctly is more complex than using C++ correctly despite the C language spec itself being "simpler" - because that simplicity came by punting all the complexity onto the user.

It seems like we've come full circle.

> C is not simple to use. It's full of non-obvious gotchas with little to no tooling support to aid with handling the vast complexity that the problem space actually contains

Once again, C++ contains the same non-obvious gotchas plus several additional gotchas as I said initially. C++ can only ever be as simple as C, not simpler since it aims to be mostly a superset of C. Sure string handling and stuff is annoying in C, but you can create libraries for that stuff the same way C++ has. I'm not sure how else to put this.

Simplicity does not mean ease of use, and the fact of the matter is C will always be simpler than C++ because C++ aims to be a superset of C.

C++ provides tools that C does not (and cannot with just a library) to aid in managing the complexity of the space.

Adding complexity to a language can reduce the complexity in using it. This is the case for C++, Rust, D's betterC, etc... when compared to C. All of them add complexity to the language to reduce the complexity in using it.

If you can't understand this, so be it. But the entire computing industry fully disagrees with you here.

As an aside, your insistence that C++ is a superset of C is factually wrong regardless. C++ changes a number of C behaviors, it is not a strict superset.

>But in this case C isn't even any different, so this tangent is irrelevant anyway. If you take a struct by value in C it, drum roll, copies it!

Not quite, in C you know that the most that can happen is a memcpy() call (or some form of inlined byte/word copy) whereas in C++ a copy constructor can do anything - like allocating memory, which might fail and raise an exception.

In C I don't even have a clue if the copy is even valid at all or if I was supposed to call some bespoke function or use some wacky macro somewhere.

C++ is a strict improvement in that you can at least generally assume the assignment operator produces functional code if it compiles, and types with unusually expensive assignments can opt to prevent the assignment operator from being used at all to make that clear.

> Other than operator overloads, you have all those same problems with C.

How about no? RAII and exceptions do a lot of stuff that is hard to see (because most of the time, there literally isn't any written code that calls them).

> (because most of the time, there literally isn't any written code that calls them).

RAII code wont be invoked unless you literally declare an object in the function.

You might as well complain that C implicitly reserves space on the stack for variables despite the fact that you did not explicitly call alloca to do it.

Well, operator overloads won't be invoked unless you literally write the operators. They can be surprising nevertheless, as the commenter above rightfully expressed. And RAII/constructors/destructors can be surprising and annoying, too. It means buying into a framework, giving up control. This can be a good thing, but it can be hard to control, and be hard to debug, too.

alloca() vs stack variables is something different, it's dynamic vs static allocation. You can literally see (ignoring optimization to make this point) all the stack variables that are part of the stack frame. It's in plain sight, very explicit and obvious to the eye. While calls to operator overloads and ctors/dtors are not visible in the plain syntax (type resolution is needed), and in case of virtual methods they might not even be resolved until runtime.

So please don't make me angry, this was a really weak refutal. Statements like this are why I am sometimes more critical at C++ than it maybe deserves.

> It means buying into a framework, giving up control

The fun part is that you can still use plain old objects if you really really want to deal with C-Strings.

> It's in plain sight, very explicit and obvious to the eye.

And since RAII applies to variables the same is true in c++, unless you pretend to write C.

> and in case of virtual methods they might not even be resolved until runtime.

Except the type of a stack allocated object is known at compile time and ctors and dtors are part of that type. For operators you can also generally see from the type if they are overloaded and possibly virtual even if you are not dealing with directly stack allocated objects.

Have you made any effort trying to understand what I'm saying? Because you're almost making all my points. How does your definition of "in plain sight" not make a difference between "I can see which functions are called from a piece of code printed out on paper" and "I need to look up the type to see if there's an overloaded operator or method or a non-default constructor/destructor, and to get there I might need an advanced code browser, probably based on compiler infrastructure -- and I must like actually hit go-to-definition to navigate to the site"?

> The fun part is that you can still use plain old objects if you really really want to deal with C-Strings.

Strings are just buffers like any other, nothing special. It's easy to see you've never seriously programmed C, because strings are like 0.1% of my concerns. They just work where they have to, and otherwise string manipulation is 99% done in places where a C programmer wouldn't even do it. If string manipulation in C becomes annoying, you should have written a Python script.

> The fun part is that you can still use plain old objects if you really really want to deal with C-Strings.

I'm sure you've heard this before, but... how exactly do I know that anything is a POD structure? And if everything is, so I can assume that anything is, where's all the advantages gone?

> and I must like actually hit go-to-definition to navigate to the site"?

If you feel like it is too much effort you can just not do it? I can't remember the last time I checked, ctors and dtors should do basic setup and teardown. Counterquestion, how do you know a specific C struct wont overflow your stack without looking? We are assuming that everything important is visible inline, right?

> Strings are just buffers like any other, nothing special. It's easy to see you've never seriously programmed C

I have seen way too many hand rolled parsers on C, they may have been only 0.1% of the total code but they amounted for at least 50% of the bugs, along with the constant manual memory management and badly initialized variables.

> If string manipulation in C becomes annoying, you should have written a Python script.

That is an interesting statement to make for someone who claims to almost never write C string handling code. As someone who has to deal with strings, std::string is simply supperior in most cases so I prefer to use C++ where I have the choice.

> where's all the advantages gone?

That was in response to the "locked into a framework" claim.

> I have seen way too many hand rolled parsers on C, they may have been only 0.1% of the total code but they amounted for at least 50% of the bugs, along with the constant manual memory management and badly initialized variables.

I have written a number of parsers for programming languages and can't recall ever having a string bug. It's all about creating the right abstractions. A parser is not about string handling! The typical (lexical) parser scans input from left to right and builds up (identifier and string literal) tokens by appending. So basically, you need a function input_read_char() and string_push_char(). When the string is built up, it gets allocated in the global parser state. The "string" now is a pointer or pointer+length, and you can freely copy it around by value as you like, knowing that the string memory is immutable and allocation is already taken care of.

The string memory management is just a large shared push buffer in the parser that pools all the strings in a central place. If you ever need to release resources, you clear out all strings in one swoop by freeing the push buffer. There is no need to track individual strings.

Allocating string storage using a general-purpose memory allocator, and tracking them individually is wrong. It's a general-purpose approach that lacks the right structure, wastes memory and is super slow. std::string doesn't change any of that. As I said, if you have lots of string allocations then write a script.

You're done, there is no other string indexing or string allocation in a parser.

> That is an interesting statement to make for someone who claims to almost never write C string handling code

What I want to say is that if you're dealing with a lot of strings, you're working in a high-level glue domain. And there is no point in using either C or C++. You're probably making a web API, or an ad-hoc throwaway thing. I do write lots of mini-programs to support development. It need not be python, possibly it should even be a shell or awk script. Most of them they live and die in my command-line history, or as a 5-line script in my /tmp folder. Using C++ for these tasks would slow me down by 10x.

What innocent looking change are you thinking of that becomes an issue as a result of this?

If your function is currently taking a parameter by-value, and you replace it with std::optional<T>, you're still taking it by value and doing the exact same amount of copies. No (new) issues from optional.

You only run into trouble if you do something like change `void foo(const T&)` to 'void foo(std::optional<T>)`. But in such a change, it's "clear" (insofar as C++ is ever clear about anything) you're replacing a reference parameter with a value parameter.

I don't think this is surprising std::optional behavior?

> The (typical C++ stdlib) problem here is that things that look deceptively simple on the surface may result in a surprising amount of activity under the hood

It's literally exactly what it looks like. The C++ stdlib didn't complicate or obfuscate anything.

> What innocent looking change are you thinking of that becomes an issue as a result of this?

In these contexts, the "innocent looking change" is usually something which pushes a method over an inlining threshold, so that something which used to be inlined no longer is, and a zero cost abstraction suddenly has a cost larger than zero.

That can be about literally any change, how does std::optional meaningfully change things here to warrant calling out that you need care when using it specifically?
It adds a space overhead.

A pointer is already an optional type by the 0 convention.

Yeah but I'd file that under C++'s ABI problems category. I don't think anything in std::optional's spec requires that it has additional padding. It could have an optimized representation for known types of T & thus specialize T* to use nullptr as the sentinel instead of an extra boolean, similar to how std::vector specializes for bool (although that one is probably a mistake, but whatever).

But unfortunately also optional references was never resolved & thus punted ( see https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n35... ), even though boost::optional supported them

i have been using std::string for many, many years and have never needed to know how my specific standard libraries implement it. if i want to worry about performance, i do it by timing and profiling, not reading standard library code.
Really, you didn't care that C++11 forbade Copy-On-Write for std::string, or you never cared about the Short String Optimisation length that each platform supports?
really, i didn't care. cow has always seemed like a mistake to me, and what am i going to do about sso? make my strings intentionally shorter? or longer?
In this example, you don't need to know how things (i.e. std::string) are implemented internally so much as you need to know whether or not they're expensive to copy. That's a pretty fundamental aspect of the language, if you care about performance.

If you do care and know about the particular implementation, I guess you might counter by saying that a COW string would be cheap(ish) to copy, or ditto if the string is short and the implementation uses the small string optimization. But at a higher level it's a very good general rule to just avoid copying anything unless you know for sure that it's small and doesn't use dynamic allocation.

(comment deleted)
This doesn't have anything to do with std::optional. Passing anything by copy works this way in C++
Most of these problems can be solved with moves, if that's the intention. This code does not copy anything and avoids the unnecessary default constructed object: http://okturing.com/src/14823/body
Might as well just include that tiny snippet in your comment:

    A f(std::optional<A> z) {
        if (z) {
            return std::move(z.value());
        } else {
            return A();
        }
    }
    
    A a("message");
    auto z = std::optional<A>(std::move(a));
    f(std::move(z));

I don't think you're saying anything fundamentally different from the article. It says you need to exercise care, and you've shown an exmaple that does.

"Most of these problems can be solved with moves" - It may be that the problem of copies is solved by using moves (i.e. this is a solved problem at the compiler level). But the problem of needing to be careful is not solved by the advice to use moves (i.e. it's trickier at the human level). It's so easy to forget to use move and you would get no warning about it.

It doesn't help that the rules are very complicated. For example, if you were returning z rather than z.value() from your function (with correspondingly different return type), the usual advice is to return z rather than return std::move(z) because it's already implicitly an rvalue in that situation and using move() prevents NRVO. But... is that still true for z.value()? I don't think so but it's not obvious (IMO). Or, if you return std::move(x) where x is a const type, it will copy, which is fair enough as that's all it can do but it ought to warn you (or not compile IMO). It's not always obvious is something is const e.g. if you have a template function, x is of deduced type T (passed by copy) is const part of the deduced type? I'm not sure.

If you're going to say "you need to be careful about copies when passing-by-value and moves are complicated" then I 100% agree. BUT if you're going to say "you need to be careful about std::optional" then I completely disagree. It's a thoroughly unsurprising class with unsurprising behavior.

Whether or not the moves do anything or not is entirely dependent on the type T here, not optional, and is therefore orthogonal to whether or not you use optional. Ie, optional did not introduce any new concerns, and therefore doesn't warrant any particular scrutiny or concern.

I would say that std::optional's value_or() method at least is a foot gun here - as it creates a great opportunity for both an unnecessary temporary object and an unnecessary copy. That in itself merits a "use with care" flag.

Also, the fact that std::optional::value() apparently returns a reference, and thus `return opt.value()` is always forced to be a copy (I'm writing this based on another comment, I can't claim to fully understand C++'s extraordinarily complicated value types and their respective limitations) seems like another problem that's somewhat specific to std::optional itself.

::value() method can both return a reference (bare use) and give up the ownership of the wrapped value (used with std::move). You just have to state your intention correctly. It's not a problem with std::optional specifically, it's just the default C++ behavior. The entire thing works much better in Rust, where move is the default op and copies need to be explicit.
`return opt.value()` is not forced to be a copy, no. It is, as you noted, a reference return. You can of course continue to propagate that as a reference if you want, assuming that makes sense.

I don't think I'd agree with a characterization that `value_or` is a footgun? It behaves basically identically to similar features in every other language (like Java's Optional#orElse). If it somehow changed parameter evaluation such that it was deferred then that would be a footgun. But it's of course not doing that.

I'm calling value_or a footgun because it calls both the initial constructor (which is to be expected, as in Java) but, if I understand correctly, a further copy constructor (perhaps ellided if the conditions are right). In Java/C#/Python/Ruby/Haskell/Rust etc, this copy would be guaranteed not to happen (at the cost of a heap allocation in the first 4, in most cases).

Of course, even in Java this method is often inferior - it only really makes sense in languages like Haskell or if replaced by a macro of some kind, which would guarantee that the unnecessary value is neither constructed nor copied. Otherwise, an if/else or ? should generally be preferred, especially in a performance-focused language like C++.

https://en.cppreference.com/w/cpp/utility/optional/value_or

value_or's parameter type is U&&, so no there's no additional copy there. There's only the explicitly visible default constructor call.

But you did get me to take a second look at the post, and yeah the author's statement of "Finally, we copy construct an instance of A when the call to value_or terminates." is just incorrect. Try it out yourself: https://godbolt.org/z/hWqnrfGKE

If you replace the `.value_or(A())` with `.value()` there's no reduction in copies, only a single reduction in the constructed count which of course makes sense since the `A()` parameter isn't there.

The way you would optimize this to eliminate copies would be to do things like construct the optional in-place and take the parameter by const& instead, such as:

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

   A g() { 
      std::optional<A> z(std::in_place, "message"); 
      return f(z); 
   }
`std::in_place` being definitely a classic C++ "quirk" here and usually unlikely to be worth it but hey if you want to play "minimize the copy count" golf there you go. The value_or isn't actually adding any copies, though.
I agree that it's complex and overly nuanced. I posted this because the article pointed out a problem, said "be careful", but didn't actually offer any way to address it.

Just z.value() gives you a reference, which, when returned will yield a copy. So no RVO in this case and the move is the next best thing.

There is a push in C++ towards Value Semantics. The compilers don't seem good enough for it yet.

It's a problem with overuse of `auto` too, copies everywhere and I'd wager a lot of them are unintended copies.

But hey, your code compiled so it must be right!

Maybe the title could be shortened to "Care is needed to use C++" :-). While it's certainly possible to create high quality software with C++, there are a lot of traps (like this).
[dead]
The article's example of using std::optional doesn't seem very realistic, so its counts of copies don't feel compelling at all. Their code is causing almost all of them.

They also didn't confirm with a compiler which will actually be copies or what can be elided (either automatically or manually/by better use of types), making it even less compelling.

Care is needed to do anything non-trivial in C++.
Clueless article upvoted by cpp haters. That code is working as intended. That ain't C#. Want to avoid copies? Use std::move.
It's an immediate red flag to see `T::T(T)` used anywhere for non-trivial objects or PODs or something not word-size.

Typically C++ requires a lot of plumbing to use value semantics with `std::move` everywhere and `std::forward`.

I don't have patience for it anymore, so I just use Rust which is move by default.

A "move" of a POD value type is still a copy and std::move/std::forward don't do anything (same in Rust for Copy traits).

Rather the Rust feature that's missing here is the compiler automatically converting pass-by-(move)value to a pass-by-reference under the covers.

I honestly assumed this would be yet another safety issue, but it turns out it's just another example of my nemesis: the ease by which a reference becomes a value and triggers a copy. It's super annoying because the the messaging is "use references to be a pointer that cannot be null", which fails on multiple levels - this one where it becomes a value copy, but also that references can happily be null (many years ago I filed a feature request on clang asking for a non-null attribute that was rejected because references exist).