Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

Over a decade ago I used to argue this with the C++ committee people. Back then, they were not concerned about memory safety; they were off trying to do too much with templates. The C++ people didn't get serious about safety until Rust came along and started looking like a threat. Now they're trying to kludge Rust features into C++ by papering over the unsafe stuff with templates, with some success. But the rot underneath always comes through the wallpaper. Too many things still need raw pointers as input.

The encouraging thing today is that we're kind of converging on good practices in language design.

- You really want to declare types for function parameters. The compiler needs that information for checking, and the programmer, and the maintenance programmers who follow behind, need that information so they know how to call the thing. On the other hand, you can usually infer the result types of expressions by a simple forward process. So C++ gained "auto", Rust and Go started off with that arrangement, Javascript got TypeScript, and Python is getting there via a side trip through unchecked type declarations.

- The three big questions in C: "How big is it?", "Who can delete it?", and "What locks it?" C gives no help with any of those. C++ now tries to address the first two. All the garbage-collected languages address the first two. Rust addresses all three.

- The idea that locks should be connected by syntax with the data they are locking took far too long to become accepted. Partly because the Ada rendezvous and the Java synchronized object didn't work well. Even Go doesn't do that very well. But language design seems to be coming around. When and if thread-type concurrency comes to Javascript, the webcrap crowd will create race conditions everywhere without automation in locking.



I think this is a little oversimplified, because I’m pretty sure C++0x had move semantics and usable smart pointers prior to Rust really entering the public conscience/probably before it existed. AFAICT Rust takes a lot of inspiration from how you would “fix” C++, in ways that you could never actually do in C++ because it would break backwards compatibility.


I suppose it depends on what you would consider being “serious” about memory safety.

I view move semantics as not a memory safety thing at all (in fact, use-after-move can be exactly as ugly as use-after-delete), and smart pointers as a “let’s just make things marginally better than raw raw new/delete” thing. Smart pointers in C++ can still be null, after all.

Calling C++ not serious about seems a reasonable take to me. Of course Rust didn’t invent all of its solutions from scratch.


Smart pointers + static analysis + sanitizers is the best options I am aware of for improving the correctness of C++ programs, and I don't think Rust really had much to do with them. The picture painted that Rust made the C++ standards committee scramble to start addressing safety seems wrong; it feels more like there's been a mostly steady increase in safety over time without need for any outside influence.


> Smart pointers + static analysis + sanitizers

I feel like you just defeated your point; those are bolted on instead of a natural part of the language.


What was my point, then?


Good question. I'd pick one of:

(a) Smart pointers + static analysis + sanitizers make C++ pretty usable

(b) C++ usability requires brittle, half-baked add-ons such as smart pointers and non-deterministic/heuristic/unreliable/NP-hard development tools such as static analysis + sanitizers.


OK. I think some people mistook me as making a value judgement about C++. This is not true, I am merely saying I have doubts that Rust has had a serious impact on C++ design (yet; if it does it will start showing up soon, though.) If you don't believe me please carefully reread my comments; I really wasn't trying to say anything overall good or bad about C++, just observation about trajectory.

My personal opinions on C++ are not very positive, but it's a language I literally grew with. My experience is that C++ code very gradually became more stable and less buggy. The thing is, some of that is just improved discipline, some of it is static analysis, etc. But a lot of it, is genuinely C++0x/C++11 and features that built on top of this.

So the idea that Rust showed up and suddenly C++ cared about safety? I don't really see it. I think C++ developers started caring about safety and then Rust showed up because of that.

P.S.: While the borrow checker and ownership semantics is really cool, I think a programming language's ecosystem and the 'best practices' it lends itself well to have a greater impact that people completely miss. Rust, Go, Zig are all clearly trying to foster a culture of writing more correct and complete programs. Error handling doesn't just feel like a couple control flow mechanisms you can use, but a responsibility that you have. Modern C++ is getting better probably more because of this than any committee; although I really wish the Expected type would've gone somewhere, since I feel Rust's `Result<...>` type and Go's multiple-return with `error` are being proven right as ways to handle errors robustly even with their caveats. (I've heard Zig's error handling is really cool too, but I haven't had a chance to write a serious Zig program. I'll get to it soon.)


