..

No rust, no pain

Note trong quá trình tự học Rust của mình

Setup môi trường

Trên fedora có thể tải như sau:

sudo dnf install rust cargo

Hoặc tải rustup, đây là công cụ quản lý phiên bản của Rust.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Sau này nếu muốn update ta chỉ cần chạy lệnh rustup update là xong.

Mới đầu học rust thì mình nghĩ đọc official doc của rust là ngon nhất rồi: Doc

Về cargo có thể xem tại đây: Cargo

Đối với vscode thì mọi người tải extension rust-analyzer để hỗ trợ intellisense và nhiều thứ khác khi code rust.

Tạo project mới:

cargo new hello_world
cd hello_world
code .

Với rust-analyzer cài sẵn thì mọi người có thể chọn run hoặc debug ở trên đầu chương trình luôn:

...

Cargo còn giúp ta quản lí các thư viện ngoài hay các dependencies từ https://crates.io/

Ví dụ ta muốn tải thư viện rand để làm việc với các hàm sinh số ngẫu nhiên thì ta thêm dòng này vào trong file Cargo.toml và gõ Cargo build

[dependencies]
rand = "0.8.5"

Cargo sẽ tự động tải các gói cần thiết về và ta có thể sử dụng được thư viện này.

Ok vậy là phần setup đã xong, bây giờ mình sẽ đi tìm hiểu một số tính năng cũng như cú pháp của rust.

Biến và kiểu dữ liệu

Kiểu dữ liệu trong Rust khá đặc biệt, được chia làm 2 loại là Mutable và Immutable.

Để khởi tạo một biến trong Rust, ta sẽ dùng từ khóa let , mặc định khi ta tạo một biến mới thì biến đó sẽ được định nghĩa là Immutable tức là không thể thay đổi. Điều này có nghĩa là sau khi khởi tạo biến, ta không thể thay đổi giá trị của nó nữa.

Ví dụ mình có đoạn chương trình sau đây:

fn main(){
	let x = 5; 
	println!("x = {}" , x); 
	x +=1;
}

Thì sẽ gặp ngay lỗi:

(base)   rustt rustc main.rs
warning: value assigned to `x` is never read
 --> main.rs:4:5
  |
4 |     x += 1;
  |     ^
  |
  = help: maybe it is overwritten before being read?
  = note: `#[warn(unused_assignments)]` on by default

error[E0384]: cannot assign twice to immutable variable `x`
 --> main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("x = {}", x);
4 |     x += 1;
  |     ^^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

error: aborting due to 1 previous error; 1 warning emitted

For more information about this error, try `rustc --explain E0384`.
(base)   rustt

Đọc log thì mọi người để ý lỗi ở đây là cannot assign twice to immutable variable và cách xử lí đơn giản là ta thêm từ khóa mut ở trước biến x khi khai báo.

Rust đảm bảo rằng nếu ta khai báo một giá trị là immutable thì nó sẽ thực sự không thay đổi, điều này giúp tránh việc có một đoạn code nào đó làm thay đổi giá trị của biến mà ta không mong muốn.

Ngoài ra còn có một khái niệm đó là Shadowing, tức là ta khai báo hai biến cùng một tên và khác kiểu giá trị, như vậy biến được khai báo lần thứ hai sẽ làm lu mờ đi biến thứ nhất.

fn main(){
	let x = 5; 
	let x = 6; 
	println!("x = {}" , x); 
	x +=1;
}
warning: unused variable: `x`
 --> main.rs:2:9
  |
2 |     let x = 5;
  |         ^ help: if this is intentional, prefix it with an underscore: `_x`
  |
  = note: `#[warn(unused_variables)]` on by default

warning: 1 warning emitted

(base)   rustt ./main
x = 6
(base)   rustt

Về phần kiểu dữ liệu thì thông thường Rust có thể đoán được kiểu dữ liệu của biến. Nó sẽ trông như thế này:

...

