Loops and Arrays
Loops and arrays are fundamental concepts in all programming languages.
So we will assume that you've already written loops and arrays in other languages.
This section will tell you how to write loops and arrays in Rust and at the last,
we will introduce the ndarray
crate, which is a powerful library for numerical
computing in Rust.
Loop and while
Rust does not define a for
loop like in C/C++ or Java. Instead, it's like Python's.
For example, to iterate over a range of numbers, you can use the for
loop like this:
#![allow(unused)] fn main() { for i in 0..10 { println!("i = {i}"); } }
which will print numbers from 0 to 9. If you'd include the tail number, you can use 0..=10
:
#![allow(unused)] fn main() { for i in 0..=10 { println!("i = {i}"); } }
It's an iterator-like syntax because ..
gives a range iterator just like range()
in Python.
So similarly, Rust also has
x..
to create a slice like[x:]
in Python, without a right bound...y
to create a slice like[:y]
in Python, without a left bound.
Infinite loops can be created using loop
:
#![allow(unused)] fn main() { loop { println!("This will run forever!"); // break; } }
Iterators
The concept of iterators originates from functional programming languages, which will be discussed
in the next chapter. But for now, we just focus on how to use iterators in Rust. To use an iterator,
the stuff you'd iterate over must implement the Iterator
and IntoIterator
trait. We don't need to
worry about this here.
- The point of having a
Iterator
is that it implements thenext()
method, which is the core of the iterator concept. FYI, an iterator is a stateful object that returns a sequence of values bynext()
. You don't need to care about the internal state of the iterator, just callnext()
to get the next value. - The
IntoIterator
trait is used to convert a collection into an iterator. It provides theinto_iter()
method, which returns an iterator over the collection. This is similar to Python'siter()
function.
For example, the for
loop we mentioned above can be rewritten using an iterator:
#![allow(unused)] fn main() { let mut iter = (0..10).into_iter(); while let Some(i) = iter.next() { // Some<T> is exlained in the next chapter println!("i = {i}"); } }
and using iterators can give you more flexibility, such as filtering
#![allow(unused)] fn main() { let mut iter = (0..10).into_iter().filter(|&x| x % 2 == 0) while let Some(i) = iter.next() { println!("i = {i}"); // i is even } }
or stepping through the range:
#![allow(unused)] fn main() { for i in (0..10).into_iter().step_by(2) { println!("i = {i}"); // i is even } }
Why do we need iterators? Because they are lazy! They don't compute the values until you actually need them. Second, they can be easily composed together to create complex data processing pipelines. We will see a more advanced example in the next chapter when we talk about functional programming.
Array (Slice and Vec)
Array in Rust is a fixed-size collection of elements of the same type. It is similar to arrays in C/C++ or Java
but it comes with a formal definition: [T; N]
, where T
is the type of the elements and N
is the size of the array
(it's a generic). So array with different sizes are also different types. Use array as you want
#![allow(unused)] fn main() { let mut arr: [i32; 5] = [0; 5]; // an array of 5 i32 initialized to 0 // ^^^ mut is important let arr2 = [1, 2, 3, 4, 5]; arr[0] = 1; // set the first element to 1 println!("{:?}", arr); // print the array }
To iterate over an array, you can use a for
loop:
#![allow(unused)] fn main() { for i in &arr { // note the reference println!("i = {i}"); } }
Slicing is a way to create a view into a part of an array. It has the annotation &[T]
, which is a reference to a slice
of type T
and looks like Python's.
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let s = &a[1..4]; // slice from index 1 to 3 (4 is excluded) assert_eq!(s, [2, 3, 4]); }
You may notice the reference &
in the slice. This is because Rust is strict about ownership and borrowing, which will
be discussed in the next chapter. For now, just remember that slices are references to parts of arrays.
To create a dynamic array, Rust provides the Vec
type, which is a growable array. Here's how you can use it:
#![allow(unused)] fn main() { let mut v: Vec<i32> = Vec::new(); // create an empty vector v.push(1); // add an element to the vector v.push(2); v.push(3); // add more elements println!("{:?}", v); // print the vector let mut v = vec![1, 2, 3]; // create a vector with initial elements v.push(4); // add an element to the vector println!("{:?}", v); // print the vector let mut v = vec![0; 5]; // create a vector of 5 elements initialized to 0 v[0] = 1; // set the first element to 1 println!("{:?}", v); // print the vector }
And to play vector with iterator, you can
#![allow(unused)] fn main() { let v: Vec<_> = (114..514).into_iter().collect(); // or a generic way let v = (114..514).into_iter().collect::<Vec<_>>(); }
As in other vectors (C++/Java/Python), the dynamic array is costly to resize because of remalloc
and moving data,
so it will be better to preallocate the size of the vector if you know it in advance:
#![allow(unused)] fn main() { let mut v: Vec<i32> = Vec::with_capacity(100); // create a vector with capacity 100 for i in 0..100 { v.push(i); // add elements to the vector } }
ndarray
It's good to have the native Array
and Vec
types in Rust, but they are not enough for numerical computing.
For example, creating a 2D array (matrix) is not straightforward and not efficient with the native types
#![allow(unused)] fn main() { let mut mat = vec![vec![0; 3]; 4]; // a 4x3 matrix initialized to 0 mat[0][0] = 1; // set the first element to 1 println!("{:?}", mat); // print the matrix }
Too much vector nesting, and the performance is not good because of the memory layout. A intuitive way to create a 2D array is flatten it into a 1D array and wrap it in a struct with metadata
#![allow(unused)] fn main() { struct Matrix { data: Vec<i32>, // the data of the matrix rows: usize, // the number of rows cols: usize, // the number of columns } impl Matrix { fn new(rows: usize, cols: usize) -> Self { Matrix { data: vec![0; rows * cols], // initialize the data to 0 rows, cols, } } // yeah I hate the getter and setter // we can write in a better way but it's not the point here fn get(&self, row: usize, col: usize) -> i32 { self.data[row * self.cols + col] // access the element at (row, col) } fn set(&mut self, row: usize, col: usize, value: i32) { self.data[row * self.cols + col] = value; // set the element at (row, col) } } }
and that's what ndarray
crate does for you. To equip with it, execute in the terminal
cargo add ndarray
then, you can use it like this:
#![allow(unused)] fn main() { use ndarray::{array, Array2}; let a = Array2::zeros((4, 3)); // create a 4x3 matrix filled with zeros println!("{:?}", a); let b = array![[1, 2, 3], [4, 5, 6]]; // create a 2D array (matrix) with initial values println!("{:?}", b); }
index the matrix is also straightforward:
#![allow(unused)] fn main() { let a = array![[1, 2, 3], [4, 5, 6]]; println!("a[0, 0] = {}", a[[0, 0]]); }
For slicing and iterating, ndarray
provides a lot of methods to manipulate the array easily:
#![allow(unused)] fn main() { use ndarray::{array, s}; let a = array![[1, 2, 3], [4, 5, 6]]; let b = a.slice(s![.., 0..1, ..]); }
more about slicing and iterating can be found in the ndarray
documentation.
Moreover, ndarray
provides lots of optimizations! View it next chapter.
String
String is complicated in Rust so we won't talk about it in detail here. Refer to this.