From C++ to Rust: How One Compelling Feature Changed My Perspective

From C++ to Rust: How One Compelling Feature Changed My Perspective

A programming language, in essence, is a versatile tool that facilitates the translation of human ideas into machine-executable instructions. It is the canvas upon which a programmer expresses their creativity, ingenuity, and problem-solving skills. However, it is essential to recognize that the language itself is not the end goal, but rather a means to an end. A good developer understands the importance of carefully selecting the appropriate tools for each specific task.

Learning a programming language can be quick or slow, and some people hesitate to learn a new one without a good reason. It's tough to find the motivation to learn a new language when you're already skilled in another. This underlines the importance of recognizing the potential benefits and growth opportunities that come with embracing diverse programming languages.

A Tale of Two Languages

I began my career as a C++ developer, captivated by the language's ability to create high-performance, system-level code that could seamlessly interact with hardware and low-level systems. Its versatility and power earned C++ a well-deserved reputation among programming languages, favored by developers who demand speed, control, and robustness in their applications. If C were to be depicted as a mighty emperor, then C++ would surely be the charismatic prince and rightful heir to the throne. However, Underneath this royal programming partnership lies a world of complexity and challenges.

As a language that demands vigilance and skill, C++ can be unforgiving. It requires developers to navigate a treacherous landscape of potential errors, memory leaks, and segmentation faults. The performance optimizations, while impressive, often led to difficult-to-debug issues and placed a heavy burden of responsibility on developers. This constant balancing act between power and safety left me wondering if there was another language that could offer the power of C++ without the inherent risks and complexities.

Seeking a language that retained C++'s strengths without its complexities, I explored widely adopted languages like C# and Java, both boasting extensive libraries, frameworks, and active developer communities. I ultimately chose C#, as it offers a higher-level, streamlined development experience while maintaining object-oriented nature and strong type system.

While embracing C#, I also discovered Python, which quickly became my favorite language for its simplicity and versatility. Python enhanced my programming toolkit, especially in scripting, machine learning, and data science.

Armed with expertise in C++, C#, and Python, I assumed my programming arsenal was complete. However, I soon encountered a new language, quietly gaining traction and challenging the status quo of traditional systems programming. This emerging contender aspires to redefine the programming landscape while preserving the stylistic essence of its predecessors.

The Rust Revolution: A Skeptic's Journey

Enter Rust, a new contender that emerged from the shadows to challenge C++. I was a latecomer to the Rust world and initially stumbled upon the language with skepticism.

My first attempt to learn Rust was short-lived. Rust appeared complicated, with concepts like ownership and safety that seemed unnecessary when compared to the comforts of C#. Rust seemed like an enigma - a language that promised safety and performance but appeared entangled in a web of complexity. Why bother learning a completely new programming language like Rust when alternatives like Go seemed easier and more modern?

It wasn't until my second attempt to explore Rust that I truly began to unravel its mysteries. As I delved deeper, my skepticism transformed into admiration, and I realized that Rust was more than just a passing fad. Delving deeper into the language, I uncovered a jewel in Rust's crown: pattern matching. This versatile and powerful feature was a revelation, enabling developers to match complex patterns and data structures with an elegance and expressiveness beyond anything I had encountered before.

In this article, I want to explore the feature that kindled my passion for the language: pattern matching. Most introductions to Rust talk about safety, performance, and the other benefits that Rust brings to the table. However, those aspects alone may not be convincing enough for some developers to invest time in learning a new language.

By introducing Rust through the lens of pattern matching, I hope to pique the interest of readers who might otherwise be overwhelmed by the language's more unique and novel concepts.

Other Languages Have Pattern Matching?

It's no secret that other programming languages have implemented pattern matching before Rust. However, those languages simply cannot compare to the efficiency and safety provided by Rust's pattern matching system. Languages like Haskell, Scala, and F# have pattern matching, but they lack Rust's memory safety guarantees. In contrast, Rust's ownership and borrowing system work together with pattern matching to create a language that can handle complex data structures with ease while ensuring that memory safety is never compromised.

