DEV Community

Zubair Maqsood
Zubair Maqsood

Posted on

The Cracked Engineer: From JavaScript to Rust: The Engineer’s Guide to Systems Programming

In my previous article, I dissected JavaScript's inner workings and mechanics. If you haven't read it yet, I recommend starting here. Today, we're diving into Rust, but first, let me explain why I chose it. Coming from a Cloud/Node.js background, I'm intimately familiar with interpreted languages. However, I felt something was missing – a deep understanding of compiled and systems programming languages. While C and C++ were options, their notorious compiler issues steered me away. Enter Rust: a modern systems language that's been gaining significant traction

The Art of Ownership: Memory Management Made Clear

Rust uses the Ownership model. Ownership is a set of rules that sets the show on how a Rust program manages memory. How that is done is with a set of rules that a compiler checks. If these rules are violated, the program itself will not compile. TLDR: There are rules that the Rust compiler has, and if your code breaks them, it simply won't work

This "strictness" allows Rust to have these attributes
1) Control over memory
2) To be Error free
3) Faster runtimes
4) Smaller program size

At runtime, Rust decides if certain variables and results of functions should be put on 2 core data structures. The Stack and the Heap

I went over the Stack data structure briefly. To put it simply, it is a LIFO (Last in, First Out) data structure. The Heap, is something different entirely. It is a tree-like data structure (like a binary search tree) but with a twist. Unlike the binary search tree, it there is no implied ordering between siblings and no implied sequence for in-order traversal.

Binary Search Tree vs Heap

So at runtime, Rust decides if variables should be pushed onto the stack or heap. For something to be pushed onto the stack, values must have a known size at compile time and/or have a fixed size type (primitives such a bool or char are examples of this), where as for something to be pushed onto the heap, it needs to be dynamically sized, usually that means large data, perhaps a batch of JSON returned from an API call that needs to be stored, or it could be a String that can be altered dynamically (we can do this easily using the String::(new) function)

Borrowing: Share Nicely or Don't Share at All

Rust uses a borrowing mechanism to access data without direct ownership of it. Instead of passing objects by value like JavaScript does, objects can be passed by reference by using the & operator

let message = String::from("Hello"); // message owns this string
let greeting = message; // ownership moves to greeting
// println!("{}", message); // ❌ Error: message has been moved

// This is different from JavaScript where:
// let message = "Hello";
// let greeting = message; // Creates a copy/reference
// console.log(message); // ✅ Works fine in JS

// Now let's look at borrowing
let original = String::from("Hello");
let borrowed = &original; // Immutable borrow with &
println!("{}", borrowed); // Prints: Hello
println!("{}", original); // ✅ Still works! original maintains ownership

In the example above, Rust initialises the message variable and at the first line, the message variable owns the value String::from("Hello) which is stored on the Heap. Essentially the Heap has a pointer telling the compiler where this value is located. In the second line, the ownership is transferred from the variable message to a new variable called greeting. In the third line, we have an error that signifies the mechanism of ownership, the message variable now does not have access to the string. Contrast this to JavaScript, it works fine because well, there is no ownership mechanism in JavaScript, so the variable greeting merely copies the value from message.

In the 3rd example, if we wanted to log out both the original and borrowed variables successfully and for same logging, we would need to use the & operator. This does not transfer ownership of the value defined originally, but merely allows the borrowed variable to do exactly what the borrowing mechanism is, to borrow the value. The original variable still owns the string however!

Think of this concept as a pencil, you are the owner of it, your friend needs a pencil for something so you let him borrow it, but it is still yours.

There is one crucial aspect however, using the & operator creates an immutable reference, meaning the variable thats doing the borrowing, cannot mutate the original value. So how can we mutate the value by using the borrower?

We use the mut keyword

let mut value = String::from("Hello");
let mut_borrow = &mut value; // Mutable borrow with &mut
mut_borrow.push_str(" World"); // Modify through mutable reference

By using the mut keyword, we are essentially allowing the borrower to be able to mutate the original value. So now, if we wanted to log out value, it would output "Hello World"

Thread Safety Without the Headaches

In multithreaded languages like Java or C++, the execution of a code is called a process or a thread, in highly performant systems like a trading engine of an FX exchange, you can have multiple threads working at the same time. This multithreading approach can help performance but it can lead to problems such as Race conditions or Deadlocks.

But before that we need to become familiar with some modules from commonly used crates in the Rust ecosystem to build thread safe programs.

1) Arc from the std crate

Arc stands for Atomic Reference Counting.

use std::sync::Arc;
// Arc enables multiple ownership of the same data across threads
let data = Arc::new(vec![1, 2, 3]);

// We can clone the Arc to share ownership
let data_clone = Arc::clone(&data); // This is thread-safe

// Both data and data_clone point to the same memory
// Arc keeps track of how many references exist
// Memory is freed when the last Arc is dropped

