A JVM in Rust part 8 - Retrospective

This is the last post in my pretty long series about my toy JVM in Rust. In this post, I will do a sort of retrospective, discussing what went well and what didn’t, with Rust and with the project itself.

Rust retro

The good parts

Sum types

I have blogged about it before, but it bears repeating - Rust sum types are fantastic. Look at this beauty:

/// Possible execution result of an instruction.
enum InstructionCompleted<'a> {
    /// Indicates that the instruction executed was one
    /// of the return family. The caller should stop
    /// the method execution and return the value.
    ReturnFromMethod(Option<Value<'a>>),

    /// Indicates that the instruction was not a return,
    /// and thus the execution should resume from
    /// the instruction at the program counter address.
    ContinueMethodExecution,
}

The fact that alternatives can have (different) payloads makes them very powerful and expressive for modeling. And, of course, Rust has powerful pattern-matching features for working with enums.

Of course, most functional programming languages had this for ages, but Rust is the first “mainstream” language I have used with this feature. Of course, now this pattern is also making its way into Java, via sealed classes and record pattern matching.

The question mark operator ?

Most function calls can fail. Rust models errors with the sum type Result, which is a very nice approach as I have discussed before. The thing I love the most about this approach, though, are two:

  • the fact that everything uses the same approach - the standard library, and any crate you pick on the internet;
  • and the ? operator, which makes fail-fast simple to implement.

For example, the current value stack might be empty while executing a method, and thus pop could fail. I have simply written this:

let result = self.pop()?;

If pop returns a “stack empty” error, the question mark operator will return and propagate the error to the caller. Otherwise, it will unwrap the value from the returned Result. It is super nice to use in practice - it is not as “hidden” as exceptions, but almost effortless.

The compiler errors

rustc, the rust compiler, famously has great compiler errors, for example:

 Compiling playground v0.0.1 (/playground)
error[E0596]: cannot borrow `sausage` as mutable, as it is not
declared as mutable
 --> src/main.rs:9:5
  |
8 | fn belka_takes_bite_off(sausage: String) {
  |                       -------- help: consider changing this to be
mutable: `mut sausage`
9 |     sausage.truncate(5);
  |     ^^^^^^^^ cannot borrow as mutable

error: aborting due to previous error

For more information about this error, try `rustc --explain E0596`.
error: could not compile `playground`.

To learn more, run the command again with --verbose.

The error messages contain a lot of details, and sometimes even a suggestion about how to fix the errors!

Cargo and the crates ecosystem

Cargo, the rust package manager, is pretty fantastic. Adding a library is trivial: cargo add xxx. Building and testing are just cargo build and cargo test. No craziness like in node-land, where each project can have a million different tasks and ways to do things (and don’t get me started on yarn). I love having one good standard. In my experience, Cargo just works™️.

There are also a lot of good libraries for Rust available - when I had to do some not-so-common things, like reading a Cesu-8 (a legacy Unicode format used in the class file format, from a time when UTF-8 was not yet the only standard you should learn) encoded string, I could easily find a library that does that.

Another example: I needed to store some bookkeeping information for each allocated object, such as its length. To keep things compact, I wanted to use just one word and really squeeze the bits. Rather than doing bit arithmetic by hand, I found immediately the crate bitfield-struct.

Rustfmt

Similarly to Go, Rust has rustfmt, the code formatter. I love having one uniform style for all code in a given language - there’s no discussion about how to format the code, no styles that differ from one project to the next, and no “whitespace” or “reformat” commits in the history. I have set my editor to invoke rustfmt on save, and I am happy.

Clippy

Clippy is the Rust linter, and it is quite useful. It has taught me a few things, and I have never had a false positive. I recommend enabling it and listening to it when working with Rust.

The not-so-amazing parts

The borrow checker

Rust is famously not an easy language to learn. A lot of its complexity comes from the ownership mechanism and the borrow checker rules. After writing some eight thousand lines of non-trivial code, I do feel like I have a reasonable understanding of it, but it was pretty painful at the beginning.

The ownership mechanism is the differentiating feature of Rust, and it is pretty useful in practice, but it does make a lot of things more complicated, such as self-references or building a struct where one field is a reference to some parts of another.

I also used pointers and unsafe quite a bit, given that I was building something rather low-level like a garbage collector. They are, in my opinion, pretty nice to use in Rust, even if a bit verbose.

I have picked up a couple of tricks for dealing with the borrow checker errors. The first is that, often, a simple .clone() can save the day.

The second one is trying to be explicit about lifetimes. When trying to understand compiler errors about reference lifetime, making the lifetime explicit made a lot of things clearer for me. For example, writing explicitly things such as:

fn get_code<'a>(class_and_method: &'a ClassAndMethod)
    -> Result<&'a ClassFileMethodCode, VmError> {

makes the compiler complain about the 'a lifetime, rather than about an anonymous one, and this can make errors clearer.

