Most active commenters
  • Const-me(4)

←back to thread

451 points birdculture | 27 comments | | HN request time: 1.214s | source | bottom
1. sesm ◴[] No.43979679[source]
Is there a concise document that explains major decisions behind Rust language design for those who know C++? Not a newbie tutorial, just straight to the point: why in-place mutability instead of other options, why encourage stack allocation, what problems with C++ does it solve and at what cost, etc.
replies(5): >>43979717 #>>43979806 #>>43980063 #>>43982558 #>>43984758 #
2. abirch ◴[] No.43979717[source]
I think the major decisions behind Rust is being explicit and making the programmer make decisions. No NULLs, no Implicit conversions, no dangling pointers. Lifetimes, Optional, Results, each Match branch needs to exist, etc.

Side note: Stack allocation is faster to execute as there's a higher probability of it being cached.

Here is a free book for a C++ to Rust explanation. https://vnduongthanhtung.gitbooks.io/migrate-from-c-to-rust/...

replies(1): >>43980072 #
3. NobodyNada ◴[] No.43979806[source]
This might not be exactly what you're looking for, but I really like "References are like jumps": https://without.boats/blog/references-are-like-jumps/
4. jandrewrogers ◴[] No.43980063[source]
Rust has better defaults for types than C++, largely because the C++ defaults came from C. Rust is more ergonomic in this regard. If you designed C++ today, it would likely adopt many of these defaults.

However, for high-performance systems software specifically, objects often have intrinsically ambiguous ownership and lifetimes that are only resolvable at runtime. Rust has a pretty rigid view of such things. In these cases C++ is much more ergonomic because objects with these properties are essentially outside the Rust model.

In my own mental model, Rust is what Java maybe should have been. It makes too many compromises for low-level systems code such that it has poor ergonomics for that use case.

replies(3): >>43980292 #>>43980421 #>>43981221 #
5. sesm ◴[] No.43980072[source]
> being explicit and making the programmer make decisions

Why RAII then?

> C++ to Rust explanation

I've seen this one. It is very newbie oriented, filled with trivial examples and doesn't even have Rust refs to C++ smart pointers comparison table.

replies(3): >>43980130 #>>43982034 #>>43984197 #
6. landr0id ◴[] No.43980130{3}[source]
>> being explicit and making the programmer make decisions

>Why RAII then?

Their quote is probably better rephrased as _being explicit and making the programmer make decisions when the compiler's decision might impact safety_

Implicit conversion between primitives may impact the safety of your application. Implicit memory management and initialization is something the compiler can do safely and is central to Rust's safety story.

7. Ar-Curunir ◴[] No.43980292[source]
> However, for high-performance systems software specifically, objects often have intrinsically ambiguous ownership

What is the evidence for this? Plenty of high-performance systems software (browsers, kernels, web servers, you name it) has been written in Rust. Also Rust does support runtime borrow-checking with Rc<RefCell<_>>. It's just less ergonomic than references, but it works just fine.

replies(1): >>43980604 #
8. Const-me ◴[] No.43980421[source]
Interestingly, CPU-bound high-performance systems are also incompatible with Rust’s model. Ownership for them is unambiguous, but Rust has another issue, doesn’t support multiple writeable references of the same memory accessed by multiple CPU cores in parallel.

A trivial example is multiplication of large square matrices. An implementation needs to leverage all available CPU cores, and a traditional way to do that you’ll find in many BLAS libraries – compute different tiles of the output matrix on different CPU cores. A tile is not a continuous slice of memory, it’s a rectangular segment of a dense 2D array. Storing different tiles of the same matrix in parallel is trivial in C++, very hard in Rust.

replies(2): >>43981043 #>>43982067 #
9. jandrewrogers ◴[] No.43980604{3}[source]
Anyone that works on e.g. database kernels that do direct DMA (i.e. all the high-performance ones) experiences this. The silicon doesn’t care about your programming language’s ownership model and will violate it at will. You can’t fix it in the language, you have to accept the behavior of the silicon. Lifetimes are intrinsically ambiguous because objects have neither a consistent nor persistent memory address, a pretty standard property in databases, and a mandatory property of large databases. Yes, you can kind of work around it in idiomatic Rust but performance will not be anything like comparable if you do. You have to embrace the nature of the thing.

