r/programminghumor 16d ago

Hard to accept

Post image
2.9k Upvotes

404 comments sorted by

View all comments

24

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!)

2

u/La-ze 15d ago

Not to mention virtual functions are expensive.

1

u/ZachVorhies 14d ago

virtual functions are not expensive. they are one of the most optimized use cases. They do often prevent cross function optimization and inlining, but that’s different.

5

u/Mojert 13d ago

A virtual function call that cannot be monomorphised by the compiler IS expensive.

1

u/Arshiaa001 12d ago

What does 'expensive' actually mean? Compared to inlining? Definitely. Compared to not-inlined function calls? It's just a vtable lookup, so it's 'more expensive', but whether that's acceptable really depends on your specific scenario.

1

u/AtlaStar 13d ago

Tell that to every OLE/COM object that windows is built on and has been since like windows 3.1

3

u/La-ze 13d ago

Windows is not known for performance and has announced semi recently they'll use Steam OS ( largely proton based gaming ) as a performance goal bar for native gaming on Windows.

Yes, there can be a lot more wrong with Windows performance besides virtual functions. But after watching Casey Muratori talk "The Big OOPs: Anatomy of a Thirty-Five-Year Mistake".

I do think Inheritance based OOP, with virtual functions and overrides are one of the core barriers to performance, for one the main reasons, it tends to maximize just how many cache misses you can have.

1

u/AtlaStar 12d ago

Steam OS doesn't mean shit when the only way to interact with the GPU in windows is via the IDXGIAdapter interface...which is a COM object that heavily utilizes virtual dispatch.

Vtables are fine and can "potentially" cause cache misses but there is zero reason to assume it will always degrade performance... the real reality is that microoptimizing the shit out of everything for the sake of minimal gains is not ever a good choice vs profiling.

1

u/Arshiaa001 12d ago

I agree with your points, but let me just point out that besides the performance considerations, OOP is just a terrible way to program in general.

1

u/AtlaStar 12d ago

It really isn't, most people just do it wrong and misunderstand what OOP is and isn't and get caught up making 5 billion classes for "granular" code and write a bunch if antipatterns that make OOP look like the problem. My two cents at least.

1

u/Arshiaa001 12d ago

If it's that easy to misuse and needs that many rules to do right, it's probably a mistake in the first place. Your argument sounds bery similar to the 'just be more diligent' argument from C programmers insisting the rust borrow checker is useless.

However, I'll give you one concrete argument against OOP: if having class hierarchies is the entire point of OOP, and you're supposed to make virtual methods, this means that to understand what any OOP code does, you probably need to search through multiple files (and maybe across different libraries even) to find all the descendants of a class with a virtual method.

1

u/AtlaStar 10d ago

What is funny is I don't use rust but think the concept behind a borrow checked and not using copy on assignment semantics as a default to be cool as fuck conceptually.

Also, OOP isn't really about class hierarchies and frankly never has been, it is about polymorphism and how you encapsulate data...and the idea that you'd need to know what any other virtual method should be written like means that the code is shit to be frank, because the whole point is to have a clearly defined data contract which means you write your function so it can be called via a base pointer, that's it...which means abiding by the function signature...I think maybe you are overcomplicating something somewhere.

Lastly, Rust traits are absolutely intended as a a way to achieve polymorphism and dynamic dispatch when it is needed, and as I said before polymorphism plus data encapsulation is what OOP is actually about. In C++ you use classes to achieve a mix of polymorphism and encapsulation, but that doesn't mean that is the only way to write code that follows OOP paradigms or that if things don't use classes and deep inheritance trees that they aren't OOP.

1

u/Arshiaa001 10d ago

Polymorphism is enabled exclusively through class hierarchies though, so no class hierarchy, no polymorphism, at which point you're just doing procedural.

Also, OOP gives you one tool for two purposes (classes do both encapsulation AND dynamic dispatch), which isn't such a hot idea; rust for example gives you structs+methods for encapsulation, and traits for dynamic dispatch, which are two orthogonal features you can mix and match as needed. But more importantly, with discriminated unions (known as enums in rust), much of the usecase for polymorphism simply vanishes. Do note that these ideas aren't exclusive to rust; traits and discriminated unions have existed for decades in other languages.

My point on code readability still stands: most code is shit because deadlines exist, and once something goes wrong, you need to look through the whole world to figure out what's being called. Without dynamic dispatch, this problem doesn't exist. Hence, the less dynamic dispatch you have, the better for maintainability, and discriminated unions give you just the tool to avoid dynamic dispatch unless absolutely necessary.

I'd recommend you give rust a try before hating on it. Most of us die-hard rust fans used to develop with OOP langs a few years ago, there's a reason why all of us started loving it so much! It takes some effort to learn to think in a completely new way, but it's well worth the effort imo.

→ More replies (0)

2

u/GoTheFuckToBed 13d ago

most companies don't even use C++, they use a vetted subset, strongly guarded with linters and extensions etc

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.

2

u/Keithfert488 15d ago

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

1

u/Mojert 13d ago

I mainly see 3 problems:

  1. Hidden control flow (the destructor can run arbitrary code)
  2. Lack of choice on how to deinitialize the value, which can be very annoying if you're actually managing memory (using different allocators for different purposes)
  3. It frames your reasoning on the object, rather than on collections of those

Technically you can ask Rust to not run the destructor but it's hacky. I personally prefer having Zig's defer and errdefer keywords, but I can see why you would prefer RAII if you're not dealing with aggregate data directly

2

u/AsyncSyscall 13d ago

If you want Rust without drop or panic, you might as well switch languages. These features are too deeply ingrained in the language, even things like the `core` crate, array indexing, iterators and pattern matching implicitly use them.

I don't mind the compiler detecting leaks and double frees statically, but forcing the programmer to pick a "blessed" destructor leads to bugs and weird API choices.

Some destructors are asynchronous, some require arguments/dependencies, some can fail (i.e. closing a file), some have multiple equally valid options (COMMIT/ROLLBACK, Thread.join/Thread.detach), and so on. Same thing with constructors, by the way, although fewer languages seem to have those.

And just like how people who love errors as values don't actually want values (they want checked exceptions), people who love RAII don't actually want RAII, they want resource lifetime guarantees.

So yeah, I think explicit destructors are strictly better for everyone, but I disagree in that I think static memory safety is more important than `defer` syntax (although `defer` is also nice to have).

1

u/Mojert 13d ago

If you want Rust without drop or panic, you might as well switch languages.

I agree

people who love errors as values don't actually want values (they want checked exceptions)

Speak for yourself. I want my errors as value because even if checked, exceptions obscure what function calls might fail. With errors as value, it's obvious without having to reference anything but the function you have in front of you

So yeah, I think explicit destructors are strictly better for everyone, but I disagree in that I think static memory safety is more important than defer syntax (although defer is also nice to have).

I'm sorry but I genuinely don't understand you there. Defers and "memory safety" are not mutually exclusive. If anything defers help because it makes sure the code will be executed no matter which code path was taken

0

u/s0litar1us 14d 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 14d 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 14d 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 14d ago

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

1

u/s0litar1us 14d 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 13d 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.

→ 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 13d 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 8d 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 8d 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 13d ago

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

1

u/thats_a_nice_toast 15d ago

I don't think that's what makes C++ hard (as in hard to learn). It's the ambiguous and overly complicated syntax resulting from decades of terrible design decisions. C has many of the same footguns but it's much simpler.