Error handling

As much as I love error handling with Rust, some things bit me. In particular, I am far more used to languages with exceptions that give you a nice stack trace whenever there’s an error. In Rust, you do not get a stack trace for free - which makes sense for a system language, but it still made debugging a pain.

This was compounded by an initial bad decision that I took in the beginning, where I used a generic VmError::ValidationError for any sort of unexpected situation - empty stack, invalid types, and so on - made debugging errors during the implementation of the various instructions very painful, as there was no simple way to get any information about where the error happened. This is exactly the sort of issue that things like the anyhow crate solve, with its context feature - though of course it can be implemented without any extra crate. My problem is that I underestimated this, and it led to a lot of pain. It is a thing that I think needs to be considered quite early in a project, and it seems to lead to a bit of boilerplate.

Other notes

Compilation times

The compilation times were - honestly - mostly fine. It helps that I wrote the vast majority of the code on an M1 Pro, though. Rebuilding my own code is close enough to be instantaneous that it wasn’t really noticeable, but doing a clean build, which rebuilds all the libraries, takes about 12 seconds.

I have used both Visual Studio Code and IntelliJ to write this project, and both showed the compilation errors or clippy warnings very fast on save - again, fast enough to not be annoying. rust-analyzer does a great job with the suggestions.

The GitHub actions pipeline is generally somewhere between one and two minutes, thanks to caching - pretty great.

Testing

Maybe I am just too used to Java projects, but I still find having the test code in the same file as the source is a bit strange. Nevertheless, I do not have any particular problem with the testing system built-in the standard library - it works well. I have ended up using cargo nextest just to have the integration tests of the VM, which read the whole rt.jar (60+ MB), run in parallel.

Conclusion

I would say that I am very happy with Rust. Most of my problems were due to my ignorance of the language or its best practices. I always felt pretty productive, even though the refactoring tools aren’t really up to the Java or Kotlin standards, except when I had to really understand the borrow checker rules. As I have written before, I am not sure Rust is the best choice for all projects, but where performance and stability matter, it certainly is a great and mature choice.


RJVM retro

I set out to build this thing as a way to learn Rust, without having to deal with networking, too many libraries, or async.

I started by thinking “I will implement a .class file parser”, and when that was done, I thought “Hmmm I wonder how complicated it is to build a tiny VM that can do some arithmetic”. That turned out to be easy. The next step was adding control flow statements, and that was ok too.

But then I decided I wanted to implement strings, and that led me into a deep rabbit hole because I had to actually use the real OpenJDK classes… And, to make that work, I had to fix many bugs in my code and implement a ton of missing bytecode instructions. In the end, the implementation of string literals still feels a bit “hacked together”, honestly.

After that was done, I decided “I would love to do exceptions and a GC, and then I will stop”, and so I did. Exceptions were not that complicated… but the GC was, because I ended up having to restructure a lot of code to make it work. And it took me three attempts before I settled on an implementation that worked.

I would say that some of the best decisions I took were:

  • the integration tests are simple and effective - I have not bothered with a lot of unit tests that check all the error conditions, as one should in a real production project, but after all it was a toy project - you are allowed some shortcuts; 🙂
  • the Value enum has been very simple and nice to use. In a real interpreter/VM, where performances and memory usage matter, you would probably use something like pointer tagging or NaN boxing, but in my toy, I relied on Rust’s facilities and got a lot of help from the compiler for free;
  • I am pretty happy with the “switch on bytecode instruction” that I have implemented in CallFrame - I think it’s quite readable and simple.

Of course, there are quite some things I’m not happy about. The main ones are:

  • I have reused the same data structure for the class parsed from the file, and the class loaded in the VM. I should have used a simpler structure for the loaded class, transforming between the two during class initialization;
  • the class loader is a mess and not anything like the JVM says it should be;
  • most errors are only really implemented as Rust enums and not as JVM exceptions, which is a bug;
  • arrays are a bit patched together - some things that should work do not (like all the java.lang.Object methods) and in general they are treated often as a special case;
  • the way I have implemented static fields is really a horrible hack!

Overall, as a first attempt to build a somewhat realistic VM, I am happy, but it can definitely be done better.

Conclusion

Finally, I am extremely happy with how this project turned out, for many reasons - one of the biggest is that I have actually finished a side project, for once! 🎉 I often start a new project, get to a point where I think “ok I know how to build the rest”, and then stop because the fun is gone. This project had one big thing going for it - at the beginning, I did not really know how to build all I wanted to build, so I had to keep going until the end of it. 😅

I had a lot of fun building RJVM and managed to learn a lot of new concepts. I also have realized how little I know and how complex a real, production-level, interpreter or VM, actually is.


Learning stuff, having fun, and even getting to be number one on Hacker News for a few hours. Can’t ask for more from a side project. 😊

I want to thank you all for reading this (long) series, and I hope you will keep following my blog in the future.