Và lưu ý một điều là các biến trong Rust một khi được khai báo trong scope thì nó sẽ chỉ tồn tại trong scope đó mà thôi. Scope ở đây là cặp ngoặc nhọn {}. Khi ra khỏi scope thì chúng sẽ bị xóa đi để giải phóng bộ nhớ.

Ngoài khai báo biến thì ta có thể khai báo constants hay còn gọi là hằng số.

Khi khai báo thì bắt buộc phải có khóa const và kiểu dữ liệu

Quy ước đặt tên của Rust cho các hằng số là sử dụng tất cả chữ hoa và dấu gạch dưới giữa các từ.

fn main()
{
	const MAX_SCORE : u32 = 100_000; 
	println!("Max score: {}" , MAX_SCORE);  
}

Các kiểu dữ liệu bao gồm:

Đầu tiên là scalar types.

Scalar type là kiểu dữ liệu đại diện cho một giá trị duy nhất. Trong rust thì ta có integers, floating-point numbers (số dấu chấm động), Booleans và characters, tương tự như các ngôn ngữ lập trình khác.

Integer Types

image

image

Trong Rust thì một byte đơn lẻ sẽ là

let b: u8 = b'a'; 
println!("{}",b);

Còn đối với một chuỗi byte thì dùng b"abc"(&[u8;3]) Ví dụ như này:

    let b = b"abc"; 
    for byte in b{
        println!("{}", byte)
    }

Floating-Point Types

Floating thì có 2 kiểu là f32f64 lần lượt là 32 và 64 bit kích thước:

fn main(){
	let x = 2.0; // f64 
	let y: f32 = 3.0; //f32
}

Các phép toán cơ bản trong Rust:

fn main(){
	let x = 5;
	let y = 5; 
	let sum = x + y; 
	let diff = x - y ;
	let product = x*y; 
	let remainder = 43 % 5; 
}

Đối với phép chia thì nếu cả hai số là i32 thì sẽ chia lấy phần nguyên còn nếu số thực thì chia như bình thường, kết quả trả về cũng là số thực

let quo = 56.7 /32.1 ; // 1.7663551
let trunc = -5/3; // -1

The Boolean Type

Boolean type thì có 2 giá trị là true hoặc false.

Nó chỉ nhận 2 giá trị duy nhất là true hoặc false.

Khác với python ở chỗ này. Như trong python thì ta có 15 hay {0},[0] đều được tính là true khi ta ép kiểu bool cho nó.

The Character Type

Char được khai báo như sau:

fn main(){
	let c = 'z'; 
	let z: char = 'Z'; 
	let cat = '😻';
}

Lưu ý rằng ở đây ta dùng dấu nháy đơn chứ không phải dấu nháy kép như string. Hơn nữa char trong rust có kích thước là 4 byte (unicode scalar value) nên nó lưu được mọi kí tự unicode chứ không chỉ ASCII.

Và chỉ được lưu một kí tự duy nhất. Tức là ta không được lưu kiểu

let c = 'AB'

Compound Types

Compound types là kiểu dữ liệu có thể nhóm nhiều giá trị lại với nhau thành một kiểu dữ liệu duy nhất. Trong rust có hai kiểu dữ liệu cơ bản thuộc loại này đó là tuple và arrays

Tuple thì có thể gộp nhiều kiểu dữ liệu vào một, chẳng hạn như:

let tup: (i32, f64, u8) = (500, 6.4, 1);

Kích thước của tuple là cố định và không thay đổi sau khi khai báo.

let (x, y, z) = tup;   // destructuring
println!("{}", y);     // 6.4

println!("{}", tup.0); // 500
println!("{}", tup.1); // 6.4

Tuple có thể rỗng:

let unit = (); 

() được gọi là unit type (giống với void trong C++)

Tiếp theo là array. Cú pháp khai báo sẽ là tên biến: [kiểu dữ liệu; kích thước]

let arr: [i32; 4] = [1,2,3,4]; 

Tương tự như python thì chỉ số của mảng cũng bắt đầu bằng 0.

