Having a stricter type system does not imply that code will have more requirements or be any more complex. Rather than strict typing, consider using the term expressive typing. Expressive typing provides more information to the compiler. This extra information allows the compiler to provide extra assistance while programming. This extra information also permits a very rich metaprogramming system. This is all in addition to the obvious benefit of safer, more robust code.
Strict abstraction means safe abstraction
Scoped data binding
Variables in Rust are treated much more strictly than in most other languages. Global variables are almost entirely disallowed. Local variables are put under close watch to ensure that allocated data structures are properly deconstructed before going out of scope, but not sooner. This concept of tracking a variable's proper scope is known as ownership and lifetime.
In a simple example, data structures that allocate memory will deconstruct automatically when they go out of scope. No manual memory management is required in intro_binding.rs:
fn scoped() {
vec![1, 2, 3];
}
In a slightly more complex example, allocated data structures can be passed around as return values, or referenced, and so on. These exceptions to simple scoping must also be accounted for in intro_binding.rs:
fn scoped2() -> Vec<u32>
{
vec![1, 2, 3]
}
This usage tracking can get complicated (and undecidable), so Rust has some rules that restrict when a variable can escape a context. We call this complex rules ownership. It can be explained with the following code, in intro_binding.rs:
fn scoped3()
{
let v1 = vec![1, 2, 3];
let v2 = v1;
//it is now illegal to reference v1
//ownership has been transferred to v2
}
When it is not possible or desirable to transfer ownership, the clone trait is encouraged to create a duplicate copy of whatever data is referenced in intro_binding.rs:
fn scoped4()
{
vec![1, 2, 3].clone();
"".to_string().clone();
}
Cloning or copying is not a perfect solution, and comes with a performance overhead. To make Rust faster, and it is pretty fast, we also have the concept of borrowing. Borrowing is a mechanism to receive a direct reference to some data with the promise that ownership will be returned by some specific point. References are indicated by an ampersand. Consider the following example, in intro_binding.rs:
fn scoped5()
{
fn foo(v1: &Vec<u32>)
{
for v in v1
{
println!("{}", v);
}
}
let v1 = vec![1, 2, 3];
foo(&v1);
//v1 is still valid
//ownership has been returned
v1;
}
Another benefit of strict ownership is safe concurrency. Each binding is owned by a particular thread, and that ownership can be transferred to new threads with the move keyword. This has been explained with the following code, in intro_binding.rs:
use std::thread;
fn thread1()
{
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().ok();
}
To share information between threads, programmers have two main options.
First, programmers may use the traditional combination of locks and atomic references. This is explained with the following code, in intro_binding.rs:
use std::sync::{Mutex, Arc};
use std::thread;
fn thread2()
{
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
Second, channels provide a nice mechanism for message passing and job queuing between threads. The send trait is also implemented automatically for most objects. Consider the following code, in intro_binding.rs:
use std::thread;
use std::sync::mpsc::channel;
fn thread3() {
let (sender, receiver) = channel();
let handle = thread::spawn(move ||{
//do work
let v = vec![1, 2, 3];
sender.send(v).unwrap();
});
handle.join().ok();
receiver.recv().unwrap();
}
All of this concurrency is type-safe and compiler-enforced. Use threads as much as you want, and if you accidentally try to create a race condition or simple deadlock, then the compiler will stop you. We call this fearless concurrency.
Algebraic datatypes
In addition to structs/objects and functions/methods, Rust functional programming includes some rich additions to definable types and structures. Tuples provide a shorthand for defining simple anonymous structs. Enums provide a type-safe approach to unions of complex data structures with the added bonus of a constructor tag to help in pattern matching. The standard library has extensive support for generic programming, from base types to collections. Even the object system traits are a hybrid cross between the OOP concept of a class and the FP concept of type classes. Functional style lurks around every corner, and even if you don't seek them in Rust, you will probably find yourself unknowingly using the features.
The type aliases can be helpful to create shorthand names for complex types. Alternatively, the newtype struct pattern can be used to create an alias with different non-equivalent types. Consider the following example, in intro_datatypes.rs:
//alias
type Name = String;
//newtype
struct NewName(String);
A struct, even when parameterized, can be repetitive when used simply to store multiple values into a single object. This can be seen in intro_datatypes.rs:
struct Data1
{
a: i32,
b: f64,
c: String
}
struct Data2
{
a: u32,
b: String,
c: f64
}
A tuple helps eliminate redundant struct definitions. No prior type definitions are necessary to use tuples. Consider the following example, in intro_datatypes.rs:
//alias to tuples
type Tuple1 = (i32, f64, String);
type Tuple2 = (u32, String, f64);
//named tuples
struct New1(i32, f64, String);
struct New2(u32, String, f64);
Standard operators can be implemented for any type by implementing the correct trait. Consider the following example for this, in intro_datatypes.rs:
use std::ops::Mul;
struct Point
{
x: i32,
y: i32
}
impl Mul for Point
{
type Output = Point;
fn mul(self, other: Point) -> Point
{
Point
{
x: self.x * other.x,
y: self.y * other.y
}
}
}
Standard library collections and many other built-in types are generic, such as HashMap in intro_datatypes.rs:
use std::collections::HashMap;
type CustomHashMap = HashMap<i32,u32>;
Enums are a type-safe union of multiple types. Note that recursive enum definitions must wrap the inner value in a container such as Box, otherwise the size would be infinite. This is depicted as follows, in intro_datatypes.rs:
enum BTree<T>
{
Branch { val:T, left:Box<BTree<T>>, right:Box<BTree<T>> },
Leaf { val: T }
}
Tagged unions are also used for more complex data structures. Consider the following code, in intro_datatypes.rs:
enum Term
{
TermVal { value: String },
TermVar { symbol: String },
TermApp { f: Box<Term>, x: Box<Term> },
TermAbs { arg: String, body: Box<Term> }
}
Traits are a bit like object classes (OOP), shown with the following code example, in intro_datatypes.rs:
trait Data1Trait
{
//constructors
fn new(a: i32, b: f64, c: String) -> Self;
//methods
fn get_a(&self) -> i32;
fn get_b(&self) -> f64;
fn get_c(&self) -> String;
}
Traits are also like type classes (FP), shown with the following code snippet, in intro_datatypes.rs:
trait BehaviorOfShow
{
fn show(&self) -> String;
}
Mixing object-oriented programming and functional programming
As mentioned before, Rust supports much of both object-oriented and functional programming styles. Datatypes and functions are neutral to either paradigm. Traits specifically support a hybrid blend of both styles.
First, in an object-oriented style, defining a simple class with a constructor and some methods can be accomplished with a struct, trait, and impl. This is explained using the following code snippet, in intro_mixoopfp.rs:
struct MyObject
{
a: u32,
b: f32,
c: String
}
trait MyObjectTrait
{
fn new(a: u32, b: f32, c: String) -> Self;
fn get_a(&self) -> u32;
fn get_b(&self) -> f32;
fn get_c(&self) -> String;
}
impl MyObjectTrait for MyObject
{
fn new(a: u32, b: f32, c: String) -> Self
{
MyObject { a:a, b:b, c:c }
}
fn get_a(&self) -> u32
{
self.a
}
fn get_b(&self) -> f32
{
self.b
}
fn get_c(&self) -> String
{
self.c.clone()
}
}
Adding support for functional programming onto an object is as simple as defining traits and methods that use functional language features. For example, accepting a closure can become a great abstraction when used appropriately. Consider the following example, in intro_mixoopfp.rs:
trait MyObjectApply
{
fn apply<F,R>(&self, f:F) -> R
where F: Fn(u32,f32,String) -> R;
}
impl MyObjectApply for MyObject
{
fn apply<F,R>(&self, f:F) -> R
where F: Fn(u32,f32,String) -> R
{
f(self.a, self.b, self.c.clone())
}
}