Coming to Rust from Ruby

Written by: Daniel P. Clark

I like to keep an open mind for other languages and what I may learn and how I may be better for it. Yet I'm cautious about the shift toward languages that become suddenly popular. Programming languages are tools designed with particular focus that bring with them both benefits and costs. Not every language is a hammer and not every problem a nail. Use the right tool for the job, but even more so, focus on using your own strengths for getting the best results.

But I believe most programmers have in common a thirst for new knowledge. Curiosity for new tools can stimulate our minds and help feed our motivation.

I recently came across an article about Rust helping with Ruby, and it was like the light turned on. What the Rust language was offering me was comparable to the power and speed of C without any of the risk! The article described how I can integrate my Rust code to add highly performant code to Ruby. So I created my own benchmarks and tested the speeds for myself using Rust with Ruby. The difference was orders of magnitude faster!

So this is what brought me to Rust. I'm not leaving Ruby, just supporting it while continuing to build on my strengths.

Getting Familiar With Rust

For Rust being such a young language, it's astounding the amount of documentation and completeness of compiler help you get.

Rust is a very thoroughly documented language and has both document generation and code sample verification as a standard feature with the Cargo package manager. Beyond the documentation and compiler, you also have a very active and helpful Rust community forum and beginner IRC channel. The learning curve is much easier to handle when you have so much access within the community.

The Cargo package manager is a bit like Bundler and Rake combined into one. It will be your primary go-to command line tool when developing with Rust. Here are the main usages of it.

cargo new project_name --bin      # binary template
cargo new library_name            # library template
cargo test                        # run all tests
cargo update                      # update dependencies
cargo build                       # compile the current project
cargo run                         # build and execute src/main.rs

A starting project directory will likely have the following directory structure:

project/
   |--Cargo.toml
   |--src/
       |--main.rs
       |--lib.rs

The lib.rs file will exist if you generated a library template, and main.rs will exist in a binary template. You may add a benches folder and a tests folder to further organize your benchmarks and integration tests respectively.