In addition to safety, Rust's pattern matching is incredibly efficient. It can work with a variety of data types, including enums, structs, and even match guards. The Rust compiler ensures that pattern matches are exhaustive and complete, which makes it almost impossible to miss a case or cause a runtime error.

Rust's pattern matching isn't just useful for simple case matching either. It can be used for complex data structures, including nested data types, recursive data structures and array’s. This makes Rust's pattern matching a valuable tool for developers who work with complex systems.

The Basics of Pattern Matching in Rust

In Rust, pattern matching can be expressed using the match keyword.

Here's a basic example. If you have a background in any other language, this should be easily recognizable and comprehensible.

Playground

let c = 'a';

match c {
    'a' => println!("The character is 'a'"),
    'b' => println!("The character is 'b'"),
    _ => println!("The character is something else"),
}

In this example, we're matching against the character c and using two patterns to match against it. The first pattern matches the value 'a', and the second pattern matches the value 'b'. The underscore (_) is a catch-all pattern that matches anything that hasn't been matched yet.

Note: I would like to draw your attention to the fact that a playground link will be included with all the code samples in this article, allowing you to try them out for yourself.

Same goes with string type. While the basic principles of pattern matching in Rust can be applied to strings, it's important to note that working with strings in Rust is not the same as in other programming languages. In fact, it's a topic that deserves its own dedicated post, which I won't cover in depth here.

Playground

let s = "hello";

match s {
    "hello" => println!("The string is 'hello'"),
    "world" => println!("The string is 'world'"),
    _ => println!("The string is something else"),
}

In C++, and most other languages, every expression can be used as a statement, but not every statement is an expression. In Rust, you can think of every line of code as a question that expects an answer (i.e., a value). Take a look at the example below

Playground

fn main() {
    let c = 'z';
    
    let result = match c {
        'a' | 'e' | 'i' | 'o' | 'u' => "starts with a vowel",
        _ => "starts with a consonant",
    };
    
    println!("{}", result);
}

Here, The entire match block is also an expression, which allows us to assign its value to the result variable and print it out. Here, we're again matching against the char variable c,  but this time we are using a pipe (|) to match against multiple values. The pattern matches any of the 5 vowels and returns the statement "starts with a vowel".  Cool ha!

The range patterns .. and ..= are used to represent a range of values in pattern matching. These range patterns are especially useful when working with numeric types or characters. The .. pattern is a half-open range, which means it includes the start value but excludes the end value. Whereas the ..= pattern is an inclusive range, which means it includes both the start value and the end value. Here is an example:

Playground

let input = '5';
let result = match input {
    'q'                   => "Quitting",
    'a' | 's' | 'w' | 'd' => "Moving around",
    '0'..='9'             => "Number input",
    _                     => "Something else",
};
/*
OUTPUT:
Number input
*/

You can also combine the pipe (|) and the ..  operators.

Playground

let input = 'D';
match input {
    'A'..='Z' | 'a'..='z' | '0'..='9'  => "AlphaNumeric",
		_                                  => "Something else"
};

Lets move on to more interesting examples. The _ (underscore) is used as a wildcard pattern in the match expression. It represents a placeholder for any value, meaning that it matches any value without binding it to a variable. This is useful when you want to match a pattern, but you don't  need the actual value in that position.

Playground

let point = (0, 5);

match point {
    (0, _) => println!("On the X axis"),
    (_, 0) => println!("On the Y axis"),
    _ => println!("Not on an axis"),
}
/*
OUTPUT:
On the X axis
*/

The point variable is a tuple with two elements. The match expression has three branches:

  1. (0, _): This branch matches any tuple where the first element is 0, regardless of the value of the second element. In this case, the point lies on the X axis.
  2. (_, 0): This branch matches any tuple where the second element is 0, regardless of the value of the first element. In this case, the point lies on the Y axis.
  3. _: This branch acts as a catch-all, matching any tuple that doesn't satisfy the previous conditions. It represents a point that doesn't lie on either axis.

