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 use
ed 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.