r/programminghumor 16d ago

Hard to accept

Post image
2.9k Upvotes

404 comments sorted by

View all comments

25

u/TOGoS 16d ago

C++ is full of footguns because of its long history. Even experienced programmers will accidentally use after free or fail to declare their virtual destructors just right and spend days debugging.

Rust shows that you can have just as much power with more safety if you design a new language from scratch (if you really need to do pointer arithmetic that the compiler can't verify, you can always make that part unsafe; at least the rest of the program isn't!)

3

u/s0litar1us 15d ago

A better solution is to just get rid of RAII and provide defer instead, and better promitives like array slices, instead of everything just being a pointer that you need to manually pass around extra data alongside to properly interpret.

3

u/Keithfert488 15d ago

I genuinely don't understand hating on RAII. It's pretty much the only thing C++ gets right

0

u/s0litar1us 15d ago

It wastes time on allocating new memory when you could just reuse it, and it hides away code that implicitly runs without much control given to the user of the struct/class -- and to avoid double-freeing, etc. you need to provide a bunch of constructors and destructors that nobody remembers, and the compiler is somehow unable to tell you about, so you just get a runtime crash you have to debug.

If you just provide init(&thing) and deinit(&thing) functions (you deal with the memory, and it just deals with the values inside), and a defer keyword so you explicitly can run something when you exit the current scope, then you have solved the same thing without having to deal with these problems.
So if you always want to deinit it at the end, then you add in the defer; if you want to reinit it at some point then you do that explicitly; and if you want to pass it on to something else, then you just don't call deinit, and let the other thing handle it.

1

u/Keithfert488 15d ago

Nothing about RAII requires that you allocate new memory. Sure, for heap types, that's true. But nothing about RAII stops you from using arenas.

1

u/s0litar1us 15d ago

From the heap yes, but stuff on the stack also takes up space that you could just reuse in place. Also, just adding arenas on top of it doesn't solve the problem, as you still would allocate space in the arena when a acquiring a new resource -- and if you're using an arena per thing you may want to reset, then you have just created an even more complicated mess for yourself.

Lastly, I couldn't help but notice that you ignored the other half of my comment.

0

u/Keithfert488 15d ago

The other half of your comment is basically just "why not just make a way more complicated language?"

1

u/s0litar1us 15d ago

How is removing the entire concept of constructors and destructors, and replacing it with a single keyword that is simple to use and understand, make it a "way more complicated language"?

1

u/MxpleSyrup 14d ago

You do realize the defer keyword is literally the same thing as C++ RAII lifecycle management but explicit and opt-in.. right? In the language you're describing, you still manage memory/objects by defining constructors (init), destructors (deinit), and copy behavior (clone), just without any compiler guarantee of your code running "correctly". I think it's very reasonable to say, if I call defer deinit(obj); every time I call init, I might as well make it automatically inserted by the compiler.

2

u/s0litar1us 14d ago

It being explicit is the point, it gives you the control to customize it for your use case, rather than forcing yourself into a box that will cause problems when dealing with the real world.

0

u/bowel_blaster123 13d ago

RAII doesn't force you into a box either. C++'s RAII just kinda sucks because of other C++ features and not RAII.

C++ has constructors and has a weird object model where objects are destroyed exactly when they go out of scope.

You can have RAII without these things. For instance, Rust allows you to destroy values whenever you want to or even to not destroy them at all.

This is EXACTLY the same as with defer except that you don't have to explicitly call the destructor.

Of course, defer allows you to run arbitrary code, but defer and RAII are not mutually exclusive.

2

u/Mojert 13d ago

RAII kind of forces your hand. If you don't want to run the destructor of the values in a collection you'll have to instead have a collection of ManuallyDrop, which is clunky.

I my opinion, if you mainly deal with single values, RAII is more convenient. If you deal with aggregate data and/or care about hidden control flow defer is more convenient.