By using the _ wildcard pattern, you can create more concise and flexible patterns in your match expressions, simplifying your code and focusing only on the values you need.

Following the previous example, we can also demonstrate the use of capture in pattern matching. Capture allows you to bind the matched value to a variable, which can then be used within the branch. This is helpful when you need to access the matched value for further processing or output.

Here's the code example:

Playground

let pair = (1, 0);

match pair {
    (x, 0) => println!("First element: {}", x),
    (0, y) => println!("Second element: {}", y),
    _ => println!("Neither element is zero"),
}
/*
OUTPUT:
First element: 1
*/

In this example, the pair variable is a tuple with two elements. The match expression has the  following branches:

  1. (x, 0): This branch matches any tuple where the second element is 0. The first element's value is captured in the variable x, which is then used within the branch to print the first element's value.
  2. (0, y): This branch matches any tuple where the first element is 0. The second element's value is captured in the variable y, which is then used within the branch to print the second element's value.

The @ symbol in Rust pattern matching allows you to capture a value that matches a specific pattern while still retaining the flexibility to use complex patterns. This is useful when you want to use the matched value within the branch while matching against a more elaborate pattern.

Here's the code example:

Playground

let number = 3;

match number {
    x @ 1..=5 => println!("In range: {}", x),
    _ => println!("Out of range"),
}
/*
OUTPUT:
In range: 3
*/

x @ 1..=5: This branch matches any value within the range of 1 to 5 (inclusive). The matched value is captured in the variable x using the @ symbol. The branch then prints the captured value, indicating that the number is within the specified range.

Next, let’s look at the use of conditionals in pattern matching. Conditionals are specified using the if keyword followed by a boolean expression, allowing you to add extra constraints when matching a pattern.

Playground

let number = 42;

let result = match number {
    n if n % 2 == 0 => "even",
    _ => "odd",
};
/*
OUTPUT:
The number is even
*/

n if n % 2 == 0: This branch matches any value where the remainder of the value divided by 2 is 0 (even numbers). The value is captured in the variable n. The branch returns the string "even" to indicate that the number is even.

Enums

Enums in Rust are very flexible, as each variant can hold different data types.  Let’s see how that works in action.

Playground

enum Action {
    Quit,
    Move,
    Number,
    Idle
}

let input = '5';
let result = match input {
    'q'                   => Action::Quit,
    'a' | 's' | 'w' | 'd' => Action::Move,
    '0'..='9'             => Action::Number,
    _                     => Action::Idle,
};
/*
OUTPUT:
Number
*/

In this example, a Rust enumeration called Action is defined with four variants: Quit, Move, Number, and Idle. The match expression is used to categorize the input character and return the appropriate Action variant based on the input.

Now, let's expand upon the Action enum by allowing it to store additional data. In this case, we'll store the character corresponding to the movement direction within the Move variant. This is useful when you want to associate extra information with a specific variant of an enum.

Playground

enum Action {
    Quit,
    Move(char),
    Number,
    Idle
}

let result = match input {
        'q'                   => Action::Quit,
        'a' | 's' | 'w' | 'd' => Action::Move(input),
        '0'..='9'             => Action::Number,
        _                     => Action::Idle,
};
/*
OUTPUT:
Move('w')
*/

In the modified code above, the Move variant now takes a char as its associated data. When matching the input, if the input character is 'a', 's', 'w', or 'd', the Move variant will be created with the input character as its associated data. This allows you to store extra information about the movement direction alongside the Move variant.

Let’s look into one more practical example with Enums.

Playground

enum Shape {
    Circle(f32),
    Rectangle(f32, f32),
}
let shape = Shape::Circle(5.0);

match shape {
    Shape::Circle(radius) => println!("Circle with radius: {}", radius),
    Shape::Rectangle(width, height) => println!("Rectangle with dimensions: {}x{}", width, height),
}
/*
OUTPUT:
Circle with radius: 5
*/

