Personally, I agree that Rust is complex, but almost all of that complexity is inherent to the kinds of tradeoffs Rust makes. With different tradeoffs, I can certainly imagine a much simpler language, but it's less clear to me what stuff could be significantly simplified without doing so. However, I am extremely biased!
1. 90% of the time, rust module layouts are basically a copy of the file-system heirarchy, so why do I have to type this? This system would be much more approachable if it gave the file-system hierarchy as the default module layout, and let you override it explicitly. The argument I have heard against this is that some people like to comment out module declarations during debugging. I don't find this compelling, because I don't see why you couldn't have an explicit way to ignore a module instead. This is just one of many cases where Rust seems to prioritize the edge case at the expense of the common case.
2. On top of requiring a lot of boilerplate the module system is quite esoteric. I've had to learn it twice: a few years ago I was dabbling in Rust, and I remember having to struggle a bit to understand how to add a second file to my project, and then about a year ago when I was getting back into rust I found it unintuitive a second time. I challenge you to find someone who is unfamiliar with the module system, and see how long it takes them, using only the documentation, to figure out where they have to put the `mod` declarations to add nested submodules to their project to make it compile. Maybe I am unreasonably thick, but I don't think it's my problem since I've worked with a lot of languages, and never had this much trouble.
3. This esoteric system isn't even deterministic. Imagine I have the line `mod foo` in my `lib.rs`. Where can I go to find the source for that? Well, it depends. It could be in `src/foo.rs`, or it could be in `src/foo/mod.rs`. And let's say I'm using external crate: `use some_crate`. Which import does that correspond to? It could be: `some_crate`, or it could be `some-crate` in my `Cargo.toml`. You just have to kind of know all of these implicit behaviors of the compiler to know what's going on.
So this is just one feature, but IMO it's just one example of a case where Rust puts very little emphasis on the UX and understandability of the language.
There are some good reasons and interesting arguments around all this, but given that I was just curious about your opinion, I won't bore you with all that :) Thanks!
I think it's debatable whether the module system really is complex or just different from what newcomers are used to (and by now I've grown pretty accustomed to it). But in contrast to most other language features, where it was clear what I was getting in return for the steep learning curve, the module system seemed overly complicated at the time for no real benefit. Not a big issue by any means and I would choose Rust with its module system over the alternatives most days of the week, but it is one tradeoff that to me at least seemed orthogonal to the other borrowing-related complexities.
(What is more worrying to me nowadays is the whole async story. I do hope that some of it will get better once certain features land and it is certainly an area where some additional complexity is unavoidable, but it is the only part of Rust that I dread touching despite heavily using async in a moderately sized personal project due to the need for WASM + IndexedDB, simply because lifetime issues become much more tricky once async and either traits, recursion or closures are involved. By now I am consciously trying to limit any async parts of the program to a simple and stupid "Rust-light" style without any "fancy" features such as traits or closures, which does not feel like a proper solution. So yeah, in general I agree with you: Rust is certainly complex, but for the most part not unnecessarily so.)
[1]: https://rust-lang.github.io/wg-async-foundations/vision.html
[2]: https://blog.rust-lang.org/2021/03/18/async-vision-doc.html
[3]: https://github.com/rust-lang/wg-async-foundations/pulls
[4]: https://github.com/rust-lang/wg-async-foundations/issues
Yeah, teaching the module system is kind of my white whale. Carol and I have spent more time on that part of the book than almost any other; re-written like five times.
My current working theory is that most people assume that "the module system" is similar to whatever one they've used in the past, then run into problems, and leads to frustration. I've talked to so many people who have totally opposite problems with it, with no real pattern to issues or expectations.
I think that it's very straightforward, personally, with very very simple rules (especially in 2018). But I certainly acknowledge that I am the exception, not the rule.
I agree with you. It's a shame we don't have a simple visual metaphor to describe this
EDIT: Wait does hacker news just delete non-ascii? I tried to put an upside down smiley here how is this even worse than I thought
When I treated modules as Java packages or C# namespaces I hated them. Only when I realized that I can treat them as glorified C imports did they start to make sense. I still hate them, but at least I can rationalize their design now.
I disagree that this is bad, and I think you've made a good decision.
I find that too many Rust programmers reach for a closure WAY too often and, even when they should grab a closure, they make it far too complicated. Closures should be short. Inline closures are nice when they're a single line part of "collect()" (although, I've seen some that make me want to hang the author ...).
However, if your closure is 15 lines long invoking a builder chain (this is a common issue in EventLoop type closures), that should be a function. This is before I get started about how builder chains are a gigantic misfeature to paper over the fact that the language doesn't have default/named function arguments.
Anyway ... I have found that "make it a named function and call it" is often a far better way to communicate exactly what your intent was.
Totally agree. I think it's telling that Rust Analyzer basically inserts argument labels inline in the editor, and this is the preferred way to work with Rust.
(In general, if you find yourself inventing falsehoods about other languages to promote Rust, you are Doing That Wrong, too.)
I have personally spent more time, summed over the past decade, preparing bug reports against Gcc than in debugging C++ memory-usage faults.
But C++ compiles faster.
Coding Rust is fun in much the way that Forth is: it is a charge to figure out a way to achieve a thing; you know it will ultimately be possible, and when you find the way, it is an ego boost. You could say, "Rust is the Second Programming Religion", and who could ever honestly disagree?
(But watch extremists downvote this to oblivion anyway.)
Genuine noob question: can you really compare Rust's borrow checker and lifetime management to the smart pointers in C++?
C++'s smart pointers have direct analogues in Rust, though there are some differences. (unique_ptr<T> and Box<T>, shared_ptr<T> and Rc<T>/Arc<T>)
The borrow checker is something else completely. The closest analogue in C++ is the https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines, which offer some similar kinds of checks to Rust, but don't attempt to go nearly as far as Rust does.
In modern C++ you get compile-time correctness by construction: you do things that reduce to correct code. All of the low-level mistakes remain possible, but are not tempting. Low-level, C-like operations are like writing "unsafe" in Rust; you can if you want to, or need to, but they are uglier, and anyway almost always not needed. Just as when coding a Rust "unsafe" block: if it ever is, you use extra care.
In a very real sense, C++ libraries perform in the role of the Rust compiler by insulating you from operations that are risky. The operations a good library exposes are safe. A Rust compiler bug could expose you to a memory fault, in the same way that a C++ library bug could; but the Rust compiler is well exercised and tested, much as is is a mature library. You need libraries anyway.
For what is worth, I believe that 99% of what you can express in one language you can in the other. If you're hitting walls with the borrow checker when writing Rust you always have the options of either using the equivalents of unique_ptr<T> and shared_ptr<T>, or of cloning memory.
> A Rust compiler bug could expose you to a memory fault, in the same way that a C++ library bug could; but the compiler is well exercised and tested, much as is is a mature library.
I'm not sure what this sentence was expressing, but it seems you're implying here that rustc isn't well exercised and tested?
[1]: https://medium.com/@lightcone/iterator-invalidation-in-moder...
It is hard for me to imagine how "the compiler is well exercised and tested" could be perceived to mean the opposite. Explain?
I misread it as talking about modern c++ compilers and libraries being well exercised and tested, and contrasting that with rustc.
You say that it's not "tempting" to use less-safe constructs in C++ these days, but that's not the point. You are assuming a perfect C++ developer who never makes mistakes and always knows to use the proper, modern constructs, and will properly document any time that they absolutely must use a less-safe construct, and that documentation will never drift or go out of date.
That developer does not exist in any meaningful or useful way. Developers make mistakes. Developers don't always have completely up-to-date knowledge or understanding of what the correct, "modern C++" way is to do literally everything. A developer who genuinely does need to do something less safe may or may not document it, and anyone who comes by later and changes the code may not update the documentation accordingly.
The Rust compiler will not allow you to do unsafe things (absent bugs in rustc); attempting to do so is a compiler error. If you really must do unsafe things, you must surround that code in an unsafe block, which serves as documentation that must be kept up to date, or the code will not compile. Others can inspect your code and very easily decide if they want to use it based on how much unsafe code they are willing to accept.
I don't want a compiler that will only ensure certain kinds of correctness if I already know how to write things in the "correct way" (which has traditionally been a moving target in the C++ world). I want a compiler that will ensure those types of correctness always, and reject programs where it can't give me that guarantee. The C++ compiler will not do that, but the Rust compiler will.
> In modern C++ you get compile-time correctness by construction
No you don't! You only get this if you've written your code correctly! The entire point of "correct by construction, verified by the compiler" is that you cannot write the code in an incorrect way that the compiler will accept. And the C++ compiler absolutely will accept "old-school C++" that could be incorrect. Put another way, there is no C++ compiler that will only accept "modern C++" and reject "old-school C++". And even if someone were to try to write such a thing (I am skeptical it's possible), reasonable, knowledgeable C++ developers could easily disagree on what should and shouldn't be included.
Fair enough. Yet, there are literally many thousands of fully competent, up to date C++ developers for each person who ever so much as compiled a hello.rs. (Probably the majority of the latter are among the former.) By 2030, under the absolute best-case scenario for Rust, there will still be literally millions of times as much C++ code as Rust code to maintain and improve upon.
> The Rust compiler will not allow you to do unsafe things ...[without] an unsafe block ...
In other words, the Rust compiler will allow you to do unsafe things with the addition of 8 characters. The temptation to that varies.
> The entire point of "correct by construction[...]" is that you cannot write the code in an incorrect way
The C++ language provides a comprehensive type system that enables the compiler, with well-engineered libraries, to prove correct use of types the libraries define. This depends on library designers taking up the mantle that the Rust compiler takes for itself. As they do.
But you need competently constructed libraries anyway, in Rust as much as in C++. Many demands are placed on library designers and implementers. Ensuring that only correct usage compiles (absent "unsafe") is not among the most difficult of those demands.
Rust will not save the world. That would be asking too much of it. There is no substitute for competence. Rust is not one.
C++ improves on a strict three-year cycle, and new C++ code can be demonstrably better, by any measure, than was possible for old code. To the degree that Rust and modern C++ can displace C, Java, and bad old C++ code, both can contribute to a better future world. Rust will not displace C++ in any plausible future, but not displacing C++ will not mean Rust has failed.
This is actually something I agree with, even as a fan of Rust. Sometimes I wonder to what extent the appeal of Rust is based in the fact that you get to feel like a CS undergrad again, climbing mountains to make code compile and run
I don't believe this, but I'd love to be wrong. Is there an exemplary codebase somewhere that I can take a look at?
struct Shape {
Shape() { init(); };
void init() { reset(); };
virtual void reset() = 0;
};
struct Point: Shape {
virtual void reset() { _x = _y = 0; }
double _x, _y;
};
int main() {
Point p; // KA-BOOM
}
Compiles without any warning with -Wall & -Wpedantic, fails at run.Clearly, this isn't it, but I do appreciate the interesting snippet. Would the compiler have caught this if `Shape()` called `reset()` directly?
struct Point {
double x{}, y{}; // zero-initialization
};
int main() {
Point p; // ok, {0.0, 0.0}
...
p = Point{}; // no need for a "reset"
}
It has been literal decades since anyone competent would have made a Point derived with virtuals, or have written so much code to achieve so little. (You might see stuff like that at Google.) You can write bad code in any language, but if you have to do extra work to make bad code, it is not tempting.I would argue that (i) old-fashioned code is not deliberately going out of your way to write bad code, and (ii) this should, at the very least, trigger a warning from the compiler.
Anytime you do all the extra work to write old-fashioned code, you have earned the outcome you get. The oldest-fashioned code looks just like C, which you can still write in a C++ program, if you want to. But there is no reasonable temptation to. Good modern code is equally fast, often faster, and more easily written, understood, and maintained.
The problem being that there is no definition of “modern” C++, and even less so that would be enforced by compilers.
> Anytime you do all the extra work
There is no extra work to write “old-fashioned” code. Quite the opposite actually; one has to indicate to compilers to accept newer features rather than the opposite.
> Good modern code
This is a very flimsy, handy-wavy concept, that changes drastically from one “best practice” guide to the other.
When people say this, they almost always mean making heavy use of C++11 (and later) features, defaulting to `unique_ptr` (or `shared_ptr` if it absolutely needs to be passed around), etc.
Also there's a sizeable minority of people who also mean staying on the stack whenever possible. Which, don't get me wrong, is a good thing, but that's been a thing going all the way back to C. I chalk that particular one up to those people having to debug C++ code written by java devs.
Besides value types' benefits to code comprehension, they often give the optimizer enormously more latitude to operate because it knows there are no stray pointers to the object.
Over-use of std::shared_ptr is called Java Disease. It has been seen to be curable. A value object containing just a std::unique_ptr<Impl> member (often called "Pimpl", pointer-to-implementation) gets most benefits of value semantics, in cases where Impl is bigger than you would want to pass around directly, and stylistically is overwhelmingly better than passing and returning std::unique_ptr.
If I were to try to pick it up again today, I would have to learn "modern C++"[0] incrementally. I would still have some old habits, and they would take time to iron out. My guess is that it would take me at least a year, possibly more, to become fully proficient in "modern C++".
And even then, I'd still expect that I'd occasionally write some C++ in the "old" way. Maybe I'm tired and forget, maybe I'm lazy and want a shortcut. Who knows. The compiler won't save me from my "old C++". It'll be there, warts and footguns and all.
I'd much rather write in a language designed to not have these problems in the first place, and let the compiler catch as many problems as it can.
[0] Whatever "modern C++" means; I suspect current C++ developers can reasonably disagree on the details, as has been the case for the entire history of C++.
Joke aside, the STL grew in complexity and features to provide more compile-time constructs and to integrate more functional programming aspects.
For example: I've been told many times that in "modern C++" you don't need `new` nor `delete`.
There are smart pointers, std::array, std::optional, const-expressions, you'll find more and more "single-header" libraries (for JSON, an either monad, etc...), even modules[1].
It's like learning a new language. C++11, 14, 17 and 20 are completely different to C++03 while remaining backward compatible (a critical feature for C/C++ languages it seems).
And even then, I think you're still behind rust. Sanitizers can only catch data races / memory errors / uninitialized reads / etc that actually happen. OTOH, Rust _proves_ that your program doesn't have these faults (modulo unsafe blocks).
As Dijkstra noted, testing can only prove a program wrong. The way to get correct programs, in any expressive-enough language, is by construction. At each level, do only operations that are well-defined by the level below. Expose only well-defined operations to the next level up. The compiler proves that the types are used correctly; so, when coding a library, you put the type system to work to make wrong code harder than correct code.
That is the right way to code Rust, too.
The best-defined software I know of is SQLite. Every function is carefully documented with a description of all failure modes and returned error codes.
But also... SQLite has one of the most comprehensive test suites in existence, so I guess they lost? https://www.sqlite.org/testing.html
SQLite are in a good position to switch to building with a C++ compiler, and then modernize incrementally. They won't, for cultural reasons. They probably could use that C-to-Rust translator thing that was used on Quake3, but they won't do that either, for better reasons.
You’re demanding a rigor that is not possible with a modern technology stack.
I am responding to this idea:
> If you are relying on testing for correctness, you have already lost. In any language. As Dijkstra noted, testing can only prove a program wrong. The way to get correct programs, in any expressive-enough language, is by construction. At each level, do only operations that are well-defined by the level below. Expose only well-defined operations to the next level up.
I believe this idea is naïve. CPUs contain undocumented instructions, and they expose implementation details via speculative execution and timing attacks.
So... by this metric, we have already lost.
I think I also take issue with the idea that there is a stable C++ dialect. GCC, Clang, and MSVC have always disagreed on how they interpret the C++ standard.
If you want portable code, there is no substitute for testing your program with every compiler you support and on every architecture you support. Proofs won't save you and the standard won't save you, because both proofs and the standard assume that we started from a bug-free foundation that doesn't actually exist.
You don't seem to realize that testing and proofs are just two faces of the same thing. A test is just an empirical proof of something. Both are rooted in the same logic and assumptions. In fact, in some cases, a test can be exhaustive and then it's as good as a proof. Tests usually have the disadvantage of being inexhaustive, and having to work their logic within the language.
I'm getting to the point that security software, such as an OS kernel, that is vulnerable to timing and speculative execution attacks, is that way not only in spite of being proven, but in spite of testing also.
It's not the case that proofs will fail to reveal that problem, but testing will.
No amount of testing will reveal a problem missed by proof methods, if both the testing and proving are rooted in the same false assumptions.
(Assumptions like "the hardware's protections mechanisms are sound, so that no data flows are observable to an unauthorized domain, so we just have to test the software itself is good.")
https://www.cs.utexas.edu/users/EWD/ewd11xx/EWD1162.PDF
In this paper, he begins with a program written in what I believe to be propositional logic notation. This program can't be typed into an HN comment, but it can be trivially translated in to Common Lisp:
(loop for k from 0 below n always (f k))
The rest of the paper is about him translating this program into another kind of math notation with C-like semantics. By the end of the paper, he has proven that the second, lower-level program is equivalent to the first program, which is the same thing that a compiler does.
He does not prove that the thing he originally wrote at the beginning correctly expresses his intentions. So his method is basically this:
1. Write a bug-free program in a very high-level programming language, or a very rigorous pseudocode.
2. Translate it to a low-level language.
3. Prove that the translation is equivalent to the original program.
Works in what way? Serious question. Do you mean "works" as in 1) "doesn't crash/seg/overflow" or 2) "doesn't UB" or 3) "doesn't have race bugs" or 4) "does what the programmer intended"?
I would say from my experience, none are strictly true. I'll hazard you mean to imply the dev knows the language pretty well in order to satisfy "works lvl 1" but you still have veterans running into bugs of the 2-4 variety.
But even to get to (1), C++ requires a ton of domain knowledge. I've been working with C++ intensively for several months and incidentally for years, and I still seg every now and then. Even after using Valgrind, it feels rickety. Meanwhile, I've spent a few dozen weekends on Rust and I just feel way more confidence that my program will "just work" without crashing.
That's all still "level 1 works". When it comes to race conditions and programmer intent making its way into correct code, it's no comparison. Rust is way easier to get my intent into a running program. C++, I still have to lean into logs and debugging, because it just doesn't quite do what I want a non-trivial amount of the time.
It's not a religion. Rust simply is a better experience. I can say that having learned both basically side-by-side.
It takes discipline to learn to write C++ in the modern way. It is work to keep up with improvements in the language and library, as new Standards are published. But many, many professionals do. C++ is not a toy. It has sharp edges that must be treated with respect. But if you do make the effort to keep up, and lean hard on the type system to help everywhere it can, the language delivers.
If your code feels rickety, it is. You have at hand the tools to fix that. If you find yourself coding races, it is because you have not adopted means to prevent them.
I can testify that coding in modern C++ can be pure fun. I never have occasion to worry about memory safety, or data races. Code not working the way (I thought) I wanted, on first run, is very much the exception.
If it is such a core just to hope to, one day, be able to code that, maybe, won't crash, what do you think is the point of using C++ over Rust?
In some possible futures, Rust code may take up just a bit of the load. The most value would come from its displacing C and Java in new projects.
Liiterally millions of times more benefit.