The Cargo.toml file is Rust's equivalent to Ruby's .gemspec file. General details about the crate (equivalent to Ruby's gem), or program, will be specified in here along with the project dependencies. Once you run cargo update, a Cargo.lock file will be generated, just like Ruby's Gemfile.lock, for locking in the current dependency scheme.

One thing Rust does differently with their crates is that they are automatically namespaced by the package name of the crate. So if you build a crate with cargo new shiny_box, then shiny_box will be the namespace that has everything under it. You don't need to create a module to put all the functions in as the crate itself behaves like a module namespace.

Testing

In Rust, you can place tests in any source code file. The tests directory is specifically for "integration" tests. It's not uncommon to have tests written right after method definitions. To write tests, you'll mark tests with the attribute #[test] on the line before a function definition.

#[test]
fn it_passes() {
  assert!(true);
}

fn defines a function, and the curly braces {} holds the code block for the function. assert! is a macro function for testing true/false results. When you see what looks like a method with an exclamation mark, you'll know it's a macro. Macros are precompiler helper functions used for injecting code which provide more flexibility for method definitions. You may refer to the Rust documentation for further details on macros.

Tests may be written within both module definitions and the global namespace. When you run cargo test, it will run these tests and code samples from within your own code's documentation.

Comparing Rust and Ruby

Before we get into writing code, let me give you some Rust and Ruby comparisons.

Struct

The closest thing I've found to replicate Ruby's class object is with Rust's Struct type. This will allow you to store and initialize state. It is also the main object type you'll use for creating your own types and adding methods (known as functions) to.

When you want to add functionality to a type of your choosing, you use the impl keyword for writing your implementation.

struct Rectangle {
  width: i32,
  height: i32,
}
impl Rectangle {
  fn area(&self) -> i32 {
    self.width * self.height
  }
}

What we've done here is create our own object with struct Rectangle. It has two values, width and height, which will be of the type of a 32-bit signed integer (the i32).

Rust is a typed language and in some scenarios you must specify the type, but in others it can be inferred. After creating the struct code block, we may instantiate an instance of it with Rectangle {width: 4, height: 5,}. It won't have any methods until we've implemented them.

You may have noticed the trailing commas in both the definition of Rectangle and the instantiation of it. This is not a typo. It may work without the trailing comma, but it's standard in Rust to write comma-separated values in a collection with a comma after the last item.

In the impl block, you can see a reference to self with &self. This is a borrowed reference which makes the values within Rectangle available through the variable self. The stabby pointer -> is declaring what type the output is expected to be.

In our case, we're returning an integer of i32 type. You'll notice that in our test earlier we ended the line with a semi-colon, but in our area function we did not end the line with one. This is because to return the value of the last line in a code block in Rust, you must either leave off the semi-colon or explicitly write the keyword return: return self.width * self.height;.

Traits

Traits are an interesting aspect in Rust. They're like a specification, or a protocol, defining the standard of methods input and output -- without writing the code for the function. It is also a place to document the expected behavior of those methods. Once you've written a trait, you can then implement that trait for any type of object.

struct Circle {
  radius: f64,
}
/// For evaluating squareness
trait Squareness {
  /// Reveals the truth of ones squareness
  ///
  /// # Example
  /// ```
  /// use example::{Circle,Squareness};
  ///
  /// Circle {radius: 2.0}.is_square();
  /// ```
  fn is_square(&self) -> bool;
}
impl Squareness for Circle {
  fn is_square(&self) -> bool {
    false
  }
}
impl Squareness for Rectangle {
  fn is_square(&self) -> bool {
    self.width == self.height
  }
}
#[test]
fn it_is_square(){
  assert_eq!(Circle {radius: 2.0}.is_square(), false);
  assert_eq!(Rectangle {width: 4, height: 5}.is_square(), false);
  assert!(Rectangle {width: 4, height: 4}.is_square());
}

Now, if you run the tests for the above code, the three tests at the end will pass but the code sample in the documentation will fail. This is because the code example in the documentation runs from an integration perspective, and the code being evaluated is all private.

By default, Rust will make most things private, so if you want to integrate them externally, you will need to declare them as public with the pub keyword preceding each thing. For this documented trait example to work, you'll need to put pub before the struct definition, its radius variable, and before the trait definition.

The use example::{Circle,Squareness}; calls into the current scope the objects and traits that you may want to use. The word example here is the name of the current crate. The use of curly brackets here is for picking multiple things from the same scope to include.

If you wanted only one thing included, you may do so without curly brackets use example::Rectangle;. If you're including something from an external crate, you must enter extern crate example; beforehand. You may include objects and traits within any scope, and the code you've useed will only be used within the scope of the block it's included in.

Collections

There are many ways to create and handle collections in Rust. Alternatives for Ruby Arrays include:

// fixed size arrays (stack allocated)
let a = [1, 2, 3];                    // a: [i32; 3]
// growable vectors (heap allocated)
let b = vec![1, 2, 3];                // b: Vec<i32>
// fixed tuples
let c = (1, 2, 3)                     // c: (i32, i32, i32)
// struct tuples (a.k.a named tuples)
struct Point3D(i32, i32, i32);
let d = Point3D(1, 2, 3);             // d: Point3D(i32,i32,i32)

Just like our implementation of traits earlier, each core type has its own traits that it has and hasn't implemented.

When looping over a collection, you'll notice that vector includes the core::iter::Iterator trait but the stack allocated array does not. So the following for loop will work for a vector but not an array.

let a = vec![1,2,3];
for val in a {
  print!("{:?}", val)
}

With a standard array, you'll need to convert it to a vector for the for loop [1,2,3].to_vec() or handle it differently.

You'll notice the print statement has some weird-looking syntax. The curly brackets are for injecting the next value provided, and the colon question mark is a helper for formatting various types. This for loop will work as expected outputting 123.

Another way to loop is by providing a range. Arrays have a length method, len(), which works fine for us with a range. The following will give us the same result as above:

let a = [1,2,3];
for val in 0..a.len() {
  print!("{:?}", a[val])
}

No nil

Rust has been designed without the concept of nil. Instead you may choose your own object type to represent your null object pattern.

Some of the built-in examples of this are the Option enum which presents either a Some() or None variant, and the Result enum which will present either an Ok() or Err() variant. Ok() and Some() are objects that wrap around your result object, and Err() wraps an error message.

Let's create an example to loop over with a range type. It returns a variant from the Option enum of either Some() or None each time the next method is called.

let mut a = 0..4;
loop {
  match a.next() {
    Some(n) => {
      print!("{:?}", n);
    },
    None => {
      break;
    },
  }
}

The above code snippet will output 0123, and then the loop ends when break; is executed.

A range type is a valid iterator. To create an iterator in Rust, all you have to do is define the next method on an object type. Iterators behave lazily as they keep track of their current offset and return one value of Some(_) each time you call next(), or None if you've reached the end. The loop {} block will infinitely loop until you give the command to break from the loop.

You'll notice we used the keyword mut for the variable a; let assigns things as immutable unless you specify that they need to be mutable (changeable). Because a range changes its current offset each time you use the next() method, you need the variable a to be mutable for it to work.

In the loop, the match block will take the value returned by a.next() and match it to one of the possible outcomes and execute the code block assigned to it. When you use match, you will need to account for every possible output from what you're matching or the compiler will throw an error. Since the next method returns an Option<T> type, T here is a generic identifier for any kind of type, it will always be either Some(T) or None.

If you want to use match for comparing to some values but don't care about others, you may use an underscore _ as a catch all. _ => { break; }. The Some(n) matcher unwraps (extracts) the value from Some as a local variable n, which you can use in the following code block.

Summary

This is just the tip of the iceberg of Rust, and there's so much more to say! As you develop with Rust, you will find the compiler telling you exactly where things need to be changed and why it failed. The debugging messages are very helpful and act as an extra layer of TDD.

I have found that it's easiest for me to guess at a full methods implementation before running cargo test; this allows me to put my work in without being distracted by any unfamiliar syntax. The compiler just points to the areas I need to change, and that's easily addressed then.

If you have any questions, be sure to connect with people through the Rust community. You'll find people available and willing to help. If you want to learn more about the core features in Rust, you can also read the source code -- they've written very detailed explanations in their well-documented code.

The guarantees that the Rust language gives for safe code execution and safe threading are unheard of in languages providing mutability. Add in the community and documentation, and you have yourself a winning language.

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.