In this example, we define a Shape enum with two variants: Circle and Rectangle. Each variant holds different information about the dimensions of the shape.

Structs

After exploring Enums, let's discuss pattern matching with structs. Structs allow you to define more complex data types by grouping related values. Pattern matching with structs can help you destructure and work with the individual fields of the struct.

Consider the following example:

Playground

struct Point {
    x: i32,
    y: i32,
}
let point = Point { x: 0, y: 5 };

match point {
    Point { x, y } if x == 0 => println!("On the X axis: {:?}", point),
    Point { x, y } if y == 0 => println!("On the Y axis: {:?}", point),
    _ => println!("Not on an axis"),
}
/*
OUTPUT:
On the X axis: Point { x: 0, y: 5 }
*/

In this example, we define a Point struct with two fields, x and y, representing the coordinates of a point in a 2D space. We then create a Point instance called point and use a match expression to determine its position relative to the X and Y axes.

The match expression has the following branches:

  1. If x is 0, the point lies on the X axis, and we print "On the X axis" with the point's details.
  2. If y is 0, the point lies on the Y axis, and we print "On the Y axis" with the point's details.

In addition to regular structs, Rust provides tuple structs, which are a more concise way to define simple data structures with related values. Tuple structs are similar to tuples, but they have a name and a defined structure.

Let's consider an example using a tuple struct and pattern matching:

Playground


struct Coordinates(i32, i32);

let coords = Coordinates(0, 5);

match coords {
    Coordinates(x, y) if x == 0 => println!("On the X axis: {:?}", coords),
    Coordinates(x, y) if y == 0 => println!("On the Y axis: {:?}", coords),
    _ => println!("Not on an axis"),
}
/*
OUTPUT:
On the X axis: Coordinates(0, 5)
*/

In this example, we define a tuple struct called Coordinates with two fields representing the coordinates in a 2D space. We then create an instance of Coordinates called coords and use a match expression to determine its position relative to the X and Y axes, just as in the previous example with the regular struct.

Sometimes, you may have more complex data structures with nested enums. In this example, we define a Color struct and a Shape enum. The Shape enum has a variant called Custom, which contains a Color struct. We will use pattern matching to destructure the shape variable, and in the case of the Custom variant, we will also destructure the nested Color struct to extract its fields.

Playground

struct Color {
    red: u8,
    green: u8,
    blue: u8,
}

enum Shape {
    Circle(f32),
    Rectangle(f32, f32),
    Custom(Color),
}

let shape = Shape::Custom(Color {
    red: 255,
    green: 0,
    blue: 0,
});

match shape {
    Shape::Circle(radius) => println!("Circle with radius: {}", radius),
    Shape::Rectangle(width, height) => println!("Rectangle with dimensions: {}x{}", width, height),
    Shape::Custom(Color { red, green, blue }) => println!("Custom color: ({}, {}, {})", red, green, blue),
}
/*
OUTPUT:
Custom color: (255, 0, 0)
*/

In this example, we define a Color struct and a Shape enum. The Shapeenum has a variant called Custom, which contains a Color struct. We will use pattern matching to destructure the shape variable, and in the case of the Custom variant, we will also destructure the nested Color struct to extract its fields. As you can see, this powerful feature makes it simple to work with complex data structures in Rust.

Arrays

In Rust, fixed-size arrays have a known length at compile-time, which allows us to match different array lengths in a concise and expressive manner. In this example, we will use pattern matching to handle different cases for an array of integers.

Playground

let numbers = &[1, 2, 3];

match numbers {
    [] => println!("Empty array"),
    [a] => println!("One element: {}", a),
    [a, b] => println!("Two elements: {} and {}", a, b),
    [a, b, c] => println!("Three elements: {}, {}, {}", a, b, c),
    _ => println!("Array with more than three elements"),
}
/*
OUTPUT:
Three elements: 1, 2, 3
*/