When I think about C++ "smart" pointers, I often think about easy to provoke / difficult to spot fatal edge cases. E.g.

- C++ code where some method calls std::shared_from_this(), and that method ends up being called indirectly during the object's construction, leading to a 0-pointer dereference [1]

- accidentally creating two shared-pointers to the same object leading to an eventual double-free (that may just silently corrupt the heap while the program continues running)

- undetectable cyclic shared_ptr<> references somewhere deeply hidden in the code causing a memory leak

Modern C++ feels like a case of "but we can do that in C++, too" syndrome. Stuff that "just works" in Java or Lisp, can now be done in C++, too, however in C++ it will only work for 95% of all use cases, and break in a spectacular manner for the remaining 5%.

E.g. think about the template meta-programming madness (and efficiency WRT compile-time) and compare that with what Lisp offers (see also [2]).

[1] https://stackoverflow.com/questions/31924396/why-shared-from... standpoint

[2] http://people.cs.uchicago.edu/~jacobm/pubs/templates.html


Both of your first two cases exist when people convert code bases that used raw pointers to shared_ptr. The normal intended usage of shared_ptr is to use make_shared and never have a raw pointer ever exist. The need to us shared_from this (or create a new independent shared ptr) happens only because some part of the code base is still using raw pointers for that particular class.

OTOH, C++ needs the ability to support old code bases because of its age. Of course a new language can forego this.

As for cycles, for GC language using ref-counting under the hood, the same problem applies. I've also seen memory leak from accidental keeping of pointers in fully GC languages. I've seen it happens in Java multiple times. Of course, two wrongs don't make right, but in this case GC is only solving one case.

But the more fundamental issue at play is that pointer cycles usually means a lack of up-front design and thought about how a program will work. As such, it is bad, but only a symptom of a lack of proper initial design which can manifest itself in all kind of places.


I think for shared_from_this() I have a different use-case in mind: an object of some class wants to register with some other class. I.e. think about something like

  auto self = shared_from_this();
  document.onclick([self](){self->doit(); } );
I'm not sure there is a way to express that in C++ without use of enable_shared_from_this.

It is very easy to accidentally run this kind of code from a constructor where shared_from_this() is a 0-ptr.

make_shared also only helps in 95% of cases. E.g. it does not allow specifying a custom deleter, which is a use-case that is allowed by the shared_ptr<> constructor. It also won't fix any of the problems WRT shared_from_this() being called in the constructor.


> I view move semantics as not a memory safety thing at all (in fact, use-after-move can be exactly as ugly as use-after-delete),

Note that move-destructors were also considered for C++11, and were not added for lack of time / implementation experience . And this is really too bad, because they are at least as important as move constructors if not more so; I've yet to encounter a case where you actually want to use a moved-from object after the move.


Everybody and their uncle have an idea about how they would "fix C++" - but these ideas mostly contradict each other and would make for rather different languages :-)


I’d argue it doesn’t make sense to fix c++. A language is not about what you can do with it, its about what it doesn't let you do. C++ will continue letting you do everything in the name of backward compatibility.


At some point, that's going to stop being sustainable.

I think C++ could really use what Rust calls generations. Figure out the obviously bad ideas and deprecated them over 10 year cycles.

That would allow the language to shrink again, I don't think it ever did.


They're called "editions". And it was proposed to the C++ committee, but with things like ADL and SFINAE existing, making any substantial changes just isn't possible. What happens if you SFINAE on a class derived from an STL container with a method removed in Y from a module in edition Y when it is declared in a module with edition X (where it would exist)? Does the method exist or not? When using contracts from different editions which holds? In what context is the question asked?

Even things like integer promotion rules or "trivial" things like "deprecate NULL and 0 for nullptr" are likely untouchable with any edition mechanism for C++ that doesn't end up Breaking the World.


Actually, that point has already come: There is insistence on maintaining ABI compatibility with increasing versions (as opposed to source-level compatibility), which prevents various potential improvements.