Ngoài ra ta cũng có thể khởi tạo nhanh một mảng rỗng như sau:

let zeros = [0;5]; // [0,0,0,0,0]

Bây giờ ta sẽ đào sâu hơn về array.

Cú pháp khai báo thì có 2 cách, 1 là ta gắn kiểu và độ dài rõ ràng như trên, 2 là để cho rust tự suy luận kiểu dữ liệu

let arr = [1,2,3,4]; 
let arr : [i32;3] = [20,30,40];

Hoặc dùng const như sau:

const N: usize = 4;
let arr = [1;N]; 

Trong đó usize là kiểu số nguyên dùng làm chỉ số mảng

Mảng lồng nhau: Tạo ma trận hai chiều

let matrix = [
[1,2,3],[4,5,6],];
println!("{}", matrix[1][2]); //6

Lấy độ dài của mảng bằng

println!("{}", arr.len()); 

Kiểm tra có rỗng không:

if arr.is_empty(){...}

Truy cập vào phần tử đầu vào cuối:

println!("{:?}", arr.first()); // Some(1)
println!("{:?}", arr.last());  // Some(4)

Dùng cú pháp .iter_mut() để cho phép thay đổi giá trị khi duyệt

let mut arr = [1,2,3]; 
for x in arr.iter_mut(){
	*x *= 2; 
}
println!("{:?}", arr); // 

Dùng map, giống list comprehension bên python:

let doubled = arr.map(|x| x * 2);
println!("{:?}", doubled); //

Kiểm tra có tồn tại giá trị cụ thể hay không:

if arr.contains(&3) {
    println!("Found 3!");
}

Lấy giá trị ở vị trí cụ thể:

println!("{:?}", arr.get(10)); //None

Nếu vượt quá giới hạn thì trả về Option<&T> thay vì thông báo panic.

Ta có thể dùng slicing để lấy mảng con:

let slc = &arr[..2]; // phan tu [0..2)

Là lấy 2 phần tử đầu tiên.

Code test:

fn main() {
    let mut arr = [1,2,3];
    for x in arr.iter_mut(){
        *x *= 2;
    }
    println!("{:?}", arr); 
    println!("{}", arr.len());
    println!("{}", arr.is_empty()); 
    println!("{:?}", arr.first());
    println!("{:?}", arr.last()); 
    let doubled = arr.map(|x| x * 2);
    println!("{:?}", doubled); // [4,8,12]
    if arr.contains(&3) {
    println!("Found 3!");
} else{
    println!("Đéo có đâu");}
    println!("{:?}", arr.get(1));
    let slc = &arr[..2];
    println!("{:?}", slc);
}

References và Borrowing

Stack & Heap

Nhiều ngôn ngữ lập trình không yêu cầu ta phải hiểu rõ về stack và heap quá rõ ràng. Tuy nhiên đối với một ngôn ngữ lập trình hệ thống như Rust thì việc hiểu rõ hai khái niệm này khá quan trọng vì một giá trị nằm trên stack hay heap sẽ ảnh hưởng đến cách nó hoạt động và tương tác với các thành phần khác trong chương trình.

Đầu tiên, cả stack và heap đều là những vùng nhớ có sẵn giúp lưu trữ giá trị khi thực thi chương trình. (chúng sẽ được lưu trữ trong RAM)

  • Stack hoạt động theo cơ chế LIFO. Các giá trị lưu trên stack đều có kích thước cố định và được biết trước tại compile time. Bộ nhớ stack thường dùng để lưu biến cục bộ trong hàm và tham số truyền vào. Việc cấp phát và giải phóng bộ nhớ trên stack diễn ra rất nhanh vì chỉ cần push/pop stack frame khi chương trình chạy.
  • Còn bộ nhớ Heap được dùng để lưu trữ vùng nhớ cho những biến con trở được cấp phát động. Nó là một vùng nhớ mà các giá trị được lưu trên đó không xác định kích thước (dùng để lưu những phần tử có kích thước thay đổi). Trên heap, một lượng bộ nhớ sẽ được cấp phát cùng với đó là trả về 1 con trỏ lưu địa chỉ của vị trí vừa được cấp phát. Con trỏ chứa địa chỉ ô nhớ với kích thước cố định và được lưu ở stack