Here, we match the numbers array against different patterns, such as an empty array, an array with one element, an array with two elements, and so on. This allows us to handle different array sizes elegantly, as well as providing an easy-to-read syntax.

We can also match the first few elements of a slice, regardless of the slice's total length. We use the .. pattern to represent the remaining elements of the slice.

Playground

let numbers = &[1, 2, 3, 4, 5];

match numbers {
    [a, b, ..] => println!("First two elements: {} and {}", a, b),
    _ => println!("Less than two elements"),
}
/*
OUTPUT:
First two elements: 1 and 2
*/

How about we match specific elements of a slice using pattern matching. We can use the .. pattern to represent the remaining elements of the slice and then use guards to filter the matches further.

Playground

let numbers = &[1, 2, 3, 4, 5];

match numbers {
    [_, x, _, y, ..] if x + y == 6 => println!("Sum of second and fourth elements is 6"),
    _ => println!("No match found"),
}
/*
OUTPUT:
Sum of second and fourth elements is 6
*/

We could also capture multiple elements of a slice using the .. pattern. We match the first and last elements of the slice and capture the elements in between.

Playground

let numbers = &[1, 2, 3, 4, 5];

match numbers {
    [first, middle @ .., last] => {
        println!("First element: {}", first);
        println!("Last element: {}", last);
        println!("Middle elements: {:?}", middle);
    }
    _ => println!("No match found"),
}

/*
OUTPUT:
First element: 1
Last element: 5
Middle elements: [2, 3, 4]
*/

Beyond Pattern Matching: Exploring Other Exciting Features of Rust.

Throughout this article, my aim was to introduce Rust by highlighting its powerful pattern matching capabilities. While pattern matching is a potent feature, it truly shines when combined with Rust's other robust concepts. As a versatile and feature-rich language, Rust has much more to offer.

To further explore Rust and enhance your understanding, consider delving into the following topics:

Rust Language: Begin with the official Rust lang book, which covers essential concepts and serves as a comprehensive introduction to the language.

Ownership and Borrowing: One of the core concepts in Rust is its ownership system, which manages memory and other resources in a safe and efficient way. This unique concept might initially seem challenging to understand, but with practice, it becomes clearer and demonstrates Rust's focus on safety and performance.  Understanding ownership, borrowing, and lifetimes is essential to writing idiomatic and efficient Rust code.

Cargo: Rust's ecosystem is built upon a package manager called Cargo and a package registry called crates.io. Learn how to create, publish, and use Rust libraries (crates) to manage dependencies, modularize your code, and share your work with the Rust community. Familiarize yourself with Rust's ecosystem and build tools, learning how to manage dependencies, create projects, and publish libraries.

Rust Strings: Gain insights into Rust's string handling and manipulation capabilities, learning about the differences between String and &str and efficient ways to work with text data.

Error Handling with Result and Options: Rust's approach to error handling is centered around  the Result and Option types, which provide a robust and expressive way to handle errors and missing values. Understand Rust's approach to error handling, which emphasizes graceful and efficient handling of errors and missing values.

Rust Macros and Metaprogramming: Discover the power of Rust's macro system and compile-time code generation, which allows for reusable patterns and more efficient, DRY (Don't Repeat Yourself) code.

Concurrency and Parallelism: Rust has built-in support for concurrency and parallelism, making it a great choice for systems with heavy workloads. Dive into Rust's support for concurrent and parallel programming, learning about threads, async/await, and other concurrency primitives.  Rust's async/await feature enables you to write asynchronous code that is both efficient and easy to read. Understand how to work with async functions, futures, and other async primitives to build scalable and responsive applications.

Popular Rust Libraries: Rust's thriving ecosystem is filled with popular libraries (also known as crates) that cater to a variety of use cases and domains. These libraries are designed to make your development experience more enjoyable and productive.

Resources

To help you dive deeper into Rust and expand your knowledge, I have curated a list of excellent resources to explore:

These resources will support your growth as a Rust developer and help you unlock the full potential of the language. Enjoy your learning journey, and happy coding!