Regardless - C++ will effectively has some deprecation, at least of the standard library, with the introduction of modules instead of included headers in C++20.


Uh, you're too optimistic:

Everybody and their uncle had a lot of ideas about how they would fix "C++". Eventually all those contradicting ideas got merged into the C++ standard.

:)


No no, almost nothing gets merged into the standard if it breaks anything, and to "fix" the language you definitely have to break it...


This is one of the few times I've seen anybody mention threading in Ada in this sort of discussion. I've become quite fond of the language, and my first brush over the tasking stuff left me fairly impressed/interested. However actually getting into it is something I've been meaning to do, so I don't really have much experience with it in practice.

So I'm quite curious, what problems with it you were referring to?


Not OP, and not familiar with Ada - but this might go into the limitations of Ada's concurrency model:

Rendezvous Facilities: Concurrent C and the Ada Language - https://www.computer.org/csdl/journal/ts/1988/11/e1546/13rRU...

> The concurrent programming facilities in both Concurrent C and the Ada language are based on the rendezvous concept. Although these facilities are similar, there are substantial differences.

> Facilities in Concurrent C were designed keeping in perspective the concurrent programming facilities in the Ada language and their limitations. Concurrent C facilities have also been modified as a result of experience with its initial implementations.

> The authors compare the concurrent programming facilities in Concurrent C and Ada and show that it is easier to write a variety of concurrent programs in Concurrent C than in Ada.


A lot changes in Ada-95; the Ada-83 concurrency model was indeed very limited (which is what that article talked about).


Rust is not a good example of a language which doesn't require discipline: on the contrary, it requires very much discipline and thinking deeply about lifetimes and resources. The difference to C++ (since that's what you've mentioned) is that the compiler double-checks everything at the end. The advance in programming language usability will not come from arcane tools like Rust, but from tools like Java, Python or Go which make previously complicated things simpler with e.g. garbage collection or coroutines.

Using "scale" is also misleading, because to use again your example C++ is a mainstream programming language and one of the most popular in the world. It towers above Rust by any thinkable metric related to scale - popularity, projects, developers, libraries, etc.

I do agree that discipline isn't enough... in the sense that one can't rely on discipline to achieve safety. This is a well known principle of safety engineering, which applies to all systems, not just SW or SW/HW systems. Discipline remains nevertheless essential, because it's one of the main things influencing the "human factor".


I admit I have a bias here, as I use Rust full-time, but Java, Python and Go are not what I would call languages that require less discipline. For instance, Go's error handling is entirely reliant on discipline! All of the meta-programming and runtime reflection nonsense requires discipline to not abuse and misuse. The worst codebases I have seen in Rust are significantly better than the worst codebases I have seen in languages like Java or Python, and maybe its because Rust programmers are more disciplined on average, or maybe its because the compiler requiring clean code forces less disciplined developers to produce better code.

Perhaps there are two camps here: (1) Reduce discipline by making developers no longer have to consider certain situations (garbage collection fits here) and (2) Reduce discipline by shifting that to the compiler (types fit in here)


> Rust is not a good example of a language which doesn't require discipline: on the contrary, it requires very much discipline and thinking deeply about lifetimes and resources.

My experience is completely different. I programmed a bit in rust recently. Nothing system level. Quite opposite.

I had multiple occasions when after succeeding to implement some part in Rust I thought to myself "wow, if I tried to do that in C++ I would have fallen in so many pit traps by now, that rust helped me avoid by restricting me in some ways".

And I'm very new to rust and I was just coding blindly just by powering through different approaches till one of those compiled.

"I can't use borrow to keep that? What's a borrow anyways? Let's just cram it in Rc whatever it is. I can't return this? Fiiine! I'll create it outside and pass it in."

And in the end it worked perfectly. No weird bugs to find. I felt like a caveman trying to bang rock into shape. And at no point rock cracked, split and or buried me under the rubble.


> ... the compiler double-checks everything at the end

This is exactly what makes it so that the programmer doesn't have to be disciplined. The Rust compiler will correct the lazy Rust programmer, while the C++ compiler will blithely do its best.