Think of Arc as a smart pointer that keeps track of everything thats referencing the data. Its inherent feature is that it allows read only access to multiple threads.

2) Mutex from the std crate

Mutex stands for mutual exclusion. Its inherent feature that it allows only one thread to access some data at any given time. For a thread to access data in a mutex, it needs the mutex's lock. A lock is a data structure that is part of the mutex that keeps track of who currently has exclusive access to the data. Only one thread access the lock at a time. This prevents data races.

use std::sync::Mutex;

// Mutex wraps data to ensure only one thread can access it at a time
let counter = Mutex::new(0);

// To access data, you must lock() the Mutex
{
let mut num = counter.lock().unwrap();
*num += 1;
} // Lock is automatically released here

// Trying to access while locked blocks the thread until lock is released

3) Channels by using the mspc module from the std crate

Another way of ensuring thread safety is using channels, where threads can communicate with each other by sending each other messages.

The best analogy is what I got from the official Rust documentation

You can imagine a channel in programming as being like a directional channel of water, such as a stream or a river. If you put something like a rubber duck into a river, it will travel downstream to the end of the waterway.

A channel has two halves: a transmitter and a receiver. The transmitter half is the upstream location where you put rubber ducks into the river, and the receiver half is where the rubber duck ends up downstream. One part of your code calls methods on the transmitter with the data you want to send, and another part checks the receiving end for arriving messages. A channel is said to be closed if either the transmitter or receiver half is dropped.

use std::sync::mpsc; // mpsc = Multi-Producer, Single-Consumer
use std::thread;

fn main() {
// Create a channel with sender and receiver
let (sender, receiver) = mpsc::channel();

// Clone sender for multiple producers
let sender_clone = sender.clone();

// Producer thread 1
thread::spawn(move || {
sender.send("Hello from thread 1").unwrap();
});

// Producer thread 2
thread::spawn(move || {
sender_clone.send("Hello from thread 2").unwrap();
});

// Main thread receives
for message in receiver {
println!("{}", message);
}
}

Channels are useful for Async communication, event handling, data streaming and message massing.

Building Blocks: Rust's Type System

One thing we haven't gone over in this issue is the Rust syntax, which I've heard is very similar to C or C++ (don't have an opinion as I have never used either of them). So let's go over them briefly and lets also compare them to JavaScript's analogous features.

1) Structs

A struct in Rust is like a blueprint for creating data objects. Think of it as a more rigid, type-safe version of JavaScript classes/objects:

Rust enforces:

  • Exact field types (String, bool, u64)
  • All fields must be initialised
  • Fields can't be added/removed after creation
  • Type checking at compile time

JavaScript classes are more flexible but less safe due to the following reasons

  1. Dynamic typing
  2. Properties can be added/removed
  3. No compile-time type checking (unless using TypeScript)
  4. More prone to runtime errors

Classes vs Structs

2) Traits

Traits are Rust's way of defining shared behavior - similar to interfaces in TypeScript or abstract classes in JavaScript. The key differences are

  1. Defining behaviour contracts
  2. Can have default implementations
  3. Multiple traits can be implemented
  4. Enforced at compile time
  5. Used for operator overloading and type conversion

The Tradeable example below shows:

Required methods (get_price, update_price)
Self reference (&self)
Return type specifications (-> f64)

Traits vs Interfaces

3) Implementations

Implementations in Rust separate the data (struct) from behavior (methods). This is different from JavaScript's class-based approach.

These are the following differences
1) Clear separation of data and behavior
2) Multiple impl blocks allowed
3) Static methods (new)
4) Instance methods with explicit self reference
5) Mutable vs immutable methods (&mut self vs &self)

JavaScript lumps everything together in the class definition, which can be less organised but more familiar to OOP developers

Implementations vs Class methods

4) Attributes

Attributes in Rust are metadata attached to code elements. They're more powerful than JavaScript decorators. Attributes in Rust are like special tags or labels that give extra instructions or capabilities to your code. Think of them like sticky notes that tell the compiler "hey, do something special with this code."

These are the common use cases
1) Deriving common traits (#[derive(Debug)])
2) Conditional compilation (#[cfg(test)])
3) Testing (#[test])
4) Documentation
5) Compiler optimisations

JavaScript decorators are more limited and primarily used for class and method modifications.

Attributes vs Decorators

Putting It All Together

In this deep dive into Rust, we've explored what makes it a powerful systems programming language, especially when compared to interpreted languages like JavaScript. From its strict ownership model to its thread safety guarantees, Rust offers a unique approach to building reliable and performant software.

Moving from JavaScript to Rust represents more than just learning a new syntax - it's about adopting a different mindset towards software development. Where JavaScript offers flexibility and ease of use, Rust demands precision and thoughtfulness. This trade-off brings us significant rewards: better performance, fewer runtime bugs, and the ability to build systems that are correct by construction.

Top comments (0)