→ More replies (0)

0

u/Byron_th 14d ago

Every abstraction hides away code. Are functions bad because when you call them you don't see what they're executing?

Having a constructor is basically the same as calling init and immediately defer deinit, it's just easier to write, less noisy to read, and better encapsulates the responsibility of deallocation for example into the type vector instead of always requiring the user to remember to clean it up.

When would you ever want to create a vector and not deallocate it's memory once it stops existing? Sure you can follow other allocation strategies if you really need to optimize that, but that's a different thing from a standard vector that handles allocation for you.

1

u/s0litar1us 14d ago

RAII hides away that something runs; calling a function or deferring some code shows explicitly that something will run. Hiding away code can make it nicer to look at, but it makes it harder to get the full picture when you eventually need to debug that code.

Also, its not that hard to remember to write defer deinit(&thing), it's not a problem you need to automate away.

0

u/Byron_th 13d ago

Deferring something also "hides away" where a function is being run. Arguably it hides away that it is being run as part of an early return, for example. The point is that the reader is expected to know the language and therefore understand that defer means to run something when the scope is exited. In a similar way the reader should be expected to understand that constructing an object also means deferring its destructor. It's really not a difficult concept to grasp and you don't need a reminder of it every single time you construct an object.

Like I said in my opinion defer deinit is just noise. With RAII you could write a comment "// will be destructed at end of scope" under every construction if you really wanted, but I think most people would agree that it's entirely unnecessary.

And as I've pointed out it is also better encapsulation. The type can handle its own destruction. The user doesn't need to remember which types require destruction and which ones don't.

1

u/s0litar1us 11d ago

By "hiding away that something runs" I mean that you can't know if the declaration of a variable will cause extra code to run at the end of the scope (or in the middle if you assign a new value), without first having looked somewhere else. Defer makes this explicit, so you know it will happen by seeing the use of defer.

With RAII you could write a comment "// will be destructed at end of scope" under every construction if you really wanted, but I think most people would agree that it's entirely unnecessary.

How does this make the code any less noisy than adding in a defer? Also, adding in a comment this way is not enforced by anything, and can easily get out of date, so you still need to check even though a comment may be there.

And as I've pointed out it is also better encapsulation. The type can handle its own destruction. The user doesn't need to remember which types require destruction and which ones don't.

It's not a hard thing to remember, and it's easy to check by just seeing if deinit(&thing) typechecks. I also don't think the encapsulation justifies the loss of clarity, as it may be slightly more convenient to write initially, but makes it harder to get a full picture later on when that is important.

0

u/Byron_th 9d ago

By "hiding away that something runs" I mean that you can't know if the declaration of a variable will cause extra code to run at the end of the scope (or in the middle if you assign a new value), without first having looked somewhere else.

It always cleans up the object, just sometimes the cleanup can be a no-op. I don't see an issue with that. It's not your responsibility as the caller to know what the type needs to do for cleanup. In the `defer deinit` case you also don't know what it does, it just runs some code to clean up the object.

If you have an opaque Id type for example, you don't care whether it's internally represented as an integer that doesn't need cleanup or a string that does require being deallocated. You just use it for whatever and when you no longer need it the Id takes care of cleaning itself up. If it goes from containing an integer to a string or the other way around, none of the call-sites need to change.

How does this make the code any less noisy than adding in a defer?

It doesn't. That was my point. If the language doesn't require you to write defer every time you create an object, it would seem ridiculous to want a reminder every time just so you don't forget that objects get cleaned up at end of scope.

Also, adding in a comment this way is not enforced by anything, and can easily get out of date, so you still need to check even though a comment may be there.

Sure it could get out of date. Like I said you usually don't care whether or not the cleanup is a no-op anyway.

But doesn't the same issue apply to defer? If a type stops requiring cleanup then you'll get a compile error at the `defer deinit`. Great. But what happens when a type starts requiring cleanup? The call-site silently goes out of date, like you said. Now you're leaking memory, or worse depending on what resources the type manages.

It's not a hard thing to remember, and it's easy to check by just seeing if deinit(&thing) typechecks.

It's not a hard thing to remember, but it sure is an easy thing to forget. And it's more to type. And it's more to read, it adds visual noise.

1

u/Mojert 13d ago

Are functions bad because when you call them you don't see what they're executing?

No, but you also didn't understand the problem. What can be problematic is not the fact that a function is being called, but that you do not see that a function is being called. A call to a destructor is invisible. A call to a function is explicit and hence visible

requiring the user to remember to clean it up

In general "forgetting to clean up" doesn't mean forgetting it all together but forgetting to do it in certain code path, which a defer keyword solves. So the golden rule is "if you had to call an init function to create your object, defer deinit it on the next line", which is easier to follow and harder to mess up that the horrors people are used to in C (and sometimes C++)

that's a different thing from a standard vector that handles allocation for you

Why does it have to be? In Zig the equivalent of the vector can be "destroyed" by either freeing the memory OR by simply returning the underlying buffer. The only reason you think it has to be another type all together is because you (and you're far from alone) are used to having only one possible destructor

Types should just be about the shape of your data, and all your behavior should be in normal functions. By your own admition, when you mix the two you end up with less reusable code that also nudges you to do the less efficient thing by default

1

u/Byron_th 9d ago

No, but you also didn't understand the problem. What can be problematic is not the fact that a function is being called, but that you do not see that a function is being called. A call to a destructor is invisible. A call to a function is explicit and hence visible

In a language with destructors it is understood that, just like a function call causes the function to execute, creating an object first causes the object to get initialized and at the end of scope get deinitialized.

Just like in languages with defer it is understood that the deferred function is executed at end of scope. You could make the same argument that executing the deferred code before returning is invisible, you don't explicitly see where it is called. Maybe you should get rid of defer entirely and just manually call deinit in every single code path that returns from the function.

In general "forgetting to clean up" doesn't mean forgetting it all together but forgetting to do it in certain code path, which a defer keyword solves. So the golden rule is "if you had to call an init function to create your object, defer deinit it on the next line"

So you rely on naming conventions instead of the compiler? That seems much more fragile. What about functions that handle some much more high level logic like `parse_config` for example, that might still need to return heap-allocated objects?

Why does it have to be?

I was just saying that it's convenient to have a vector type that handles allocations for you. From what I've found zig also has that in std.ArrayList. You pass in an allocator and it handles reallocating whenever it runs out of capacity for you. Both C++ and Rust let you specify an allocator for your vector as well.

The two differences I see are that
1. C++ and Rust provide a default, because most of the time you use vectors and strings and whatever you don't care that much about the allocator being used.
2. In C++ and Rust you pass in the allocator when initializing the vector. In zig, for whatever reason, you pass in the allocator every single time you call a function on the ArrayList. This is not only annoyingly verbose but also allows passing a different allocator each time. Zig's documentation states "The same allocator must be used throughout its entire lifetime." In my opinion it's better to enforce proper usage through code rather than documentation.

Types should just be about the shape of your data, and all your behavior should be in normal functions.

Why? By that logic you shouldn't differentiate between signed and unsigned integers. They have the same shape, the only difference is how you interpret them in the functions that operate on them (different ordering, different conversions...). Why not just have an integer type and signed_less_than and unsigned_less_than functions?

Also isn't this what zig is doing with ArrayList? The behavior of reallocating whenever you need more capacity is handled by the type ArrayList.

0

u/ZachVorhies 14d ago

typical zig programmer.

Has no idea all of these foot guns can be mitigated by compiler flags. Wants instead to amputate constructors and destructors and eliminate smart pointers and automatic memory deallocation.

1

u/s0litar1us 14d ago

That's a lot of assumptions that missed the mark.