I’m not sure “what locks it” is a useful question. Locking is a performance and scalability destroying operation in a time when we care about both. Systems that care about both largely avoid locking (including most “lock-free” locks) altogether outside of rare cases, and in such rare cases the logic is simple. Nothing is lost by avoiding locks with good architecture.

In big multi-core systems, I model the handful of locks by the worst-case access frequency for a core to understand contention. In practice, this is often on the order of 100ms, which means the locks are effectively contention-free and the fast path almost always wins.


"What locks it" can include "owned by different threads". That's a lock at a higher level, really.

"Lock free" data structures are tied very closely to the data being locked, and depend heavily on certain CPU operations being atomic. This is more of an issue with ARM than with x86, because ARM needs more fence instructions. Really, "lock free" programming is just locking critical sections with lower level hardware primitives.

The opposite extreme is POSIX locks. Locks are the OS's problem, and nothing in the code says what's covered by the lock. That was once standard practice and is now recognized as being bad.


> Really, "lock free" programming is just locking critical sections with lower level hardware primitives.

I have to disagree, as the more interesting lock free algorithms use atomic operations that can fail. Yes, a compare-and-swap is like having a critical section on modifying that particular address. But a compare-and-swap can fail if what is currently there is unexpected. The result of that failure generally means redoing a bunch of work, rather than just trying again. Interesting lock free algorithms tend to have the structure:

1. Read some data.

2. Perform computations on that data.

3. Trying to commit the result of that computation. If someone else committed data to the same location after you read it in step 1, go back to step 1.

That's less like a "critical section," and more like a transaction. For lock free algorithms that just rely on atomic operations that cannot fail (such as an atomic increment), then yes, those are just like having a lower-level critical section. (And, on some architectures, that's exactly what they are.)


Optimistic locking is still locking. The low level compare-and-swap operation still uses CPU cacheline-level locking. https://en.wikipedia.org/wiki/MESI_protocol


What's important is that you no longer have a critical section, where mutual exclusion is enforced. The only piece of code that has mutual exclusion is an attempt to commit the transaction. The kind of thinking required to implement such algorithms is quite different than when you can rely on mutual exclusion.

