Notes on a Smaller Rust
boats.gitlab.io
boats.gitlab.io
Everytime that I have turned away from Rust, despite trying half a dozen times is due to its super busy and alien looking syntax. I just can't parse the code.
https://crystal-lang.org/reference/overview/http_server.html
I have heard this a lot, and I am not sure I agree. I agree that the limiting factor for how long a project takes to finish is never going to be how fast you can type, but when I am really in flow I am actually typing pretty quickly, and I don't really like more friction when it's not necessary.
Of course it's a matter of taste, but the argument that it's english-like never made sense to me either. There is no english sentence construct with "do ... end" or "wile ... end".
Patricia Asa has an excellent presentation[0] on how she tried to learned C# and one of the biggest things she discovered was these bad cross-over abstractions held her back.
I think there is such a thing as liking what's familiar, but there are also design decisions which are simply easier to read or easier to parse. Another one would be semicolons: I have never missed semicolons one bit in a language where they're not required.
I dutifully wrote semicolons for years, but just a short stint of Scala and I don't like using them anywhere now.
I'm trying to learn Rust, working through various programming exercises, but it looks and feels very foreign to me.
One example is the closure syntax. Compared to Go where closures look just like function definitions, closures in Rust look needlessly complex.
I also always find myself wanting to type mut &a instead of the correct &mut a. The former just seems more obvious to me, a reference to a is &a. Mutable a is mut a. So a mutable reference must be mut &a.
Then the lifetime syntax, I don't even know what to think of that, but that's probably more because I'm struggling asking myself "Should I use a lifetime here, or am I doing something else wrong that is preventing the compiler from figuring it out."
Maybe these things make more sense to people coming from C.
In the same vein I think Typescript error messages are absolutely horrific. Furthermore, I keep seeing people who start using Typescript to "make it like my preferred language xyz".
Coincidentally these devs 1) hate Javascript 2) end up writing a massively over engineered OO big ball of mud
These reasons are why I avoid Typescript where I can.
Rather, I think the problem is that the syntax is 100% fine in isolation, but has some issues when taken together with experience in other languages — when you skim the code, some parts of it beg to be looked at like C++, while other parts push you to read it like a functional language. Unsurprisingly, neither fully works, which makes the experience jarring. Once you accept that Rust is its own language with its own rules and stop trying to borrow (hah!) from your experience with other languages, it feels much better.
You literally just got a user experience report claiming that it is.
There's no winning over everyone. Of course Rust's syntax will be completely jarring if you're coming from Crystal or Ruby.
In my personal experience it's a little jarring at first and then it completely goes away.
* The need to put :: before <> in expressions for generic parameters, but not in type signatures.
* Using :: instead of just . even though most of the time the naming convention will delineate the difference anyway
* Error handling with futures
* Self-referential structs
* Passing a shared pointer through multiple move closures
IMHO the frustration involved in these things is not typically worth being more explicit. The :: thing may seem a bit pedantic, but it is simply more physical effort (Shift-colon-colon vs dot) and creates more visual noise.
If/when all of the things above are addressed, I think Rust could give Python a run for its money.
I don’t mind the syntax because I find it explicit enough, but terse enough in certain cases. It’s similar but different and so it rhymes with my past in C++, but it’s not the same.
I'm still not so experienced though so maybe it gets better.
There are many things like this in Rust where if you just follow the compiler errors until you can make it happy, things will end up really convoluted and baffling. They'll also probably not perform as well. To really use Rust you have to absorb what's actually going on underneath its protections, and not just the errors that surface from them. This isn't easy, and is probably the dividing line between people who stick with it and people who decide it's not for them.
You get about 50% of this picture for free (thinking in terms of stack, heap, references) if you've done C/C++ before. The other 50% is completely unique to Rust. But the key is to read the Book, follow guides, etc. Rust isn't really something you can learn just by hacking it out on your own, unfortunately.
* Algebraic data types
* Resource acquisition is initialization
* Aliasable XOR mutable
I think it can help not only to design a Smaller Rust as discussed here, but also to grasp the key design points of this language which is a bit daunting.
> the necessary components ... to make imperative programming work as a paradigm
You know, before Rust imperative programming just didn't work. Didn't work as a paradigm, even!
But one of the primary motivators for functional purity is to avoid unintended side-effects.
In pure FP, You never even have to bother your mind with unexpected side-effects because there are no side-effects.
Rust instead gives you the vocabulary to carefully articulate intended side-effects, preventing all other, unexpected side-effects at the same time. So you can colonize the wilderness, instead of avoiding it altogether, and for that you get to skip the overhead of treating everything as immutable. Not that this is an indisputable improvement for every use-case, but it's a novel tradeoff and one that is definitely preferable in many domains.
Apparently the Linux kernel, and all code written before 2010 is just completely random!
The Linux kernel is well known to be completely random when considered as a C program: building something useful out of it relies on a particular set of flags and ad-hoc implementation details of GCC (e.g. -fno-delete-null-pointer-checks).
That makes it esoteric, not random. If the Linux kernel really did produce "random output" there is no way it would serve as the backbone of the global computing infrastructure, the financial system etc.
To the extent that Linux is useful, it's not written in C. The particular binaries produced by GCC with particular flags have particular behaviour, more or less, but considered solely as a C program in terms of the C standard (i.e. behaviour on the C abstract machine), Linux does produce random output.
[0] https://lwn.net/SubscriberLink/793253/6ff74ecfb804c410/
[1] https://lkml.org/lkml/2018/6/5/769
[2] https://bugzilla.redhat.com/show_bug.cgi?id=638477#c129
[3] http://lkml.iu.edu//hypermail/linux/kernel/1407.3/00650.html
That's from the previous paragraph. So yeah, out of context it's a bad quote, but in the context it's a little less hoity toity.
Hard disagree. Rust made conscious design decisions (i.e. limiting type inference) to enable local reasoning, and exceptions are non-local.
It's not just subclassing.
It also breaks with higher-order functions, e.g. what is the throws clause of a map() method on lists? There's really no good answer in Java, so what people do in practice is to just re-wrap into a RuntimeException, but that leaves callers with a problem: Calling code cannot match on exception types using a catch clause on the checked exception -- it has to check for a RuntimeException with an embedded checked exception. This is disastrous for ergonomics and real-life use of checked exceptions.
The Result/Either method leads to its own ergonomic problems, but I think they could be solvable with support for row-types and (anonymous) union types. At least in Haskell, the Either method leads to a proliferation of FooError types whose only purpose is to wrap other error types. Well, either that or you accept that every little thing can return in a generic 'AppError'.
interface Function<T,U,Err> {
U apply(T value) throws Err;
}
interface List<T> {
<U,Err> List<U> map(Function<T,U,Err> f) throws Err;
}Right, whoops. My bad!
> In any case, Java does not have any of those, and this is what makes checked exceptions impractical to work with
Absolutely. I was approaching it from the standpoint of "what would I change about Java's type system to make checked exceptions usable?".
> Rust has sum types but I don't think it has either inclusive unions or never types.
Rust is working on stabilizing a true never type[0], and until then you can emulate it with an empty enum.[1] There is also a RFC for unions, but that doesn't seem to have gone anywere.[2]
[0]: https://github.com/rust-lang/rust/issues/57012
[1]: Such as https://crates.io/crates/void which also provides helpers for safely coercing `Void` `Result`s
The point wasn't that these things couldn't be solved -- they could, at least in theory[0] -- but that the current practical limitations of Java[1] are such that the interaction of higher-order functions and checked exceptions make checked exceptions a really bad idea.
[0] At least I think so... given anonymous or adhoc sum types, etc.
[1] Interestingly, this is not a JVM-wide issue. It's just the Java compiler that imposes these restrictions, for example: You cannot "catch" a checked exception which hasn't been declared at least one method in the "try" scope. This makes sense at first glance ("cannot happen"), but means that re-wrapping by higher-order functions must entail extremely error-prone inspection of any exception by calling code, etc. etc. It's a huge mess.
This only works to the first order. If you have functions-that-call-functions-that-call-functions, you end up not being able to write(!) the correct throws clauses in Java.
As you make it easier to propagate the error case to the caller, you asymptotically reach checked exceptions.
I think the main thing you'd gain with checked exceptions is being able to list the possible failures in the function signature, instead of having to create an enum type. But I guess I'm not sure it's really worth the tradeoff.
If you do a lot of error handling close to the point of the error condition being found, the Rust / checked exceptions approach works well. For the kinds of applications that are written in Java, it's not the case. Most error conditions need to be propagated and the action in flight aborted.
That's exactly what the '?' operator does. It's just as ergonomic as Java exceptions, and it doesn't hide the places where errors can be returned and the execution flow can be diverted.
fn foo(i: i32) -> string throws IOException
let x: Vec<i32> = ...
let y = x.iter().map(foo).collect()
What's the type of y ? There are no good answers here: if it's Vec<string> then where did the errors go? If it's Vec<Result<IOExecption, string>> then the user is pretty confused about where the Result came from. You can't propagate the IOException through the map() call in the general case (it might be in a third-party compiled library etc.). In Java this is a compilation error and the reason checked exceptions are useless in practice.If you're interested in which item failed, ideally you'd be using a method that doesn't throw at all, and instead returns an error condition. I like how C# makes this clear, with Parse and TryParse on Int32. You use Parse when you want unwinding and abort behaviour including stack unwinding, and TryParse when you want to handle errors, and you don't use exceptions for expected errors, since expected errors are not exceptional.
(If the map is lazy, you'd expect the materialization in collect() to throw. But the return type wouldn't change.)
IMO, the kind of code you write on a daily basis is the primary determinant of your preference for error codes vs exceptions. If you need to handle error conditions frequently, you'll prefer error codes, because they're data, like all other data, and you can use the general compositional tools at your disposal to manipulate them. If you handle error conditions exceedingly rarely, and mostly just want to abort, unwind, roll back, go back to the main loop and log them, then exceptions are your friend.
I do not think there is a fundamental superiority either way. I think there are tools for purposes. I do think that Java's checked exceptions are half-baked; they're half-way into the compile time type system in a language whose applications are generally better suited to run time exceptions.
You'd have to use unchecked exceptions to achieve that though. There's no way to propagate the checked exception across that call, because there's no way to make map() generic over exception-or-not in a Java/Rust-like type system.
> If you're interested in which item failed, ideally you'd be using a method that doesn't throw at all, and instead returns an error condition. I like how C# makes this clear, with Parse and TryParse on Int32. You use Parse when you want unwinding and abort behaviour including stack unwinding, and TryParse when you want to handle errors, and you don't use exceptions for expected errors, since expected errors are not exceptional.
Result gets you that without needing to write two implementations of every method. You call unwrap-or-panic in the cases where error is not expected/handled, and handle it in the cases where you want to handle it.
Using magic language syntax like checked exceptions in lieu of type system expressiveness is not great, which is why Java's checked exceptions are a bit on the unpleasant side. You can't have one map() that handles functions with and without exceptions, because exceptions aren't part of the type system.
In Rust, exceptions are part of the type system, so you only need one map(). If you also add syntactic sugar for the types such as:
1: fn foo(i: i32) -> string throws IOException
2: throw SomeIOException
3: try a catch IOException b
To mean, respectively and approximately: 1: fn foo(i: i32) -> Result<string, IOException>
2: return Err(SomeIOException)
3: match a {
None => b
Some(_) => {}
}
... you can keep the simpler program code of exceptions and keep the ability to express exceptions first class in the type system.The Rust implementation (syntax I use might vary from reality) of async/await is just this sort of syntactic sugar over a type: "async fn foo() -> X" is sugar for "fn foo() -> Future<X>", and "await!foo()" is (kind of) sugar for "foo().then(rest of the function)".
The only really kind of disappointing thing about this is that exceptions and futures don't get unified: we get ? for exceptions and await! for futures. Both of them ultimately are continuations: exceptions bypass the continuation and return an error immediately, futures call the continuation only when their value becomes available. The value of using a unified interface to continuations (or monads, if you prefer) is that you can use ones that don't have a magic blessed syntax: parsers with automatic backtracking written as simple imperative blocks, non-determinism via iterators where the continuation is a flatten operation, etc.
I don't want to have to worry about whether the function "throws" or not at the point where I'm calling map - that's the whole problem of doing this in Java. What if the function that calls map is itself a generic higher-order function? Result's great benefit over exceptions is that it isn't a special case; generic functions work with Result just as they work with string.
> In Rust, exceptions are part of the type system, so you only need one map().
So what's the answer to the question? If there's only one map() I should be able to use it to map with foo; when I do, what do I get back?
> The Rust implementation (syntax I use might vary from reality) of async/await is just this sort of syntactic sugar over a type: "async fn foo() -> X" is sugar for "fn foo() -> Future<X>", and "await!foo()" is (kind of) sugar for "foo().then(rest of the function)".
Sure, and try!foo() does a pretty similar thing for Result. I think that's a better approach than exceptions, because you can see what's going on at the call site - having functions that "throw" and don't "throw" look exactly the same at the point of use is too magic/confusing IME.
> The value of using a unified interface to continuations (or monads, if you prefer) is that you can use ones that don't have a magic blessed syntax: parsers with automatic backtracking written as simple imperative blocks, non-determinism via iterators where the continuation is a flatten operation, etc.
Yeah, I find continuations too confusing to reason about but I really wish Rust would adopt some general-purpose syntax for monads ("do notation"). That would require HKT though, because you can't even form the type of a monad in the general case without that.
I'd agree with stepping away from guarantees about when allocations happen etc. But I think at the point where you remove those from Rust you really just have OCaml.
OCaml doesn't have break or continue though.
If you're talking about a language to write drivers or an operating system, etc in then a GC is just a big No. In that domain we don't even have Malloc/Free, let alone GC services. Having the overhead and normative lifestyle assumptions of _any_ kind of runtime is a big Nope.
Which ones do you mean?
Though it should be noted that Smalltalk provided a non-local return.
Can you do that sort of thing without a GC? Some functional languages use tailcall as their control flow primitive, but that's just a glorified GOTO.
I've found that Rust flows much more nicely when you just embrace an imperative style "by default". That's its native tongue; the functional stuff is more of an exception.
Those of us used to multi-paradigm languages have taught ourselves that imperative == bad (actual for-loops! egads!), but the thing is, Rust is specifically designed to make it not-bad. It gives you all these tools to carefully control and limit mutability, so you can do imperative stuff with much less fear. It takes getting used to but in my (limited) experience this mindset significantly reduces friction when working with Rust.
Expressions evaluate to a value, and semicolons take an expression, throw away its value, and evaluate to () instead. That's it.
The other behaviors you're talking about fall out of these semantics, but are also different than what you've said; functions evaluate to a value, so the final expression's value determines the value it evaluates to, but you cannot "implicitly return" from the middle of a function by dropping a ;, for example.
fn foo(x: i32) -> i32 { x * 2 }Thinking of them as implicit returns leads people to believe they can write code like
fn foo(x: bool) -> i32 {
if x {
5
}
// more code
and then they're confused when this doesn't work. if(foo = 5) {
...
}
They are easy to mess up when you're getting started, but they're also very easy to fix because they'll basically always result in a clear compiler error. If "builds failing" are annoying for you, you probably aren't using an IDE with inline errors, and with Rust you absolutely should be doing that or you'll have a bad time. if(foo = 5) {
...
}
You don't need the rust style expression/statement distinction to catch this. Many languages will catch this at compile time since "foo = 5" does not evaluate to a boolean.I know that it's relatively easy to catch this error in Rust, and it is at most a minor annoyance, but it still feels like a clear step backwards after working with languages which don't require semicolons at all.
In most C-like languages, this expression is valid: the whole thing evaluates to "5", which has a truthiness. Some "clever" programmers use this pattern intentionally in loops to save a line of code, especially in the C/C++ world. For example:
while(currentNode = currentNode.next) {
...
}
When currentNode becomes null, the expression/statement becomes falsy, and the loop terminates. I absolutely love the way Rust systematically disallows shaky syntax tricks like this.My point is, there are plenty of examples of languages that show that you don't need to add semicolons to almost every line of your program just to avoid this one potential error.
I think the only language I know of that disallows assignment in them is python, and it's not because assignment evaluates to something other than boolean, python is happen to evaluate the 'truthiness' of certain types. And funnily enough, they are going to add this back in to python 3 I believe, using a different operator.
Are you sure about Go? I just tried this in an online playground, and I get this error:
syntax error: assignment x = 2 used as valueI recently built a small prototype Entity Component System, and one of the big learnings for me was to what extent RAII is an anti-pattern with respect to performance. It's actually amazing how fast a modern CPU can be when you're not using it to allocate and initialize small blocks of memory at a time.
I understand the benefits of RAII in terms of bookkeeping, but it seems like there is opportunity for a new resource management paradigm which is more optimized for using computing resources.
Also the implementation of "initialize everything at once" in an RIAA paradigm is not necessarily going to be the most efficient possible implementation. For instance, I might want to do something like this (in a made-up language):
struct A { ... }
func init() {
let a1 = new A();
let a2 = new A();
let a3 = new A();
}
The most efficient way to implement this would be to allocate enough memory to store a1, a2, and a3 and then run the initialization method 3 times over that memory. But it's likely that instead the compiler will allocate a1, initialize a1 and so on with a2 and a3. Since the allocations are separate, maybe another thread takes precedence between the allocation of a2 and a3 and allocates some memory, so these values are not contiguous in memory, and I get worse caching performance when accessing them.In this toy example with 3 values it doesn't make aa lot of difference, but if you add up all these marginal costs in a large application it absolutely does cost a lot of performance.
Only if a1, a2, a3 are also freed at once. In which case, you can just put all three in a struct that is allocated as a single block. Rust doesn't yet support this very well in the general case, because its support for what C++ calls "placement new" and customized allocation in general is not complete or stabilized. But doing this in a more hackish special-cased way is already possible in many cases.
Yes exactly. This is meant to be illustrative of the case suggested in the parent comment, where all required runtime objects are known and can be initialized together at the beginning of execution.
edit: it may be possible to program around some of the worst performance pitfalls of RAII in many cases, but you will end up with very non-idiomatic code, which I would take as evidence that RAII is not ideal for memory performance.
> Aliasable XOR mutable
because it prevents eventual synchronisation (e.g. single writer, multiple readers of a "monotonic" data structure like a counter)... I'm not convinced that it's entirely necessary, nor how easily it can be proved safe, but AFAIK many garbage collection runtimes work using eventual synchronisation... Probably other algorithms as well!
Another thing that's not (AFAIK) covered by Rust's ownership system is flexible memory pools... where you can have proofs that some memory is accessible and pass those proofs around (whether at runtime, or just during compilation on the type system level)... although that might be entirely isomorphic to just passing around owned pointers to memory (pools), so maybe Rust does support it.
People with lots of experience with Rust, what scenarios do you need to use unsafe Rust for?
There's a variety of safe arena types that give allocation inside pools too: https://crates.io/search?q=Arena&sort=downloads
Presumably you'd still need some sort of synchronisation otherwise the memory will stay mutated just in one CPU's cache, you need to "flush" the updated memory so that other CPUs can then see it, but that's likely cheaper than executing the whole operation atomically... Basically, increments are fundamentally not atomic (i.e. composed of multiple separate operations) whereas reads (of a single word) are, so if all mutations happen from a single thread (assuming a sensible CPU architecture and cooperative compiler) there's nothing else that needs to be done to ensure atomicity, you only need to care about the "happens before" relationship.
But then I'm not a low-level programmer so the above is probably all wrong :)
A platform like the JVM disguises this because every read and write (even without 'volatile') use minimal synchronisation to avoid the worst aspects of the danger here. This ensures that programs that do it wrong (e.g. forget a 'volatile') are "only" incorrect, but not unsafe.
Thankfully it looks like they’re trickling in via Typescript/Swift/Kotlin.
I think languages similar to Rust but with less complexity will be a very cool space to watch going forward. All sorts of interesting language designs possible.
I’m working on a language that does “Rust-like” things, but unlike the article, what I am going for is the efficient memory management, in this case the inline structs (zero cost abstraction) and compile time memory management (automatic lifetime analysis). The biggest difference with Rust is that it is fully automatic: when ownership can’t be determined at compile time, by defaults it falls back to a reference count increase at runtime (in Rust this would just be an error). The advantage is zero annotations, and a language that can be used mostly by people that don’t even understand ownership. There may be ways to explicitly (optionally) enforce ownership in the future, for those who want more control.
Language: http://strlen.com/lobster/ Details on memory management: http://aardappel.github.io/lobster/memory_management.html
Yea, I like this.
Sure, but people wanting a smaller Rust might not care about the "real insight of Rust", but of the surface syntax, speed, apis, tooling (e.g. cargo), and so on...
I'd like a Rust like language that's GCed for example, and drops all the lifetime annotations and so on...
After all, just about everything else in Rust is in Haskell.
When I first learned about the borrowchecker I thought it was brilliant. Then I spent some time fighting it and I hated it. Then I learned to love it...until I got backed in to a corner and had to re-architect a project I'd been working on for months. Now I have mixed feelings about it.
So I agree Rust isn't Rust without the borrowchecker, but I empathize with all the noobs who want a rust without it because many people just want a language with some traction that fixes c++ past mistakes, and they end up getting a massive paradigm shift thrown at them instead.
So you may be see a rustscript at some point. Some people try to implement Python in it, which I think is very interesting: https://github.com/RustPython/RustPython
After all, replacing C/C++ is the raison d'être of rust.