I didn’t intend to go three months without a blog post, but there’s been… a lot going on. This one is about the Rust programming language, and I hope to make it the first of many.
When the COVID-19 pandemic hit and I found myself with more time at home, I decided to look into a language that I had heard a bit about, and was curious to explore. I was intrigued by Mozilla’s effort to help create a modern programming language that makes it easy to write fast, secure, and stable code. It is often described as a systems programming language, which I tend to associate with embedded systems or core software libraries, so I wasn’t sure if I would find an immediate opportunity to use Rust in one of my own projects; however I soon found that Rust is useful for all kinds of things. I started using it in a financial desktop app I’m tinkering on, for which it has been very effective, and I would not be surprised if Rust eventually finds its way into many server applications.
What is Rust?
Rust is a modern programming language that uses explicit memory management and minimal runtime, similar to C and C++, while providing a lot of features that facilitate the writing of fast, stable, and secure code. In some ways, Rust feels like an answer to the ways in which C or C++ is painful, but one that doesn’t require the sacrifice of performance or control.
The best way to learn about the concepts behind Rust is to read the official Rust Book, which gives a very good intro to programming in Rust. Below, I’ll touch on some of the features and concepts in Rust that I think are noteworthy, particularly coming from a C++ background.
Rust Concepts
Discriminated Unions
If you’ve written programs in C or C++, you may be familiar with the concept of a discriminated union, which is essentially a bucket that you can use to store something of any one of a predefined set of types,— and you can query what type it contains at runtime. You may also be aware that C++ only recently gained built-in support for them. Before C++17, you had to either combine an enum
with a union
inside a Union-like class, store them together in a struct
, or use a third-party implementation like Boost’s variant. Rust has this capability built-in as part of its enum
type. Enums in Rust are similar to enums in C or C++, except that each possible value within an enum can have a type associated with it, which allows you to attach data of that type to the enum along with the enum value itself.
I like Rust’s first-class support for “discriminated unions” because they map well to a concept commonly found in most systems: A thing that can be one of a finite set of possible things. For example, say you want to represent a JSON value in Rust in a way that is easy to work with: a JSON value can be an object (which maps strings to more JSON values), an array (of JSON values), a string, a number, a boolean, or null. The need for adding new value types in the future is highly unlikely. In an object-oriented language, you may be tempted to represent this with a class hierarchy: a “JSONValue” parent class with subclasses representing each of the possible value types. It is indeed achievable, but it’s harder to work with without writing a significant amount of boilerplate code. In C++, the variant
type vastly simplifies the problem, though it is only available in projects that use C++17 or later, or the Boost library. To show what this would look like in Rust, I’ll just use the definition of a JSON value provided by a popular JSON library for Rust, serde_json:
pub enum Value {
Null,
Bool(bool),
Number(Number),
String(String),
Array(Vec<Value>),
Object(Map<String, Value>)
}
This says that something of type Value
can be a Null, Bool, Number, String, Array, or Object. If it’s a String, it has a String
value attached to it, and if it’s an Array, it has a Vec<Value>
value attached to it, etc. If you wanted to print out a description of the type for a certain JSON value, you’d use a match
statement, which is similar to the switch
statement found in many languages, but in the case of enums, each arm of the match statement also has access to the correctly-typed data attached to the enum. So you could write something like this:
pub fn print_info(val: Value) {
match val {
Null => { println!("The value is null!"); },
Bool(b) => { println!("The value is a boolean! value = {}", b); },
Number(n) => { println!("The value is a number! value = {}", n); },
String(s) => { println!("The value is a string! value = {}", s); },
Array(a) => { println!("The value is an array with {} elements!", a.len()); },
Object(m) => { println!("The value is a map with {} entries!", m.len()); }
}
}
So easy! This function takes in a value, and the correct arm of the match statement is executed corresponding to the type of value that is stored in val
, giving access to any data stored with that enum value at the same time. Match statements are powerful in another way, too, which I’ll get into later.
Alternative to Inheritance
With the rise of C++ and Java as dominant programming languages, object-oriented programming has became a standard concept in software development. Every software engineer who has learned an object-oriented programming language should be able to talk about how inheritance works, and how it can be used to provide helpful things like interfaces and default behaviors for sub-types. One challenge with object-oriented programming, though, is that inheritance can be easily misused. It’s very handy to create a class hierarchy where each class defines the common functionality and interfaces of the subclasses that inherit from it, but class hierarchies can be hard to adapt to new changes that you introduce into your program over time, and the interface of a class higher up in the hierarchy can explode in size as the complexity of the inheritance tree underneath it increases. Interfaces are much more useful for describing common characteristics of objects because they are composable, (that is, a class can implement multiple interfaces, and two classes don’t have to be related in order to share the same interface.)
Rust essentially abandons the concept of inheritance in favor of a system called traits. In Rust, a “trait” is essentially an interface, which defines a set of functions that can be invoked on something with that trait. If a trait is implemented for a type, those functions can be invoked on that type, and that type can be used wherever that trait is called for. Traits can provide default implementations for the functions they declare, but there’s no way for one type to “inherit” its implementation of a trait from another type. This traits system provides the benefits of interfaces that object-oriented programming languages provide, but without the common pitfalls that arise under a general-purpose “inheritance” system. If you’re used to object-oriented programming languages, Rust’s traits system might require a bit of adjustment, but I think it’s an excellent paradigm in which to write software.
Static Analysis
One of the unique and incredible things about the Rust language is the static analysis that is built into the compiler and enables all kinds of great language features. Here are a few:
Match statements are required to be complete: I said I’d come back to another way that match statements in Rust are powerful. In Rust, match statements enforce that all possible cases are handled. That means you can write a match statement to handle each one of the possible types that could be stored in your enum without thinking, “but what if I add a new enum value and forget to come back to this match statement and handle the new case?” The Rust compiler absolves this common worry and makes it possible to handle enums with match statements that won’t be prone to breaking in the future.
Move semantics by default: One of the great features of Rust is how it tracks the lifetime of variables beyond simply knowing the lexical scope in which they were declared. Basically, the Rust compiler will prevent you from using a variable after the end of its lifetime. This enables some cool features like making variable assignments and function parameters use move semantics by default while still being safe. Use of move semantics prevents unnecessary copies, which can be a performance boost; but in C++, the compiler doesn’t enforce that you don’t use a variable after it has been moved. If you do this, you will create a bug in your code that results in undefined behavior! The Rust compiler, on the other hand, knows that a variable’s lifetime ends when it is moved, so it can use move semantics by default, and will prevent you from referencing any variable after it has been moved.
References have lifetimes: Along with tracking variable lifetimes, Rust also associates lifetimes with references. This is an incredibly powerful but sometimes painful feature to deal with in Rust. I think the benefits are worth the cost, though. Basically, a reference type has a lifetime associated with it, so even if you create a reference to a variable and return it from a function, pass it around, etc., Rust can still make sure you never use that reference after the end of the lifetime of the variable it refers to. Note that this check is done at compile time, meaning Rust will automatically root out dangling reference bugs as part of the standard compilation process. This mostly eliminates the common C++ experience of getting a program to compile, only to see it spit out “Segmentation fault” and crash when you try to run it. This means that if a Rust program compiles, you can be fairly confident that it is free of memory bugs, which is incredibly valuable!
Conclusion
There are many more notable aspects of Rust as a language, but these are the top ones in my mind. In many areas, Rust takes a different approach from languages you might be used to, but after writing a few programs in Rust, those differences feel like they were informed by a wealth of experience with designing and using programming languages. Also, it’s great to see a language that builds greater static analysis capabilities directly into the compiler. I made a lot of comparisons to C++, and I think Rust is likely to be most immediately appealing to those who use C++, but I wouldn’t be surprised if Rust finds a home in a broad variety of applications and platforms. For me, so far it as worked incredibly well for building the core of a hobby desktop application project, and I’m finding uses for it in my work now, as well.
If you would like to lean Rust, I would highly recommend reading the Rust Book, and then picking a small software project that you can implement in Rust, which will help you to develop thoughts on best practices and work through solving some common programming tasks within the paradigm of Rust.
This is a companion discussion topic for the original entry at https://saltytron.com/posts/2021-01-17-first-rust-post/