(Also, not all architecture use hardware mechanisms to enforce actual mutual exclusion. For example, Power does not, as it uses load-linked, stored-conditional: https://en.wikipedia.org/wiki/Load-link/store-conditional. On Power, even things like atomic increments are implemented with LL/SC. Architectures basically have to decide: will they implement atomics through some kind of a lock mechanism, or some kind of LL/SC mechanism.)


You still have a critical section; the cacheline is in an exclusive mode during the change. LL/SC operates on top the cache sharing protocols, just like everything else. Your wikipedia link even refers to that: "Typically, CPUs track the load-linked address at a cache-line or other granularity [...]". MESI and friends are that mechanism.

What lock-free means in this case is that an OS level thread freezing doesn't prevent global progress. That's true on the abstraction level of typical programming, but grandparent is very much correct:

> Really, "lock free" programming is just locking critical sections with lower level hardware primitives.


compare-and-swap

Compare and swap is interesting. It's a tiny transaction. It can even operate across networks - FireWire had hardware compare and swap support. Compare and swap with retry may have to wait and retry, which is effectively waiting for someone else to stop using a resource you want. So it's a form of a critical section lock.


> Really, "lock free" programming is just locking critical sections with lower level hardware primitives

Can fence instructions really be seen as locking mechanism? While a lock generally is bound to some data, memory fences are bound to relationships between data or a piece of code. I don't know if Rust or any other language can help you with that.

Isn't the actual bad practice to expose locks at all? If the locking is done implicitly in API calls, one doesn't need to ask "What locks it".


The fences aren't a locking mechanism, at least not on their own. The lower (hardware) level locks are the steps in the cache coherency protocols (potentially including explicit cache line locking signals) that allow an execution unit to, eg, perform a correct atomic compare-exchange operation on a 32/64/128-bit piece of data.

See also, eg, the LOCK prefix on x86 instructions.

(disclaimer: I only have approximate knowledge of anything, I'm not an expert)


Agreed. But! Not all architectures implement atomic operations by locking the bus, as x86 does. The Power architecture uses load-linked, stored-conditional (https://en.wikipedia.org/wiki/Load-link/store-conditional) where when you load a value, you can say "pay attention to who modifies this value," and when you go to store the value, you can say "only actually store this value if no one else modified it."


> Locking is a performance [...] destroying operation

That is incorrect. Properly designed and implemented mutexes (like in the parking_lot crate [1]) are extremely fast in the uncontented case, and need just one byte of overhead (and no heap-allocated memory) per mutex. In the contented case, a lock-free algorithm still has to deal with arbitration between the different threads, so it's typically not faster than a lock-based approach. Lock-free algorithms do have certain advantages (like deadlock-freedom), but performance is typically not one of them.

> Locking is a [...] scalability destroying operation

Also incorrect. If you have N threads trying to append to a single queue, then you have a scalability problem whether you're using locked or lock-free data structures. The solution in such a case is fine-grained concurrency and a different approach, not lock-free algorithms.

[1] https://github.com/Amanieu/parking_lot


> it's typically not faster than a lock-based approach

A common train of thought seems to be "locks are slow, so lock free must be fast", while lock-free says nothing about performance -- only about forward progress.


The question is not about actual software locks.

The question is "What prevents me from making changes to this variable without breaking everything".

So the point is not "What locks guard this data" the point is, how do I determine whether I can currently modify (or even read) this data. This allows the compiler to check that, indeed, you never use the data in a way that would break things. Hence your compiler can then give guarantees about thread-safety.

Now it seems likely that, when compilers do this and you write wrong code, your conclusion might be "I need a mutex lock for this", or if performance matters, "I need to rewrite this to allow performant lockless code. But the main question remains "Can I currently modify this data without issues".


im pretty sure Rust builds on top of C++11's move semantics and unique_ptr.


The compiler needs that information for checking, and the programmer, and the maintenance programmers who follow behind, need that information so they know how to call the thing. On the other hand, you can usually infer the result types of expressions by a simple forward process.

Possibly unpopular opinion: I am wary of relying on type inference for return types, other than for a function defined locally where you can easily see the implementation at the same time as the code calling it. Code like

    auto x = calculate_some_complicated_thing()
    ...
    do_something_with(x)
has a similar obscurity problem to code like

    do_something_impressive(true, false, false, true)
in that to understand what the code is doing at this level, you have to read the interface documentation for the function you’re calling anyway, and then you’re hoping that you get everything in the right place because your tools might not be able to warn you if you don’t.


In practice, type inference for return types works fine for the work I do (iOS development). Initializers are blindingly obvious anyway, and other functions are often members of an object or a struct. There are barely any bare functions.


Can’t members return other objects?


Yeah but coding style dictates that the member is then named "makeWidget()". Any confusion about the returned type is minimal, IMHO.


I thought Java's locking worked fine as long as you didn't make your locks public or lock on `this`. What is Rust doing that's an improvement?


What is wrong with locking on "this" ?

Making the locks public, I sort of get (like making any field public) -- you have multiple locks, you need to ensure they are accessed in a certain order and if you make them public, you cannot enforce that order.


>What is wrong with locking on "this" ?

Instance.this is implicitly public because something else could have a reference to instance.

    synchronize(this){}
that will lock with the following if its inside instance.

    synchronize(instance){}


"The encouraging thing today is that we're kind of converging on good practices in language design." - doubtful that we'll arrive at good practices in language design through incremental steps from a flawed base. All the mentioned languages are low-level.


Therefore... what? Just give up? Or start over from a non-flawed base? Which is what? Still to be created, or does it exist?

And in your view, are the bases flawed because they are low level?


"Therefore... what? Just give up? Or start over from a non-flawed base?" - those would both be ways to avoid the presumed local optima, yes.

"Which is what? Still to be created, or does it exist?" - several different 'initial stakes'/bases in the space of programming languages exist/were made/can be created than the family of languages mentioned in OP. Exploration from those might yield better optima. The widespread adoption of the current industry 'standard' languages seems an accident of history, not guided by quality of language.

"Are the bases flawed because they are low level?" - yes. A significant part of the computing field is about abstractions. Improving the level of the technology is one of the arguments the article here makes too. Besides, it's the implicit admission of everyone who doesn't advocate/write assembly.


This is very well summarised !


I really think lock-free concurrency models are the long term best bet. Locks and shared memory scale pretty badly, on top of being super hard to get right.


Lock free algorithms and data structures are even harder to get right! And most of the canonical descriptions of them assume that you have a garbage collector and that memory allocation is a bounded operation. Neither of which is really true when you're working in constrained situations.

On top of that, locking is faster than most lock-free strategies unless you deal with high contention or deterimistic guarantees (90% of the latter is solved with bounded fifos).


When I say lock free, im suggesting concurrency that doesn't assume shared memory. I think the notion of multiple cores working on nearby memory blocks just is a bad way to do this, and so threaded models are not great. More along the lines of an actor model or how gpus subdivide work.


> [...] how gpus subdivide work.

That's a bad example: GPUs are all about shared memory, concurrently accessed by many threads. It's up to the programmer to make sure that there are no read/write or write/write data races.


We're probably talking different GPU coding techniques... I'm not talking about things like annotating C++ code to run on a GPU, I'm thinking shaders where it's very hard/impossible to have side effects outside of what that shader is run on.


So a task/job system with explicit dataflow, where data is handed off to the task that uses it, rather than being shared? A lot of game engines do this nowadays and it works quite well. It doesn't eliminate synchronisation completely, but it does simplify things a good bit.

Ideally, any shared data is immutable (and modifying it would then need to be done in a coordinated synchronised fashion) while most mutable state is only ever owned by one task/thread, with ownership explicitly passed between them as others need access, unique_ptr style. Reality doesn't always match with the ideal though.


most mutable state is only ever owned by one task/thread

For many applications, this is the way I prefer to structure code these days. If you know that the only part of your program that will touch certain state directly is always running in a single thread, a lot of problems just go away, because whatever you do in response to any given message or other signal is effectively an atomic transaction, regardless of any concurrency or asynchronous behaviour you have going on elsewhere in your system. You still need to figure out how your message passing or other signalling infrastructure is going to work and in particular what will block and what won’t, so it doesn’t answer all of the usual questions with concurrency, but it’s a widely useful strategy and it goes a long way toward keeping things manageable.


I like lockless synchronization, but livelock can also be an annoying problem.


IMHO, types for function parameters depends on the use case.

If you have a function in a standard library, types are obviously helpful, albeit it can be convoluted if they are templated.

On the other end of the scale, if you have a little helper function used to reduce duplication/increase readability in another function, explicit typing easily becomes busywork. This is where duck typing/templates shines.

I once sent a suggestion to the C++ committee people that they allowed easy templating of function parameters (something like "auto foo(auto bar)"), but I don't think people who are not used to duck typing appreciate how much it helps in the grunt work inside modules. At least, I didn't myself.


Well, you'll be happy to know that C++ pretty much allows that, but not for templates. You can use auto in parameters if the calling context allows inference, for example when writing lambda.

Similarly, you can use auto as a return type if the function implementation is immediately given and the compiler with infer the return type. The new starship operator (<=>) typical usage is pretty much declaring it with an auto return type and "= default" implementation.


I quite like the Haskell culture for this one: you specify the type signatures explicitly for your top-level functions, but you don’t usually specify them for nested functions. Put another way, anything with an interface you might need to understand somewhere else should be explicit, but anything only used very locally doesn’t need to be.


There's something I've always been curious about wrt `auto`, and that's why have the keyword at all? In Swift for instance, one can just use `var` or `let` to express mutability and have the compiler infer the type. Is there a particular reason C++ can't, or perhaps chooses not to, do this?


I'm not sure there's a particular difference between the two except the keyword chosen. You type "auto my_var_name = func_call()" or you type "let my_var_name = func_call()". In both cases you get an inferred type for a variable.

Mutability is very different in C++ though - const is quite a complex topic, and isn't as simple as "this variable binding is/isn't mutable".




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: