Rust is a modern programming language with unique memory safety features that make it particularly suitable for systems programming. Its first stable release (1.0) was in 2015, and since then it's been gradually gaining popularity, especially for low-level programming and performance-critical applications. I've been wanting to learn Rust for a while, and this week I finally took the leap. In this post, I'll talk about how I learnt Rust, the distinctive features it has that separate it from the languages I'm most familiar with (Go, Python, JavaScript), and a simple project I built in order to solidify my understanding of Rust.
How to learn Rust
Some frequently cited resources for learning Rust are:
- The Rust Programming Language - the official Rust book, which covers the language pretty comprehensively
- Rustlings - a collection of exercises which help you learn Rust by fixing small code snippets
- Rust by Example - interactive, runnable code examples with explanations
I personally found Rust by Example the most helpful, as it meshed well with my hands-on learning style. It reminded me of the "Tour of Go", which was the primary resource I used to learn Go back in the day. However, I encourage you to pick the resource that works best for your own personal learning style.
If you prefer video resources, I also found freeCodeCamp's Rust course on YouTube to be a good resource, although I only watched a small part of it.
I also used ChatGPT extensively to explain concepts in-depth and help me understand compiler errors, refactor code, etc. Especially at the learning stage, the key is to not just let AI write the code for you - make sure your brain processes everything it says and that you understand it fully. Remember that AI can hallucinate too! So take everything it says with a grain of salt.
Distinctive features of Rust
Coming from a background in Go, Python, and JavaScript, several features of Rust stood out to me:
Syntax
Rust uses a double colon :: as a path separator for modules, namespaces, etc, unlike the dot . used in Go/Python/JS.
Ownership
This is a concept that is pretty much unique to Rust. Each value has exactly one owner, and the value is dropped when the owner goes out of scope. For example, if we have
let s = String::from("hello");
then s is the owner of the value String::from("hello"). If we then go
let s2 = s;
then s2 takes over ownership of this value from s - as a result, trying to reuse the variable s after this point will raise a compilation error.
Passing a variable into a function call will also transfer the ownership over to that function. At the end of that function, the variable will go out of scope and the value will be dropped.
fn main() {
let s = String::from("hello");
takes_ownership(s); // ownership moves into the function
println!("{}", s); // ❌ ERROR: s is no longer valid here
}
fn takes_ownership(value: String) {
println!("Got: {}", value);
} // value goes out of scope here → String is dropped
Returning a value from a function also transfers the ownership to the outer calling function.
The whole point of ownership is to guarantee memory safety in Rust - it removes the need for a garbage collector, and prevents problems such as memory leaks and dangling pointers. To avoid transferring ownership, you can "borrow" a value instead. This is essentially done by passing in a reference to the variable rather than the variable itself. For example, if we have
let s = String::from("hello");
foo(&s);
then ownership of s is NOT transferred over to foo, and as a result, we can still use the variable s after the call to foo.
By default, when we pass a reference into foo, we are not allowed to modify the data it points to. If we need to do this, we can use a mutable reference instead:
let mut s = String::from("hello");
foo(&mut s);
Rust lets you have any number of immutable references to a variable at any one time, or at most one mutable reference, but you can't mix and match. This allows Rust to prevent data races at compile time.
Error handling
In Rust, errors ultimately have to be handled explicitly, but Rust provides some nice syntactic sugar to make this easy. The Result<T, E> type represents something that could be either a value of type T or an error of type E; for example, a function with signature
fn foo() -> Result<String, IOError>
can return either a String or an IOError. The ? operator is the most common way to handle errors, and it propagates the error up to the calling function. For example, if we have this function which calls foo:
fn bar() -> Result<(), IOError> {
let data: T = foo()?;
...
}
it acts as follows:
- If
fooreturns a non-error value of typeT, this will be assigned to the variabledata, and execution ofbarwill continue as normal. - If
fooreturns an error, execution ofbarwill stop immediately and it will propagate the error up to the caller.
The ? operator can be a bit confusing at first, but once you understand how to use it, it's really quite a nice tool that can save you a lot of boilerplate. I'm a Go programmer, and although I appreciate that Go also handles errors explicitly, I still find it kind of annoying to have to do this dance every time:
data, err := foo()
if err != nil {
return "", err
}
At least Rust's ? gives you a nice way to avoid that and condense it down into a single line.
Macros
Macros are a powerful feature of Rust that allow you to do metaprogramming - basically, macros are pieces of code that are expanded by the compiler to generate other pieces of code. In practice, they look a lot like functions but are actually a lot more powerful. Macros are denoted by a ! after the name, e.g. my_macro!(args). In fact, Rust's built-in println! utility is actually a macro.
There are several different types of macros, but the most common type is defined using macro_rules!. Here's an example:
macro_rules! add_one {
($x:expr) => {
$x + 1
};
}
fn main() {
let a = add_one!(5);
println!("{}", a);
}
At compile-time, the add_one! macro will be expanded by the compiler, so that main will be transformed into:
fn main() {
let a = 5 + 1;
println!("{}", a);
}
Traits
Traits in Rust are a lot like interfaces in Go or other OOP languages. Essentially, a trait defines a contract of behaviour via a collection of methods, and then a type can implement that trait by defining implementations of each of the methods.
trait Greet {
fn greet(&self) -> String;
}
struct Person {
name: String,
}
impl Greet for Person {
fn greet(&self) -> String {
format!("Hello, my name is {}!", self.name)
}
}
Building a simple HTTP server in Rust
To test my knowledge of Rust, I set myself a small challenge of writing a basic HTTP server implementation. You can see the complete code here.
The first thing we need to do is start a TCP listener:
let listener = TcpListener::bind("localhost:0")?;
println!("listening on {}", listener.local_addr()?);
Port 0 doesn't actually exist - it just means that the OS will automatically choose a free port for us. Theoretically, TcpListener::bind can return an error, so we use the ? operator to propagate the error upwards. We then print the listener's local address to the console so that we know what port was chosen.
Now, we need to wait for incoming TCP connections on the listener, and handle each one in turn. A simple way to do this is loop over listener.incoming():
for conn in listener.incoming() {
handle(conn?)?;
}
Ok(())
For each connection, we pass it to a handle function which is responsible for reading the HTTP request and writing a response. The Ok(()) line should never be reached - it's just needed to make the compiler happy.
So what does the handle function look like? The first thing we need to do is read the incoming HTTP request from the stream.
let r = BufReader::new(&mut conn);
BufReader is a buffered reader which allows more efficient reading from the stream. Here we pass in a mutable reference to conn - this allows the BufReader to read from the stream without passing ownership of conn into the BufReader, because we will need conn later to write the response.
Next, we loop over the reader's .lines() method to read the request:
for line_res in r.lines() {
let line = line_res?;
if line.is_empty() {
// This is the blank line between headers and body
break;
}
println!("{}", line)
}
Here we are just logging the request to the console using println!. In reality, we should probably be parsing each line and storing the headers, but in this case, we don't care what the request is, we're just going to return the same response every time. Every HTTP request includes an empty line between the headers and body, so when we reach an empty line, we know that we've read all the headers, so we can break the loop. The next thing to do would be to read the body (using the results of the Content-Length header), but for this simple example, I chose to just ignore it.
The next thing to do is to generate the response and write it to the connection. For future extensibility, I chose to represent the response using a struct:
struct ResponseParams {
http_version: String,
status_code: i32,
reason_phrase: String,
headers: Vec<(String, String)>,
body: String,
}
I defined a utility function that accepts a string and generates a response struct with status code 200 (OK) using that string as the body:
fn construct_ok_response(body: String) -> ResponseParams {
ResponseParams {
http_version: "1.1".to_string(),
status_code: 200,
reason_phrase: "OK".to_string(),
headers: vec![
("Content-Type".to_string(), "text/plain".to_string()),
(
"Content-Length".to_string(),
body.as_bytes().len().to_string(),
),
],
body: body,
}
}
Then, we have a separate marshal_response function which accepts a ResponseParams struct and generates the HTTP response using those params. This basically just uses format! to generate a string using the parameters.
Finally, we just need to write the marshalled response to the connection stream:
let resp = marshal_response(response_params);
conn.write_all(resp?.as_bytes())?;
And with that, we are done with this connection - we can call Ok(()) to return from the handle function, and let the server handle the next request.
Points of confusion
Writing this server definitely gave me some practical experience with Rust and helped me internalise the concepts I had learned. However, I am still left with a few doubts. For one, there are multiple string types, and I am still a bit confused about whether I should use &str or String in a given scenario. (There's another string type as well - &'static str). Conceptually, I understand the difference (value vs reference type), but it's still tricky to tell which one I should use as an argument to X function, or as the type of a struct field.
I also found I had to use .to_string() a lot (e.g. see the construct_ok_response method above), and I found this to be kind of unsightly. I'm not sure if this is a sign that I'm using the incorrect string type, or if it's just something that I have to accept about using Rust.
I hope to address these doubts and gain more insight on them as I continue learning Rust.
Possible improvements
The HTTP server that I wrote here is extremely simple - there are many improvements I would have to make for it to be production-ready. Since this is just a toy project, it's unlikely that I will actually make these improvements - I'm sure there are already highly-optimised general purpose Rust HTTP server implementations out there, and there's no point reinventing the wheel here. However, I've at least thought of several ways that I could improve it:
- Allow specifying the port on the command line via a
--portflag. - Currently, we process incoming connections sequentially - to improve server capacity, we should make the
handlefunction async and accept connections concurrently. For example, we could process each incoming connection in a different thread. - When reading the response, we should actually parse it and extract the request path, method, headers, etc. Furthermore, we should actually read the request body as well. We could put all of this into a
Requeststruct. - If we wanted to serve different responses based on the request, we could inspect the generated
Requeststruct and discriminate e.g. based on the URL path. - We could define more utility methods to generate different kinds of responses (e.g. 404).
- Error handling could be done more carefully - for simplicity, I just used
?everywhere, but there are certain cases where we should probably be more careful (e.g. not propagatinghandleerrors for a single connection up to the main loop). -
marshal_responsecould be replaced by an implementation ofstd::fmt::Displayon theResponseParamsstruct - this might be a more idiomatic Rust way of doing it. - We could implement a "handler registry" pattern, similar to what's done by Go's
http.HandleFunc. At the start, we register various paths and point each one to a separate handler function. Then, when a request comes in, the server matches the request to the registered paths and determines which handler to use.
Conclusion
Well, that just about sums up my first week in Rust - what I learned and what I built. It's definitely quite different to the other languages I'm familiar with, and writing in Rust forces you to think in a different way to e.g. writing in Go. However, I think that's ultimately a good thing, as this leads you to writing programs that are more performant and memory-safe. Overall, I enjoyed the experience, and I'm proud of what I learned in just a few days. I look forward to continuing my Rust journey in the next few weeks, diving into more complex features of Rust and building upon my understanding.
Top comments (1)
This is a nice breakdown! Thank you 😊