The near impossibility of building a competitive high-performance I/O scheduler in safe Rust is almost a trope at this point in serious performance-engineering circles.

To be clear, C++ is not exactly comfortable with this either but it acknowledges that these cases exist and provides tools to manage it. Rust, not so much.

replies(2): >>43981194 #>>43986775 #
10. winrid ◴[] No.43981043{3}[source]
Hard in safe rust. you can just use unsafe in that one area and still benefit in most of your application from safe rust.
replies(1): >>43981264 #
11. lenkite ◴[] No.43981194{4}[source]
New DB's like Tigerbeetle are written in Zig. Memory control was one of the prime reasons. Rust's custom allocators for the standard library have been a WIP for a decade now.
12. pjmlp ◴[] No.43981221[source]
Java should have been like Modula-3, Eiffel, Active Oberon, unfortunately it did not and has been catching up to rethink its design while preserving its ABI.

Thankfully C# has mostly catched up with those languages, as the other language I enjoy using.

After that, is the usual human factor on programming languages adoption.

13. Const-me ◴[] No.43981264{4}[source]
I don’t use C++ for most of my applications. I only use C++ to build DLLs which implement CPU-bound performance sensitive numeric stuff, and sometimes to consume C++ APIs and third-party libraries.

Most of my applications are written in C#.

C# provides memory safety guarantees very comparable to Rust, other safety guarantees are better (an example is compiler option to convert integer overflows into runtime exceptions), is a higher level language, great and feature-rich standard library, even large projects compile in a few seconds, usable async IO, good quality GUI frameworks… Replacing C# with Rust would not be a benefit.

replies(3): >>43981870 #>>43983490 #>>43986327 #
14. ◴[] No.43981870{5}[source]
15. BlackFly ◴[] No.43982034{3}[source]
I would say that RAII is very explicit: Resource Acquisition Is Initialization. When you initialize the struct representing the resource you are acquiring the resource. If you have a struct representing a resource you have the resource. Knowing this, you are also acquiring a call to drop when it goes out of scope. I would argue that the difference here isn't explicitness.

Instead, I would argue that rust is favoring a form of explicitness together with correctness. You have to clean up that resource. I have seen arguments that you should be allowed to leak resources, and I am sympathetic, but if we agree on explicitness as a goal then perhaps you might understand the perspective that a leak should be explicit and not implicit in a the lack of a call a some method. Since linear types are difficult to implement auto-drops are easier if you favor easily doing the correct thing. If you want to leak your resource, stash it in some leak list or unsafe erase it. That is the thing that should be explicit: the unusual choice, not all choices and not the usual choice alone.

But yeah, the drop being implicit in the explicit initialization does lead to developers ignoring it just like a leak being implicit if you forget to call a function often leads to unintentionally buggy programs. So when a function call ends they won't realize that a large number of objects are about to get dropped.

To answer your original question, the rationale is not in one concise location but is spread throughout the various RFCs that lead to the language features.

16. arnsholt ◴[] No.43982067{3}[source]
That's the tyranny of Gödel incompleteness (or maybe Rice's theorem, or even both): useful formal systems can be either sound or complete. Rust makes the choice of being sound, with the price of course being that some valid operations not being expressible in the language. C of course works the other way around; all valid programs can be expressed, but there's no (general) way to distinguish invalid programs from valid programs.

For your concrete example of subdividing matrixes, that seems like it should be fairly straightforward in Rust too, if you convert your mutable reference to the data into a pointer, wrap your pointer arithmetic shenanigans in an unsafe block and add a comment at the top saying more or less "this is safe because the different subprograms are always operating on disjoint subsets of the data, and therefore no mutable aliasing can occur"?

17. scotty79 ◴[] No.43982558[source]
In-place mutability and stack allocation are for speed. This made them have variables and values inside them both as separate first-class citizens. The entire borrow checker is just for tracking variables access so that you don't need to clone values. I'd say that given this one guiding decision there are very little arbitrary ones in Rust. The rest of it pretty much had to be made exactly the way it is. Rust is more of a discovery than a construct.