image

Ownership

Ownership là một khái niệm hoàn toàn mới trong Rust, có chức năng đảm bảo tính an toàn, tối ưu cho bộ nhớ mà không cần đến garbage collector như trong Python hoặc một số ngôn ngữ khác như C/C++ yêu cầu người dùng phải tự giải phóng bộ nhớ bằng tay.

Ownership được định nghĩa là một bộ các quy tắc chỉ định cách quản lý bộ nhớ trong Rust và nếu bất kì quy tắc nào bị vi phạm thì chương trình sẽ không được biên dịch.

Các quy tắc như sau:

  • Mỗi giá trị trong Rust có một biến được gọi là chủ sở hữu (ownership) của nó
  • Chỉ có thể có một chủ sở hữu tại một thời điểm
  • Khi chủ sở hữu đi ra khỏi phạm vi, bộ nhớ lưu giá trị đó sẽ được giải phóng

Quy tắc đầu tiên:

fn main(){
	let s = String::from("hello");
}

"hello" nằm trong heap và s là biến duy nhất sở hữu nó.

Quy tắc thứ hai:

fn main(){
	let s1 = String::from("Hello"); 
	let s2 = s1; 
	println!("{}",s1); 
}

Khi s1 được gán cho s2 thì Rust chuyển quyền sở hữu dữ liệu cho s2 chứ không copy dữ liệu heap. Như vậy s1 sẽ bị vô hiệu ngay lập tức sau khi ta gọi s2. Nếu ta chuyển thành println!("{}", s2); thì chương trình chạy lại bình thường.

Nhưng hãy xem xét kĩ hơn, ta hãy cùng xem các giá trị lưu trữ trên biến s1:

image

Biến s1 lưu trữ 3 thông tin trên stack:

  • con trỏ ptr trỏ đến nội dung của chuỗi trên heap
  • len lưu độ dài của chuỗi (tính bằng bytes)
  • capacity lưu kích thước bộ nhớ đang được cấp phát cho chuỗi (tính bằng bytes)

image

Khi ta gán let s2 = s1; thì trình biên dịch sẽ hiểu là ta đang copy nông thay vì copy sâu. Do đó chỉ phần con trỏ, len và capacity được copy thay vì giữ liệu trên heap và từ đó tiết kiệm được 1 lượng bộ nhớ trên heap.

image

Nếu như cấp phát như thế này thì khi ra khỏi scope , rust sẽ tự động gọi hàm drop() và giải phóng bộ nhớ Heap 2 lần và gây ra “double free error”

Quy tắc thứ ba:

fn main(){
	{let s = String::from("Hello");}
	println!("{:?}",s);
}

Gặp ngay lỗi:

help: the binding `s` is available in a different scope in the same function

Khi ra khỏi scope thì vùng nhớ heap sẽ được giải phóng ngay

Lưu ý là trong Rust chỉ có một số kiểu dữ liệu đặc biệt được cấp phát trên heap còn đa số dữ liệu sẽ nằm trên stack

Stack: Mọi giá trị có kích thước cố định, nhỏ, xác định tại compile time đều nằm trực tiếp trên stack

let x: i32 = 10; 
let b: bool = true; 
let c: char = 'A'; 
let tup = (1,2.0,false); 

Heap: sẽ dùng cho những dữ liệu có kích thước quá lớn để lưu trực tiếp trên stack hoặc ta không biết được trước kích thước của nó.

Như trên thì biến kiểu String sẽ được cấp phát trên Heap, ta có thể thay đổi nội dung, độ dài tùy ý của nó.

Với str literal thì ta không làm được như vậy:

fn main() {
   let mut a = "conglt";
   println!("{}", a);
}
   Compiling playground v0.0.1 (/playground)
warning: variable does not need to be mutable
 --> src/main.rs:2:8
  |
2 |    let mut a = "conglt";
  |        ----^
  |        |
  |        help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

Control Flow

Tương tự các ngôn ngữ lập trình khác thì Rust cũng có 3 câu lệnh điều khiển luồng cơ bản là if, while, for.

If

Lệnh điều kiện if cho phép ta rẽ nhánh dựa trên các điều kiện. Syntax cơ bản:

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Nếu có nhiều trường hợp hơn thì thêm else if vào

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

while, loop, for

Rust có 3 vòng lặp đó là while, loop, for . Ví dụ như sau:

Vòng lặp loop:

fn main(){
    let mut count = 0; 
    loop {
        count +=1; 
        if count == 5{
            break;
        }
        println!("count: {}", count);
    }
}

Loop có thể lặp vô hạn nếu ta không thêm break; ở trong:

fn main() {
    loop {
        println!("again!");
    }
}

Có thể interrupt bằng ctrl+c

Loop cũng có thể trả về giá trị nếu ta thêm giá trị trả về vào ngay sau câu lệnh break

fn main(){
    let _res = loop {
        break 10 * 2; 
    };
    println!("{}", _res)
}

Thêm nữa là nếu như ta xài nhiều vòng loop lồng nhau thì có thể gán nhãn cho chúng để lệnh breakcontinue biết đang trỏ tới vòng loop nào:

Ví dụ:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

Kết quả:

count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

Vòng lặp while:

Lặp khi điều kiện còn đúng:

let mut n = 3;
while n > 0 {
    println!("{}!", n);
    n -= 1;
}
println!("Hết giờ!"); 

Vòng lặp for:

Duyệt qua một range hoặc iterator:

for i in 1..5 { // 1, 2, 3, 4
    println!("i = {}", i);
}

let arr = [10, 20, 30];
for val in arr.iter() {
    println!("Giá trị: {}", val);
}

Ngoài ra ta còn có:

match

Ví dụ:

let number = 2;

match number {
    1 => println!("Một"),
    2 | 3 => println!("Hai hoặc ba"), // nhiều trường hợp
    4..=6 => println!("Từ 4 đến 6"), // range
    _ => println!("Khác"),           // mặc định
}

Functions

Hàm là một nhóm các đoạn code thực hiện một công việc nào đó và có thể được gọi để sử dụng ở nhiều nơi khác nhau. Trong một chương trình Rust có thể viết bao nhiêu hàm cũng được.

Ta đã biết: Hàm main() là điểm khởi đầu của mọi chương trình rust.

Rust cũng có quy tắc đặt tên hàm riêng tương tự như quy tắc đặt tên cho hằng số. Quy tắc đặt tên hàm trong rust sẽ là viết thường và cách nhau bằng dấu gạch dưới _ (snake case).

Cú pháp đơn giản như sau:

fn ten_ham(tham_so1: kieu, tham_so2: kieu) -> kieutrave {
	// than ham 
}

Nếu muốn return về giá trị thì ta không thêm dấu chấm phẩy ở dòng cuối cùng của hàm, nếu không thì trình biên dịch sẽ hiểu đây là hàm thực thi chứ không trả về giá trị.

fn main() {
    fn tinh_tong(a:i32, b:i32)->i32{
        a+b
    }
    let a:i32 = 5; 
    let b:i32 = 7;
    let tong:i32= tinh_tong(a,b);
    println!("{}", tong); 
}

Hàm không trả về dữ liệu (hàm thực thi):

fn xinchao(){
    println!("Xin chao"); 
}

fn main() {
    xinchao(); 
}

Hoặc thêm return vào như sau:


fn kiem_tra(x: i32) -> i32 {
    if x < 0 {
        return 0;
    }
    x * 2
}
fn main() {
    let x = -7; 
    let test = kiem_tra(x); 
    println!("test = {}", test); 
}