Thanks for your detailed response. I think we still disagree on several fundamental aspects, but I see where you are coming from.
Sad but true. However, I am somewhat of an ergonomics enthusiast myself. I cringe when I have to write out a <'lifetime> explicitly.
Seriously though — I have seen and learned about many pieces of bad language design and ergonomics issues causing bugs. I’ve helped people debug their JavaScript containing == instead of ===, Python being mis-indented, C++ double-freeing through a shared_ptr because the base class wasn’t marked enable_shared_from_this… and so on and so forth. Over the years, I’ve come to appreciate good language ergonomics and despise the lack of it greatly.
However, we just seem to have different thresholds of sensitivity here, and therefore disagree on what needs changing. I think that the current syntax and semantics of unsafe in Rust is just fine (maybe with the exception of automatically marking unsafe functions’ bodies as unsafe), whereas you think it needs improvement.
We also seem to disagree on the issue of granularity. You insist that finer granularity would help learners understand the issues around unsafe code, whereas I assert this is not the case. Why do I think this? Well, here’s my take (it is my own experience mixed with some thoughts by which I try to be more systematic about explaining this):
- If you understand the workings of safe Rust, and are aware of the halting problem, you will not be surprised why certain constructs need to be marked as unsafe.
- You just ought to master safe Rust before diving deep into unsafe code. I accept no excuses.
- Safe Rust is a very well-designed, self-consistent language with a powerful, orthogonal, crystal clear type system, and a compiler which guides you towards writing the right code.
- Therefore, if you know Safe rust, you will recognize the patterns in it, and it will come to you naturally why and when unsafe code was/had to be/will be used. Of course, for the fine details of the how, you may need to consult the Rustonomicon, but my point is, the language doesn’t really contain unpleasant surprises, so even without explicit syntactic differences between the different kinds of
unsafe, it’s pretty clear what they do. (More on this later.)
Today, devising an unsafe abstraction involves a small set of complex decisions. Like “where do I need to write the unsafe keyword?”
Excuse me, but I have to disagree with that point too. It is very clear in which situations you have to use unsafe:
- First of all, you probably don’t. This should be the default answer 99.9% of the time.
- If you still do, however, then the compiler (in theory, modulo bugs) enforces the use of
unsafe around every piece of code that might introduce a soundness hole. That is the whole point in the concept of differentiating between safe and unsafe. The code won’t compile if you don’t mark something unsafe as unsafe. So, in theory you could almost devise a minimally-unsafe implementation by starting out with not using unsafe at all, then placing it around the smallest possible scope and before the least number of impls, until the code compiles. Of course, nobody does this in practice, because we already – hopefully – know which abstractions are unsafe, so we can put most of the unsafes in upfront, but I’m sure you get the point.
Actually, that is the right question. In today’s Rust, the distinction already exists. The unsafe keyword is, grammatically speaking, context-sensitive. It can mean slightly different things based on where it is in the code:
- Before a block, it means “allow me to use unsafe abstractions here” (e.g. calling an unsafe function or dereferencing a raw pointer).
- Before a function, it means “this function does not enforce all guarantees of safe Rust, you need to be extra careful when calling it!”. If I understand correctly, the proposed
unsafe(pre) and unsafe(post) annotations would further break down this scenario into two different case.
- Before a trait impl, it means “I am marking this type as having an invariant which the compiler couldn’t prove”.
And so on and so forth. Ouch, that context-sensitivity might sound really bad to some people! I can understand that it is a valid concern. Yet, I found that in practice, since the distinction is already clear syntactically (you don’t confuse impl, fn and {, do you?), it works really-really well once you actually start using it, although it might not seem ideal or even good from the outside.
Isn’t that already how we ought to design unsafe abstractions? I think this sounds much more of an education problem. If, for example, people start by marking functions unsafe instead of trying to hide a piece of unsafe code inside the abstraction, that’s pretty bad regardless of how unsafe is formulated syntactically or semantically. I think this is something that needs to be in the culture, not in the language.
Of course, today we can’t do what you suggested in the 3rd bulletpoint:
because this would turn the entire function body into unsafe. But then again, that’s ~trivial to achieve by one (unfortunately, breaking) change, if unsafe fn just stops automatically considering the entire body to be an unsafe block. I would absolutely welcome this change, because I tend to try minimizing the scope of unsafe blocks myself, in order to reduce the number of expressions I have to reason about without the compiler’s aid.
I beg to differ here, too… I think that is somewhat of an exaggeration, I do not think that the unsafe situation is that bad, or that it’s bad at all. I have seen a lot of bad unsafe code in the wild. (I always grep for unsafe in any crate I plan to depend on, because I have trust issues.) I still think most of it didn’t have the “hard to write and read” problem. I don’t really remember asking myself, “why is this unsafe”? Instead, they had the “it shouldn’t even have been unsafe and could have been written differently” problem.
In other words, Rust programmers should already approach unsafe code gradually, instead of just jumping right in, and it is already possible with today’s unsafe syntax and semantics. I think a much more significant issue around unsafe is, again, that of documentation and culture. I am a former C and C++ programmer myself, and I see several other C and C++ programmers coming to Rust, then using unsafe on ecery second line in order to avoid having to learn the type system, or for “optimizations” which really should be written in a different, safe way. I often feel an urge to yell “That’s not what unsafe is for!” at them. (I usually don’t.) This is something that might be improved by better communication and docs, but ultimately that wouldn’t completely solve the problem either, as there will always be users who just abuse the language, as it is powerful enough to be abused.
Finally, I’d like to provide two alternatives to the concrete syntax you proposed.
First of all, I do like the library-based prototyping approach. I strongly prefer implementing features in libraries instead of incorporating them right into the core. My biggest fear about adding to the core language is that it shifts the entire language and ecosystem towards a very domain-specific paradigm. Hyperbolically speaking, if we unsafe(pre) and unsafe(post) today, we will eventually end up being a contract-based, dependently-typed, pure functional language which is only suitable for automated theorem proving, and has the quintessence of Coq, Agda, Idris, PROLOG, and Midori C# embedded in its macro system.
In all seriousness, everyone’s favorite niche feature/improvement could be in Rust, but then it wouldn’t solve the C++ bloat and complexity issue anymore…
Having said that, the first alternative is, I believe, a subset of your proposal. I suggest we do not touch the unsafe keyword, but we add the UnsafeData type and then use it in return position only. In this model, unsafe fn would be the equivalent of unsafe(pre) fn, and fn foo() -> UnsafeData would be the equivalent of unsafe(post) fn.
So, why is this good? Well, today many programmers associate unsafe fn with “unsafe to call because it does unsafe things”. This is a good first mental model, but not exactly accurate — it is partial towards assumed/unsafe preconditions. In particular, the intrinsic feeling about unsafe { some_unsafe_fn() } is that the unsafety is over once the function returns. The thinking could go like this:
- Oops, look, an unsafe function! I need to be prepared to call it correctly!
*deep breath*
- Call!
-
*phew* Finally, it’s over! I don’t have to think about it ever again!
However, this doesn’t take those effects into account which can manifest after the function returned. If, however, the function returned an UnsafeData<T>, this would be somewhat clearer.
(Eventually, if deemed really-really necessary, or a lot superior, fn foo() -> UnsafeData<T> could even be turned into fn foo() -> unsafe T or something, for syntactical symmetry…)
The idea for the second alternative comes from the safe/design-by-contract supporting variant of C# that I just mentioned, the implementation language of the Midori OS. If I recall correctly, instead of core language syntax, they extended annotations in order to denote preconditions and postconditions. Rust could do the same! Instead of adding more keywords (which also look really ugly in my humble opinion, by the way, but I digress), why don’t we just add two attributes as well? Something along the lines of:
#[precnd]
unsafe fn foo(arg: T) {
…
}
#[postcnd]
unsafe fn foo() -> T {
…
}
This would remain 100% machine-readable as well as human-readable, and since it’s an attribute, it would be much easier to extend in the future, without requiring further radical changes to the core language. It could even be used to encode additional pre-and postconditions, which are unrelated to unsafety, so I believe it is also much more general.
It also has the additional advantage of containing the good parts of both worlds, I think. Namely, I love how today’s simple one-keyword unsafe catches the reader’s attention, but doesn’t look uglier than e.g. pub fn — it is clear but not disturbing. Compare with Java’s public static final volatile synchronized iAlreadyLostTrackOfTheFuncName()… brrr!)
With this attribute-based approach, there would be a hierarchy: functions would be unsafe, that would be the first level; and the second level would be the attribute, which refines how they are unsafe. (This would also mean that it could be easier to implement in a backward-compatible manner. Once custom attributes and related improvements to the attribute system land, a compiler which doesn’t understand the attribute could just ignore it, or maybe have it “backported” as a no-op (?). But I went off on a tangent with this too much already…)
What do you think about the alternatives?