C++ is just Rust without any attempt at tracking variable access and cloning which leads to a mess because people are too terrible at that to do that manually and ad-hoc. So Rust fixes that.

18. dwattttt ◴[] No.43983490{5}[source]
It does sound like quite a similar model; unsafe Rust in self contained regions, safe in the majority of areas.

FWIW in the case where you're not separating code via a dynamic library boundary, you give the compiler an opportunity to optimise across those unsafe usages, e.g. inlining opportunities for the unsafe code into callers.

replies(1): >>43987535 #
19. echelon ◴[] No.43984197{3}[source]
Rust is RAII at the compiler level. It's the language spec itself. That's probably the best way to describe the design and intent of Rust's memory model.

When you create a thing, you allocate it. That thing owns it and destroys it, unless you pass that ownership onto something else (which C++ RAII doesn't do very cleanly like Rust can).

Then it does some other nice things to reduce every sharp edge it can:

- No nulls, no exceptions. Really good Option<T> and Result<T,E> that make everything explicit and ensure it gets handled. Wonderful syntactic sugar to make it easy. If you ever wondered if your function should return an error code, set an error reference, throw an exception - that's never a design consideration anymore. Rust has the very best solution in the business. And it does it with rock solid safety.

- Checks how you pass memory between threads with a couple of traits (Send, Sync). If your types don't implement those (usually with atomics and locks), then your code won't pass the complier checks. So multithreaded code becomes provably safe at compile time to a large degree. It won't stop you from deadlocking if you do something silly, but it'll solve 99% of the problems.

- Traits are nicer than classes. You can still accomplish everything you can with classic classes, but you can also do more composition-based inheritance that classes don't give you by bolting traits onto anything you want.

- Rust's standard library (which you don't have to use if you're doing embedded work) has some of the nicest data structures, algorithms, OS primitives, I/O, filesystem, etc. of any language. It's had 40 years of mistakes to learn from and has some really great stuff in it. It's all wonderfully cross-platform too. I frequently write code for Windows, Mac, and Linux and it all just works out of the box. Porting is never an issue.

- Rust's functional programming idioms are super concise and easy to read. The syntax isn't terse.

- Cargo is the best package manager on the planet right now. You can easily import a whole host of library functionality, and the management of those libraries and their features is a breeze. It takes all of sixty seconds to find something you want and bring it into your codebase.

- You almost never need to think about system libraries and linking. No Makefiles, no Cmake, none of that build complexity or garbage. The compiler and cargo do all of the lifting for you. It's as easy as python. You never have to think about it.

20. steveklabnik ◴[] No.43984758[source]
I’m not aware of one, but I’m happy to answer questions.

> in-place mutability

I’m not sure what this means as.

> why encourage stack allocation

This is the same as C++, things are stack allocated by default and only put on the heap if you request it. Control is imporrant

> what problems with C++ does it solve and at what cost

The big one here is memory safety by default. You cannot have dangling pointers, iterator invalidation, and the like. The cost is that this is done via compile time checks, and you have to learn how to structure code in a way that demonstrates to the compiler that these properties are correct. That takes some time, and is the difficulty people talk about.

Rust also flips a lot of defaults that makes the language simpler. For example, in C++ terms, everything is trivially relocatable, which means Rust can move by default, and decided to eliminate move constructors. Technically Rust has no constructors at all, meaning there’s no rule of 3 or 5. The feeling of Rust code ends up being different than C++ code, as it’s sort of like “what if Modern C++ but with even more functional influence and barely any OOP.”

21. winrid ◴[] No.43986327{5}[source]
I would definitely rather use C# or java in a GUI app, yes.
22. Ar-Curunir ◴[] No.43986775{4}[source]
You can always fall back to unsafe. Again, there are very few workloads that C/C++ can support which Rust cannot.
23. Const-me ◴[] No.43987535{6}[source]
> quite a similar model

Yeah, and that model is rather old: https://en.wikipedia.org/wiki/Greenspun%27s_tenth_rule In practice, complex software systems have been written in multiple languages for decades. The requirements of performance-critical low-level components and high-level logic are too different and they are in conflict.

> you give the compiler an opportunity to optimise across those unsafe usages

One workaround is better design of the DLL API. Instead of implementing performance-critical outer layers in C#, do so on the C++ side of the interop, possibly injecting C# dependencies via function pointers or an abstract interface.

Another option is to re-implement these smaller functions in C#. Modern .NET runtime is not terribly slow; it even supports SIMD intrinsics. You are unlikely to match the performance of an optimised C++ release build with LTO, but it’s unlikely to fall significantly short.

replies(2): >>43987903 #>>43990873 #
24. neonsunset ◴[] No.43987903{7}[source]
> LTO

On some workloads (think calls not possible to inline within a hot loop), I found LTO to be a requirement for C code to match C# performance, not the other way around. We've come a long way!

(if you ask if there are any caveats - yes, JIT is able to win additional perf. points by not being constrained with SSE2/4.2 and by shipping more heavily vectorized primitives OOB which allow doing single-line changes that outpace what the average C library has access to)

replies(1): >>43988370 #
25. Const-me ◴[] No.43988370{8}[source]
> on some workloads, I found LTO to be a requirement for C code to match C# performance

Yeah, I observed that too. As far as I remember, that code did many small memory allocations, and .NET GC was faster than malloc.

However, last time I tested (used .NET 6 back then), for code which churches numbers with AVX, my C++ with SIMD intrinsics was faster than C# with SIMD intrinsics. Not by much but noticeable, like 20%. The code generator was just better in C++. I suspect the main reason is .NET JIT compiler doesn’t have time for expensive optimisations.

replies(1): >>43988785 #
26. neonsunset ◴[] No.43988785{9}[source]
> The code generator was just better in C++. I suspect the main reason is .NET JIT compiler doesn’t have time for expensive optimisations.

Yeah, there are heavy constraints on how many phases there are and how much work each phase can do. Besides inlining budget, there are many hidden "limits" within the compiler which reduce the risk of throughput loss.

For example - JIT will only be able to track so many assertions about local variables at the same time, and if the method has too many blocks, it may not perfectly track them across the full span of them.

GCC and LLVM are able to leisurely repeat optimization phases where-as RyuJIT avoids it (even if some phases replicate some optimizations happened earlier). This will change once "Opt Repeat" feature gets productized[0], we will most likely see it in NativeAOT first, as you'd expect.

On matching codegen quality produced by GCC for vectorized code - I'm usually able to replicate it by iteratively refactoring the implementation and quickly testing its disasm with Disasmo extension. The main catch with this type of code is that GCC, LLVM and ILC/RyuJIT each have their own quirks around SIMD (e.g. does the compiler mistakenly rematerialize vector constant construction inside the loop body, undoing you hosting its load?). Previously, I thought it was a weakness unique to .NET but then I learned that GCC and LLVM tend to also be vulnerable to that, and even regress across updates as it sometimes happens in SIMD edge cases in .NET. But it is certainly not as common. What GCC/LLVM are better at is if you start abstracting away your SIMD code in which case it may need more help as once you start exhausting available registers due to sometimes less than optimal register allocation you start getting spills or you may be running in a technically correct behavior around vector shuffles where JIT needs to replicate portable behavior but fails to see your constant does not need it so you need to reach out for platform-specific intrinsics to work around it.

[0]: https://github.com/dotnet/runtime/issues/108902

27. dwattttt ◴[] No.43990873{7}[source]
> injecting C# dependencies via function pointers or an abstract interface

This is the opposite of what I was suggesting though; those function pointers or abstract interfaces inhibit the kind of optimisations I was suggesting (e.g. inlining causing dead code removal of bounds checks, or inlining comparison functions into sort implementations, classics).

EDIT: that said, it's definitely still possible to not let it impact performance, it just takes being somewhat careful when making the interface, which you don't have to think about if it's all the same compiler/link step