Sunday, January 26, 2020

Thoughts on Rust

I've recently spent a few days learning to program in Rust, and thought I'd write down my thoughts so far. I used programming contest problems as away to get practical experience, which probably biases things somewhat. For example, I didn't look into multi-threading, testing, or see very much of the standard library.

References

This is obviously the stand-out feature of Rust. It seems like a nice idea, and eliminating use-after-free, double-free, null pointer dereferences, and many forms of memory leak sounds great from a C/C++ perspective. While modern C++ makes it much easier to manage ownership safely, it doesn't really address borrowing.

The mutability rules also sound like a huge win for safe concurrency, but I haven't looked into any of the details.

The one thing I've found non-intuitive is that it seems sometimes references are implicitly dereferenced (e.g., you can add two references to integers) but in other places they aren't (e.g., adding in place to a mutable reference to integer).

I haven't really tried using references very heavily: my contest coding style tends not to use a lot of pointers anywhere, preferring standard library structures like vectors.

Types

I found it really annoying that there is no automatic coercion between integer types. What's worse, the typecast operator ("as") doesn't follow the same rules as arithmetic operations, namely panicking on overflow in debug builds. It's also annoying that indexing requires an unsigned type. If you have an index (of type usize) and an offset to it (of type i32, possibly negative) it is a real pain to add them to produce a new index.

The type inference also seems like spooky action at a distance: I'm all for omitting the type when declaring a variable and inferring it from the initialiser, but inferring it from usage elsewhere in the code takes some getting used to. It leads to weirdness where changing/removing some apparently unrelated line of code can cause a compilation failure, or even silently change the type of a variable. Given that the indexing requires unsigned, I worry that some variable that might need to store negative values could end up unsigned without one being aware of it just because of an inference chain from an index.

Overloading

Coming from C++, it's disappointing to have no function/method overloading, not even default arguments. It leads to having to invent names for lots of variants of basically the same function. And unfortunately I don't think it can be easily fixed, because of the aggressive type inference: overloading uses the types of arguments to select a function overload, but type inference uses the types of formal parameters to infer the types of arguments. So as soon as you add a new overload, you're reducing the power of type inference and potentially making old code no longer valid.

Traits and generics

While it took a bit of getting used to when coming from traditional OO languages like C++ and Python, I quite like the traits system. It's a lot better than Java interfaces, because you can have default implementations, and you can define new traits and bolt them on to existing classes. It's a much cleaner way to put type bounds on generics than SFINAE in C++, and seems like it probably has many of the same advantages as C++ concepts (not that I've looked at the latest incarnations of C++ concepts).

I also really like the way traits allow you to use a trait with either static dispatch (ala C++ templates) or dynamic dispatch (ala C++ virtual functions), rather than forcing the API designer to choose one or the other. I suspect there are also some performance advantages: in C++ when using a polymorphic class, you pay for a virtual function call even when the exact class is known to the programmer, unless the compiler can also determine that it is known or the programmer uses final classes/methods. With Rust there is no inheritance, so an object whose type is a concrete struct will have exactly that type.

The one thing I disliked is that methods defined in traits end up looking the same as normal methods on the class. If you're not aware that a particular method is actually provided by a trait (or you don't know which trait), it can mysteriously fail to exist if you forget to import the trait into your namespace.

The generics system still has some way to go before it catches up to C++ e.g. there are no non-type template parameters (although it's being worked on), no variadic templates, and from what I could see, no real specialisation to override more generic implementations.

Enums

I think this is one of the more under-rated features of Rust. Rust enums are really discriminated unions (ala boost::variant), with first-class language support. I particularly like the "?" operator: given an expression of type Result (which holds either an "ok" or an error), put a "?" after it, and if it is an error it will immediately return it from the function. This means that although Rust doesn't have exceptions in the same way as C++/Java/Python, one can propagate errors with very little boilerplate, and with the benefit that it's explicit where early returns might happen. It certainly looks nicer than what I've seen of Go.

Performance

I translated a few contest solutions from C++ to Rust, and was pleasantly surprised by the performance: generally faster than the C++ code (which might just be because Rust uses LLVM, which is pretty good and often better than GCC, particularly since Codeforces uses a 32-bit GCC). The one area where it was much worse is writing large outputs to stdout, because Rust always makes stdout line-buffered while GCC makes it fully-buffered when it is not a TTY (which is a known issue in Rust). After wrapping a buffer around stdout the performance was good again.

Summary

In general I like Rust, although I think it still needs a few years to mature before I'd consider abandoning C++ for it. While Go seems to be getting all the popularity, I think a high-performance language really needs to avoid garbage collection and provide a strong compile-time generics system.