Bỏ qua để đến nội dung

Rust cho lập trình viên JavaScript và TypeScript

Nếu bạn viết JavaScript hay TypeScript, bạn đã biết về Rust nhiều hơn bạn nghĩ. Rust có closure hoạt động rất giống arrow function, có async/await, có các phương thức iterator mang những cái tên quen thuộc như map, filter và find, có destructuring, và có một công cụ dòng lệnh (Cargo) làm những việc mà npm vẫn làm. Nếu bạn dùng TypeScript, bạn cũng đã quen với một trình biên dịch bắt lỗi trước khi code chạy. Trình biên dịch của Rust làm điều tương tự, chỉ là khắt khe hơn.

Vậy cái gì thật sự mới? Có ba điều. Rust không có bộ thu gom rác (garbage collector): mỗi giá trị có đúng một chủ sở hữu, và trình biên dịch kiểm tra quyền sở hữu (ownership) và mượn (borrowing) trước khi chương trình chạy. Rust không có undefined hay null: một giá trị có thể bị thiếu sẽ có kiểu Option. Và Rust không có exception: một hàm có thể thất bại sẽ trả về một Result. Phần lớn thời gian học của bạn sẽ dành cho ba ý tưởng này.

Tại sao lập trình viên JavaScript học Rust? Thường là vì một trong những lý do sau: bạn muốn một web backend hay công cụ dòng lệnh thật nhanh, đóng gói trong một file duy nhất; bạn muốn tăng tốc một điểm nóng trong trình duyệt bằng WebAssembly; bạn muốn viết một native addon cho Node.js; hoặc bạn tò mò về những công cụ mình đang dùng hằng ngày. SWC, Biome, Oxc, Rolldown, Rspack, Turbopack, Lightning CSS và Deno đều được viết bằng Rust.

Trang này đối chiếu những gì bạn biết từ JavaScript và TypeScript sang Rust, để bạn dành thời gian cho những phần thực sự mới. Nếu bạn muốn một bài so sánh chung với C++, Go và Python thì hãy xem Rust so với C++, Go và Python.

Khái niệm ngôn ngữ: từ JavaScript và TypeScript sang Rust

Phần tiêu đề “Khái niệm ngôn ngữ: từ JavaScript và TypeScript sang Rust”

Hãy dùng bảng này như một cuốn sổ tay hội thoại. Cột cuối dẫn đến bài học trên trang này giải thích phía Rust.

JavaScript / TypeScript Rust Tìm hiểu thêm
let và const let (không đổi được) và let mut (đổi được). Một object const của JS vẫn có thể bị thay đổi bên trong; một giá trị let của Rust thì không Biến
number Bạn chọn kích thước: i32, i64, u32, usize, f64, … Số nguyên và số thực là các kiểu khác nhau Kiểu dữ liệu
bigint i128 / u128, hoặc crate num-bigint cho số lớn tùy ý Kiểu dữ liệu
string String (sở hữu, có thể nới rộng) và &str (một góc nhìn mượn vào văn bản) Chuỗi
boolean bool Kiểu Boolean
Template literal format!("Hi {name}") và println!("Hi {name}") In ra màn hình
any, unknown Không có any. Dùng generic, một enum, hoặc serde_json::Value cho JSON không rõ cấu trúc Generic, JSON với Serde
interface hoặc type mô tả hình dạng một object struct Struct
interface mô tả các phương thức trait Trait
Structural typing (cùng hình dạng = cùng kiểu) Nominal typing: hai struct có cùng field vẫn là hai kiểu khác nhau Struct
Union type và discriminated union enum, mà mỗi biến thể (variant) có thể mang dữ liệu riêng Enum
String literal type như "small" hoặc "large" Một enum với các biến thể đơn giản: enum Size { Small, Large } Enum
undefined, null, thuộc tính tùy chọn x?: T Option<T>: hoặc Some(value) hoặc None Option
?. và ?? ? (bên trong một hàm trả về Option), .map(...), .and_then(...), .unwrap_or(...) Option
Non-null assertion x! .unwrap() hoặc .expect("lý do"), sẽ dừng chương trình nếu giá trị là None Panic
throw, try/catch, Promise bị reject Result<T, E> + toán tử ? Result
class MyError extends Error Enum lỗi của riêng bạn (thường dùng thiserror); anyhow trong ứng dụng Lỗi tự định nghĩa, anyhow và thiserror
class có field và phương thức struct cho dữ liệu + một khối impl cho các phương thức Struct
extends (kế thừa) Không có kế thừa. Dùng composition (struct nằm trong struct) + trait có phương thức mặc định Trait, Mẫu thiết kế
class Dog implements Animal impl Animal for Dog Trait
Một mảng chứa nhiều class khác nhau cùng chung một interface Vec<Box<dyn Animal>> (một trait object) Trait object
Generic kiểu T extends HasId Generic với ràng buộc trait (trait bound): T: HasId Generic
Arrow function và callback Closure, được mô tả bằng các trait Fn, FnMut, FnOnce Closure, Fn, FnMut và FnOnce
this Một tham số self, &self hoặc &mut self viết rõ ra. Không còn bất ngờ về chuyện this trỏ tới đâu Struct
Destructuring const { x, y } = point Pattern: let Point { x, y } = point; (dùng được cả trong match và tham số hàm) So khớp mẫu
Spread { ...user, admin: true } Cú pháp cập nhật struct: User { admin: true, ..user } Struct
switch, hoặc chuỗi if dựa trên typeof / kind match (trình biên dịch kiểm tra bạn đã xử lý mọi trường hợp) match
=== ==, so sánh giá trị (thông qua trait PartialEq). Không bao giờ tự ép kiểu Các trait thông dụng
for...of for item in &items Vòng lặp for
map, filter, reduce, find, some, every Các phương thức iterator: map, filter, fold, find, any, all, rồi collect Iterator
Generator (function*) và Symbol.iterator Triển khai trait Iterator Iterator tự định nghĩa
Promise<T> Một Future với Output = T. Khác với Promise, nó không làm gì cả cho tới khi được await Async/Await
async / await async fn và .await, kèm một runtime như tokio để chạy chúng Async/Await, Async chuyên sâu
Promise.all tokio::join!, hoặc join_all từ crate futures Async chuyên sâu
setTimeout tokio::time::sleep trong code async, std::thread::sleep trong các trường hợp khác Async/Await
Web Worker, worker_threads Luồng thật: std::thread::spawn, hoặc rayon cho vòng lặp song song Lập trình đồng thời
ES module: import, export Module: mod, use và pub Module
JSON.parse / JSON.stringify serde_json với #[derive(Serialize, Deserialize)] JSON với Serde
console.log println!, và dbg! để debug nhanh. Dùng {:?} cho các kiểu derive Debug In ra màn hình
readonly, as const, Object.freeze Không cần: mọi thứ đều chỉ đọc trừ khi bạn viết mut Biến
Decorator Attribute như #[derive(Debug)] và macro Macro
Number("abc") cho ra NaN "abc".parse::<i32>() trả về một Err mà bạn phải xử lý Ép kiểu, Chuyển đổi
Bộ thu gom rác Quyền sở hữu và mượn, được kiểm tra lúc biên dịch Quyền sở hữu, Mượn
JavaScript Rust Tìm hiểu thêm
Array Vec<T> (mọi phần tử cùng một kiểu) Vector
Mảng độ dài cố định, hoặc tuple TS [string, number] [T; N] cho độ dài cố định, tuple (String, i32) cho nhiều kiểu lẫn nhau Mảng, Tuple
Map, hoặc một object dùng làm từ điển (Record<string, T>) HashMap<K, V> HashMap
Một map có khóa được sắp xếp BTreeMap<K, V> HashMap
Set HashSet<T> (hoặc BTreeSet<T> nếu cần thứ tự sắp xếp) HashSet và BTreeMap
Mảng dùng làm hàng đợi (push + shift) VecDeque<T> (push_back / pop_front, cả hai đều nhanh) VecDeque và BinaryHeap
Mảng dùng làm ngăn xếp (push + pop) Vec<T> với push / pop Vector
Uint8Array, Buffer Vec<u8>, hoặc một slice byte &[u8] Slice
WeakMap, WeakRef Weak<T> (từ Rc hoặc Arc) Weak và Cow

Một điều cần để ý: Map, Set và object thông thường của JavaScript nhớ thứ tự bạn thêm phần tử vào. HashMap và HashSet của Rust thì không. Nếu bạn cần thứ tự sắp xếp, hãy dùng BTreeMap; nếu bạn cần giữ thứ tự thêm vào, crate indexmap cho bạn đúng điều đó.

Bạn sẽ thấy Cargo như ở nhà. Nó vừa là trình quản lý gói, công cụ build, trình chạy test vừa là trình chạy script, và mọi dự án Rust đều dùng nó theo cùng một cách.

JavaScript Rust Ghi chú
node + npm (hoặc pnpm, yarn) cargo Build, chạy, test, thêm gói, publish. Xem Cargo và crate
package.json Cargo.toml
package-lock.json Cargo.lock Được tạo tự động cho bạn
node_modules/ Không có thư mục riêng cho từng dự án. Gói tải về được cache một lần trong ~/.cargo, kết quả build nằm trong target/
npmjs.com crates.io Tài liệu API của mọi crate đã publish đều có trên docs.rs (tiếng Anh)
npm install zod cargo add serde
npm init cargo new my_app (cargo new --lib cho thư viện)
npm run dev / build / test cargo run / cargo build / cargo test Thêm --release để có bản build tối ưu; bản build debug chậm hơn nhiều
"scripts" trong package.json Không có phần scripts. Dùng alias của cargo trong .cargo/config.toml, hoặc một task runner như just
npx some-tool cargo install some-tool, rồi chạy nó cargo binstall tải binary dựng sẵn thay vì biên dịch
nvm và .nvmrc rustup và một file rust-toolchain.toml Xem Công cụ và cài đặt
Workspace của npm / pnpm (monorepo) Một Cargo workspace Một Cargo.toml ở thư mục gốc liệt kê các crate thành viên
tsc rustc, được cargo gọi giúp bạn
tsc --noEmit cargo check Kiểm tra kiểu mà không build; nhanh hơn nhiều
ESLint cargo clippy Hàng trăm lint hữu ích, chính thức
Prettier cargo fmt Một phong cách chuẩn duy nhất cho mọi người
nodemon, tsx watch cargo watch hoặc bacon Cài bằng cargo install
Jest / Vitest / Mocha #[test] có sẵn và cargo test (cargo nextest là trình chạy nhanh hơn được nhiều người dùng) Xem Kiểm thử
Snapshot của Jest insta Xem Kiểm thử nâng cao
jest.mock() mockall, hoặc tự viết bản giả (fake) triển khai trait của bạn
fast-check proptest Kiểm thử dựa trên thuộc tính (property-based testing)
c8 / Istanbul (độ phủ code) cargo llvm-cov
JSDoc / TypeDoc Doc comment /// viết bằng Markdown + cargo doc Ví dụ code trong tài liệu được chạy như test. Xem Tài liệu
VS Code + TypeScript server VS Code + rust-analyzer, hoặc JetBrains RustRover rust-analyzer cho bạn gợi ý code, nhảy tới định nghĩa và báo lỗi ngay trong editor
npm publish cargo publish

Đây là một Cargo.toml nhỏ, để bạn so sánh với một package.json:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Một phiên bản như "1" có nghĩa giống "^1" trong package.json: bất kỳ bản 1.x tương thích nào. Feature là những phần tùy chọn của một crate mà bạn bật lên; xem Feature của Cargo và cfg.

Thư viện và framework: “Vậy thay vì … thì dùng gì?”

Phần tiêu đề “Thư viện và framework: “Vậy thay vì … thì dùng gì?””

Lập trình viên JavaScript nào cũng hỏi câu này đầu tiên. “Bọn mình dùng Express và Prisma. Trong Rust có gì?” Dưới đây là những thứ tương đương gần nhất. Giống npm, crates.io có nhiều lựa chọn cho hầu hết mọi việc; đây là những crate phổ biến và được bảo trì tốt.

JavaScript Rust Ghi chú
Express, Fastify, Koa axum (chúng ta xây một cái trong Dự án: Web API), actix-web, Rocket axum và actix-web được dùng rộng rãi trong production
Middleware của Express Các layer của tower và crate tower-http (CORS, nén, tracing, timeout) axum dùng trực tiếp tower
fetch, axios reqwest Mặc định bất đồng bộ, có client blocking tùy chọn. Xem Lập trình mạng
ws, Socket.IO WebSocket trong axum (feature ws), tokio-tungstenite, hoặc socketioxide (một server Socket.IO)
Apollo Server (GraphQL) async-graphql
gRPC (@grpc/grpc-js) tonic (kèm prost cho protobuf)
Trình sinh Swagger / OpenAPI utoipa
jsonwebtoken jsonwebtoken Cùng tên, cùng ý tưởng
bcrypt argon2 hoặc bcrypt
JavaScript Rust Ghi chú
Prisma, TypeORM SeaORM (bất đồng bộ, entity, migration) hoặc Diesel (truy vấn được kiểm tra kiểu lúc biên dịch) SeaORM cho cảm giác gần Prisma và TypeORM nhất
Knex, pg, postgres.js sqlx: bạn viết SQL thuần, và macro query! kiểm tra nó với cơ sở dữ liệu thật của bạn ngay lúc biên dịch Bất đồng bộ, rất phổ biến, không phải ORM
better-sqlite3 rusqlite, hoặc sqlx với SQLite
Mongoose, driver MongoDB mongodb (driver chính thức) Không có schema kiểu Mongoose; bạn dùng struct với serde
ioredis redis

Bài Cơ sở dữ liệu trình bày sqlx, Diesel và SeaORM với code chạy được.

JavaScript Rust Ghi chú
JSON.parse / JSON.stringify serde + serde_json Tiêu chuẩn chung; còn làm được TOML, YAML và nhiều định dạng khác. Xem JSON với Serde
Zod, Yup Kiểu trong struct của bạn + serde làm phần lớn công việc; validator hoặc garde cho các quy tắc như “ít nhất 8 ký tự” Nếu JSON không khớp với struct, việc parse sẽ thất bại và trả về lỗi
Lodash Chủ yếu là iterator của thư viện chuẩn; itertools cho những thứ thêm như unique, sorted_by và chunk_by
date-fns, Day.js, Luxon chrono hoặc jiff Xem Ngày và giờ
dotenv dotenvy Xem Môi trường và tiến trình
Commander, yargs clap Xem Dự án: công cụ CLI với clap
chalk colored hoặc owo-colors
winston, pino tracing (+ tracing-subscriber), hoặc log + env_logger Xem Ghi log
uuid uuid
EventEmitter Channel (std::sync::mpsc, hoặc tokio::sync::broadcast cho nhiều listener) Xem Channel
BullMQ (hàng đợi job) tokio::spawn cho task trong tiến trình; apalis cho hàng đợi job lưu bền
Biểu thức chính quy regex Không có lookahead hay backreference, đó là chủ ý thiết kế. Xem Biểu thức chính quy
JavaScript Rust Ghi chú
Tăng tốc code nặng chạy trong trình duyệt Biên dịch Rust sang WebAssembly với wasm-bindgen và wasm-pack Xem Rust và WebAssembly và phần bên dưới
React, Vue, Svelte Leptos, Yew, Dioxus (đều biên dịch sang WebAssembly) Đều chưa tới phiên bản 1.0; API vẫn còn thay đổi
Electron Tauri: frontend web của bạn + backend Rust, ứng dụng nhỏ hơn nhiều Tauri 2 đã ổn định. Xem Ứng dụng giao diện (GUI)
Native addon cho Node.js (C++ với node-gyp) napi + napi-derive (napi-rs), hoặc neon Đưa binary dựng sẵn lên npm; không cần C++
Game với Phaser, Three.js Bevy, macroquad Bevy chưa tới 1.0 và thay đổi API giữa các phiên bản. Xem Phát triển game

Đây là một object TypeScript với một interface, một hàm đóng vai trò phương thức, và một bản sao bằng spread:

interface User {
name: string;
age: number;
admin: boolean;
}
const greeting = (u: User) => `Chào, mình là ${u.name} (${u.age})`;
const ada: User = { name: "Ada", age: 36, admin: false };
const promoted = { ...ada, admin: true };
console.log(greeting(ada));
console.log(promoted);

Trong Rust, hình dạng đó trở thành một struct, còn hàm trở thành một phương thức trong khối impl:

#[derive(Debug, Clone)]
struct User {
name: String,
age: u32,
admin: bool,
}
impl User {
// `&self` nghĩa là “phương thức này đọc user, không thay đổi nó”.
fn greeting(&self) -> String {
format!("Chào, mình là {} ({})", self.name, self.age)
}
}
fn main() {
let ada = User { name: "Ada".to_string(), age: 36, admin: false };
// Giống { ...ada, admin: true } trong JavaScript.
let promoted = User { admin: true, ..ada.clone() };
println!("{}", ada.greeting());
println!("{:?}", promoted);
}

Kết quả:

Chào, mình là Ada (36)
User { name: "Ada", age: 36, admin: true }
▶ Tự chạy thử

Có vài điểm khác. Mỗi field có một kiểu chính xác: age là u32 (một số nguyên không thể âm), chứ không phải một number chung chung. #[derive(Debug)] là thứ cho phép bạn in cả struct bằng {:?}, giống console.log(obj). Còn tại sao lại có .clone()? Spread ..ada sẽ move chuỗi name ra khỏi ada, và sau đó chúng ta không dùng ada được nữa. Clone trước sẽ giữ ada nguyên vẹn. Đó là quyền sở hữu đang làm việc, và bạn sẽ gặp nó rất nhiều.

Đặt cạnh nhau: discriminated union trở thành enum

Phần tiêu đề “Đặt cạnh nhau: discriminated union trở thành enum”

Lập trình viên TypeScript rất thích discriminated union: một field kind cho bạn biết mình đang có hình dạng nào, và switch thu hẹp kiểu lại:

type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "rect":
return s.width * s.height;
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 1 },
{ kind: "rect", width: 2, height: 3 },
];
for (const s of shapes) console.log(area(s).toFixed(2));

Rust có sẵn điều này, và đó là một trong những tính năng hay nhất của ngôn ngữ. Mỗi biến thể của enum mang dữ liệu riêng, và match tách nó ra:

enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { width, height } => width * height,
}
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 1.0 },
Shape::Rect { width: 2.0, height: 3.0 },
];
for shape in &shapes {
println!("{:.2}", area(shape));
}
}

Kết quả:

3.14
6.00
▶ Tự chạy thử

Không cần chuỗi kind nào: chính biến thể là cái nhãn. Và match phải xử lý mọi biến thể. Nếu sau này bạn thêm Triangle, mọi match quên nó đều trở thành lỗi biên dịch, giống mẹo kiểm tra đầy đủ bằng never của TypeScript nhưng lúc nào cũng bật. Bạn sẽ tìm hiểu thêm ở bài So khớp mẫu.

Đặt cạnh nhau: undefined và null trở thành Option

Phần tiêu đề “Đặt cạnh nhau: undefined và null trở thành Option”

Trong TypeScript, giá trị tùy chọn xuất hiện dưới dạng undefined, null hoặc một thuộc tính tùy chọn, và bạn dùng ?. và ?? để xử lý chúng:

interface User {
name: string;
email?: string;
}
function findUser(id: number): User | undefined {
if (id === 1) return { name: "Ada", email: "[email protected]" };
if (id === 2) return { name: "Grace" };
return undefined;
}
for (const id of [1, 2, 3]) {
console.log(`${id}: ${findUser(id)?.email?.toUpperCase() ?? "không có email"}`);
}

Trong Rust, một giá trị có thể bị thiếu sẽ có kiểu Option<T>. Nó hoặc là Some(value) hoặc là None, và bạn không thể dùng nó như một giá trị bình thường cho tới khi đã xử lý trường hợp None:

struct User {
name: String,
email: Option<String>,
}
fn find_user(id: u32) -> Option<User> {
match id {
1 => Some(User { name: "Ada".to_string(), email: Some("[email protected]".to_string()) }),
2 => Some(User { name: "Grace".to_string(), email: None }),
_ => None,
}
}
// Bên trong một hàm trả về Option, `?` hoạt động giống `?.`:
// nếu giá trị là None, dừng ở đây và trả về None.
fn email_domain(id: u32) -> Option<String> {
let email = find_user(id)?.email?;
let at = email.find('@')?;
Some(email[at + 1..].to_string())
}
fn main() {
for id in [1, 2, 3] {
// Giống findUser(id)?.email?.toUpperCase() ?? "không có email"
let email = find_user(id)
.and_then(|user| user.email)
.map(|email| email.to_uppercase())
.unwrap_or("không có email".to_string());
println!("{id}: {email}");
}
println!("{:?}", email_domain(1));
println!("{:?}", email_domain(2));
if let Some(user) = find_user(2) {
println!("tìm thấy {}", user.name);
}
}

Kết quả:

2: không có email
3: không có email
Some("example.com")
None
tìm thấy Grace
▶ Tự chạy thử

Rust không có lỗi “cannot read properties of undefined”, vì chẳng có undefined nào để đọc. Trình biên dịch đơn giản là không cho bạn gọi .to_uppercase() trên một Option<String>. Và cũng chẳng có khác biệt nào giữa null và undefined để phải nhớ: chỉ có None. Hãy học các phương thức hỗ trợ ở trang Option, và những cách viết ngắn ở trang If let / while let.

Đặt cạnh nhau: try/catch trở thành Result và ?

Phần tiêu đề “Đặt cạnh nhau: try/catch trở thành Result và ?”

Trong JavaScript, một hàm ném lỗi, và bạn bắt lỗi ở đâu đó phía trên call stack. Chữ ký hàm không hề cho bạn biết nó có thể ném lỗi:

function parseAge(input) {
const age = Number(input);
if (!Number.isInteger(age)) throw new Error(`'${input}' không phải là số nguyên`);
if (age > 150) throw new Error(`${age} tuổi là quá già`);
return age;
}
function totalAge(a, b) {
return parseAge(a) + parseAge(b);
}
for (const [a, b] of [["36", "41"], ["36", "abc"], ["36", "200"]]) {
try {
console.log(`Tổng: ${totalAge(a, b)}`);
} catch (e) {
console.log(`Lỗi: ${e.message}`);
}
}

Trong Rust, một hàm có thể thất bại sẽ trả về một Result: hoặc Ok(value) hoặc Err(error). Toán tử ? có nghĩa là “nếu bước này thất bại, trả lỗi về cho nơi gọi tôi ngay lập tức”. Nó làm đúng việc để exception nổi lên trên, nhưng bạn nhìn thấy nó ngay trong code:

fn parse_age(input: &str) -> Result<u32, String> {
let age: u32 = input
.parse()
.map_err(|_| format!("'{input}' không phải là số nguyên"))?;
if age > 150 {
return Err(format!("{age} tuổi là quá già"));
}
Ok(age)
}
fn total_age(a: &str, b: &str) -> Result<u32, String> {
// Mỗi `?` chuyển lỗi lên cho nơi gọi, giống như một exception.
let first = parse_age(a)?;
let second = parse_age(b)?;
Ok(first + second)
}
fn main() {
for (a, b) in [("36", "41"), ("36", "abc"), ("36", "200")] {
match total_age(a, b) {
Ok(total) => println!("Tổng: {total}"),
Err(e) => println!("Lỗi: {e}"),
}
}
}

Kết quả:

Tổng: 77
Lỗi: 'abc' không phải là số nguyên
Lỗi: 200 tuổi là quá già
▶ Tự chạy thử

Chữ ký -> Result<u32, String> nói với mọi nơi gọi rằng “hàm này có thể thất bại”, và trình biên dịch không cho họ bỏ qua điều đó. Code async cũng vậy: ở chỗ mà JavaScript cho bạn một Promise bị reject, một hàm async của Rust trả về Result, và bạn viết .await?. Với kiểu lỗi của riêng bạn, hãy xem Lỗi tự định nghĩa. Trong ứng dụng, crate anyhow cho bạn thứ gần giống “bắt mọi lỗi” (xem anyhow và thiserror).

Đặt cạnh nhau: phương thức mảng trở thành iterator

Phần tiêu đề “Đặt cạnh nhau: phương thức mảng trở thành iterator”

Những phương thức mảng bạn dùng hằng ngày đều có thứ tương đương trực tiếp trong Rust:

const orders = [
{ customer: "Ada", total: 120, paid: true },
{ customer: "Grace", total: 80, paid: false },
{ customer: "Linus", total: 200, paid: true },
];
const paidNames = orders.filter((o) => o.paid).map((o) => o.customer);
const revenue = orders.filter((o) => o.paid).reduce((sum, o) => sum + o.total, 0);
const firstBig = orders.find((o) => o.total > 150);
console.log(paidNames.join(", "));
console.log(`Doanh thu: ${revenue}`);
console.log(`Đơn lớn đầu tiên: ${firstBig?.customer ?? "không có"}`);
console.log(`Có đơn chưa thanh toán? ${orders.some((o) => !o.paid)}`);

Phiên bản Rust dùng iterator và closure. Cú pháp closure là |o| o.paid thay vì (o) => o.paid:

struct Order {
customer: String,
total: u32,
paid: bool,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120, paid: true },
Order { customer: "Grace".to_string(), total: 80, paid: false },
Order { customer: "Linus".to_string(), total: 200, paid: true },
];
// filter + map, rồi collect() biến kết quả thành một Vec.
let paid_names: Vec<&str> = orders
.iter()
.filter(|o| o.paid)
.map(|o| o.customer.as_str())
.collect();
// reduce có giá trị khởi đầu được gọi là fold.
let revenue = orders
.iter()
.filter(|o| o.paid)
.fold(0, |sum, o| sum + o.total);
// find trả về một Option, vì có thể không có phần tử nào khớp.
let first_big = orders.iter().find(|o| o.total > 150);
println!("{}", paid_names.join(", "));
println!("Doanh thu: {revenue}");
match first_big {
Some(o) => println!("Đơn lớn đầu tiên: {}", o.customer),
None => println!("Đơn lớn đầu tiên: không có"),
}
// some() được gọi là any().
println!("Có đơn chưa thanh toán? {}", orders.iter().any(|o| !o.paid));
}

Kết quả:

Ada, Linus
Doanh thu: 320
Đơn lớn đầu tiên: Linus
Có đơn chưa thanh toán? true
▶ Tự chạy thử

Có một khác biệt lớn so với JavaScript. Mỗi phương thức mảng trong JS tạo ngay ra một mảng mới hoàn chỉnh. Iterator của Rust thì lười (lazy): bản thân filter và map không làm gì cả, và công việc diễn ra trong một lượt duy nhất khi bạn gọi thứ gì đó như collect(), sum(), fold() hoặc dùng vòng lặp for. Không có mảng tạm nào được tạo ra ở giữa. Riêng phần tính tổng, bạn cũng có thể viết .map(|o| o.total).sum::<u32>(). Việc sắp xếp nằm trên Vec (sort, sort_by), không nằm trên iterator; xem Sắp xếp và tìm kiếm.

Đặt cạnh nhau: Promise trở thành future + tokio

Phần tiêu đề “Đặt cạnh nhau: Promise trở thành future + tokio”

Đây là một đoạn JavaScript async. Hãy để ý lúc "đang tải 1" được in ra:

async function fetchUser(id) {
console.log(`đang tải ${id}`);
await new Promise((resolve) => setTimeout(resolve, 100));
return `người dùng ${id}`;
}
const promise = fetchUser(1); // in "đang tải 1" ngay lập tức
console.log("đã tạo promise");
console.log(await promise);
const [a, b] = await Promise.all([fetchUser(2), fetchUser(3)]);
console.log(`${a}, ${b}`);

Trong JavaScript, gọi một hàm async là bắt đầu chạy nó ngay. Rust thì khác: gọi một async fn chỉ tạo ra một future, tức một bản mô tả công việc. Chẳng có gì chạy cho tới khi bạn .await nó. Và Rust không có event loop có sẵn, nên bạn thêm một runtime: tokio (tiếng Anh) là runtime phổ biến nhất.

use std::time::Duration;
async fn fetch_user(id: u32) -> String {
println!("đang tải {id}");
tokio::time::sleep(Duration::from_millis(100)).await;
format!("người dùng {id}")
}
#[tokio::main]
async fn main() {
// Tạo một future KHÔNG làm nó chạy. Chưa có gì được in ra.
let future = fetch_user(1);
println!("đã tạo future");
println!("{}", future.await);
// Giống Promise.all: chạy cả hai cùng lúc và chờ cả hai xong.
let (a, b) = tokio::join!(fetch_user(2), fetch_user(3));
println!("{a}, {b}");
}

Kết quả:

đã tạo future
đang tải 1
người dùng 1
đang tải 2
đang tải 3
người dùng 2, người dùng 3
▶ Tự chạy thử

Bạn thấy thứ tự chưa? Trong JavaScript, “đang tải 1” sẽ xuất hiện trước “đã tạo promise”. Trong Rust, nó xuất hiện sau “đã tạo future”, vì future chỉ bắt đầu chạy khi gặp .await. #[tokio::main] dựng runtime (event loop của bạn) và chạy main trên đó. tokio::join! chờ cả hai future cùng lúc, giống Promise.all; tokio::spawn khởi động một task chạy nền, đó là thứ gần nhất với việc gọi một hàm async mà không await. Các bài Async/Await và Async chuyên sâu giải thích chi tiết.

Rust trong dự án JavaScript của bạn: WebAssembly và Node addon

Phần tiêu đề “Rust trong dự án JavaScript của bạn: WebAssembly và Node addon”

Bạn không cần viết lại gì cả để bắt đầu dùng Rust. Có hai cách phổ biến để đưa Rust vào một dự án JavaScript:

WebAssembly trong trình duyệt. Đánh dấu các hàm Rust bằng #[wasm_bindgen], build bằng wasm-pack, rồi import kết quả như mọi ES module khác. wasm-pack còn viết cả file .d.ts, nên TypeScript biết kiểu của các hàm của bạn:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn slugify(title: &str) -> String {
title
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}
import init, { slugify } from "./pkg/my_lib.js";
await init(); // tải và khởi động file .wasm
console.log(slugify("Hello Rust World")); // "hello-rust-world"

Bài Rust và WebAssembly hướng dẫn bạn toàn bộ quá trình cài đặt, và giải thích những giá trị nào đi qua ranh giới giữa hai bên với chi phí thấp. Một mẹo: Wasm đáng giá cho những tính toán nặng (xử lý ảnh, parse dữ liệu, mô phỏng), chứ không phải cho những hàm nhỏ bị gọi hàng nghìn lần, vì mỗi lần gọi giữa JS và Wasm đều có một chi phí nhỏ.

Native addon cho Node.js. Với napi-rs (tiếng Anh) (các crate napi và napi-derive), bạn đánh dấu một hàm Rust bằng #[napi], và nó trở thành một hàm bạn có thể require hoặc import từ Node. Nhiều gói npm phổ biến đưa kèm binary Rust dựng sẵn theo cách này, nên người dùng không bao giờ cần đến trình biên dịch Rust.

Những chỗ dễ vấp với lập trình viên JavaScript

Phần tiêu đề “Những chỗ dễ vấp với lập trình viên JavaScript”

Ai đến từ JavaScript cũng vấp phải cùng những ổ gà này. Biết trước sẽ giúp bạn nhiều.

Truyền một giá trị vào hàm là move nó. Trong JavaScript, truyền một mảng vào hàm là truyền một tham chiếu, và bạn vẫn dùng mảng đó tiếp được. Trong Rust, truyền một Vec hay String theo giá trị sẽ chuyển (move) quyền sở hữu vào hàm, và biến ban đầu không dùng được nữa:

fn print_all(names: Vec<String>) {
for name in names {
println!("{name}");
}
}
fn main() {
let names = vec![String::from("Ada"), String::from("Grace")];
print_all(names);
println!("{} names", names.len());
}
error[E0382]: borrow of moved value: `names`
--> src/main.rs:10:26
|
8 | let names = vec![String::from("Ada"), String::from("Grace")];
| ----- move occurs because `names` has type `Vec<String>`, which does not implement the `Copy` trait
9 | print_all(names);
| ----- value moved here
10 | println!("{} names", names.len());
| ^^^^^ value borrowed here after move

Trình biên dịch báo rằng names đã bị move vào print_all ở dòng 9, nên dòng 10 không được dùng nó nữa. Cách sửa thường gặp là mượn thay vì move: cho hàm nhận names: &[String] và gọi nó bằng print_all(&names). Hãy đọc Quyền sở hữu và Mượn trước; hai bài đó giải thích quy tắc này, và bạn sẽ gặp nó hằng ngày.

Số có kiểu, và các kiểu không trộn lẫn được. JavaScript có một kiểu number. Trong Rust, 3 là số nguyên còn 2.5 là số thực, và bạn không thể kết hợp chúng nếu không chuyển đổi:

fn main() {
let items = 3;
let price = 2.5;
let total = items * price;
println!("{total}");
}
error[E0277]: cannot multiply `{integer}` by `{float}`
--> src/main.rs:4:23
|
4 | let total = items * price;
| ^ no implementation for `{integer} * {float}`

Thông báo này nghĩa là không thể nhân một số nguyên với một số thực. Hãy viết items as f64 * price, hoặc cho items là số thực ngay từ đầu (3.0). Cũng lưu ý rằng phép chia số nguyên bỏ phần dư: 7 / 2 là 3, không phải 3.5. Xem Kiểu dữ liệu và Ép kiểu.

Thêm vài chỗ nữa bạn sẽ gặp:

  • Future là lười và cần một runtime. Như bạn thấy ở trên, chẳng có gì xảy ra cho tới khi .await, và bạn cần tokio (hoặc một runtime khác) thì mới chạy được code async. Quên .await chỉ cho bạn một cảnh báo của trình biên dịch, chứ không phải một task đang chạy.
  • Không thể đánh chỉ số vào chuỗi. name[0] không biên dịch, vì String của Rust là UTF-8 và một ký tự có thể chiếm nhiều byte. Dùng name.chars().next() hoặc .chars().nth(i). Và hãy sớm học sự khác nhau giữa String và &str: hàm thường nhận &str, struct thường lưu String. Xem Chuỗi.
  • Không có structural typing và không có any. Trong TypeScript, object nào có đúng field cũng khớp với một interface. Trong Rust, một hàm nhận User chỉ chấp nhận User. Với hành vi dùng chung, hãy dùng trait. Với JSON không rõ cấu trúc, hãy dùng serde_json::Value.
  • Đồ thị object dùng chung và có thể thay đổi rất khó làm. Trong JavaScript, truyền một object đi khắp nơi rồi để ai cũng thay đổi nó, hay để object trỏ ngược về cha của nó, là chuyện bình thường. Borrow checker cho phép nhiều người đọc hoặc một người ghi tại một thời điểm, không bao giờ cả hai. Những mẫu như vậy cần con trỏ thông minh (Rc<RefCell<T>>, Arc<Mutex<T>>) hoặc, tốt hơn, một thiết kế khác. Hãy chuẩn bị cho vài tuần bực bội. Rồi mọi thứ sẽ dễ hơn nhiều.
  • HashMap không có thứ tự. Map và object của JavaScript giữ thứ tự thêm vào, nên code thường dựa vào điều đó mà không để ý. HashMap của Rust thì không. Hãy sắp xếp trước khi in, hoặc dùng BTreeMap hay crate indexmap.
  • Không có exception. Lỗi là giá trị, và bạn sẽ viết ? rất nhiều. Gọi .unwrap() khi đang học thì không sao, nhưng nó là phiên bản Rust của dấu ! trong TypeScript: “tin tôi đi, nó có đó”. Nếu bạn sai, chương trình sẽ panic.
  • Thời gian biên dịch. Không có chuyện chạy lại tức thì như node app.js. Lần build đầu với nhiều crate mất một lúc; các lần sau là build tăng dần (incremental) và nhanh hơn. Hãy dùng cargo check trong lúc sửa code, vì nó bỏ qua bước sinh mã.

Lộ trình học cho lập trình viên JavaScript

Phần tiêu đề “Lộ trình học cho lập trình viên JavaScript”

Bạn không cần đọc trang này theo thứ tự. Là lập trình viên JavaScript hay TypeScript, lộ trình dưới đây bỏ qua những gì bạn đã biết và dành thời gian cho chỗ Rust khác biệt:

  1. Bắt đầu với Rust và Công cụ và cài đặt: cài Rust, thiết lập VS Code với rust-analyzer, chạy cargo new.
  2. Biến và Kiểu dữ liệu: hãy đọc kỹ. Các kiểu số là điều mới với bạn.
  3. Quyền sở hữu, rồi Mượn, rồi Slice: những trang quan trọng nhất với bạn. Cứ từ từ.
  4. Chuỗi: String và &str.
  5. Struct và Enum, rồi match và So khớp mẫu: interface và discriminated union của bạn.
  6. Option và Result, rồi Lỗi tự định nghĩa.
  7. Closure và Iterator: các phương thức mảng của bạn.
  8. Vector, HashMap và HashSet và BTreeMap.
  9. Trait, Generic và Các trait thông dụng.
  10. Module, Cargo và crate và Kiểm thử.
  11. Lifetime và Con trỏ thông minh.
  12. Async/Await và Async chuyên sâu.
  13. JSON với Serde, rồi xây dự án Web API, hoặc thử WebAssembly.

Cả hai bài tập đều cho bạn một đoạn JavaScript hoặc TypeScript. Hãy chuyển nó sang Rust.

Đoạn JavaScript này đếm số lần mỗi từ xuất hiện, rồi in các từ từ phổ biến nhất đến ít phổ biến nhất (bằng nhau thì xếp theo bảng chữ cái). Hãy chuyển nó sang Rust. Gợi ý: dùng một HashMap<&str, u32> và *counts.entry(word).or_insert(0) += 1. Để sắp xếp, collect map vào một Vec gồm các cặp (word, count) rồi dùng sort_by.

const text = "the cat and the hat and the bat";
const counts = new Map();
for (const word of text.split(" ")) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
const sorted = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
for (const [word, n] of sorted) console.log(`${word}: ${n}`);

Câu mẫu được giữ nguyên tiếng Anh vì kết quả được sắp xếp theo bảng chữ cái.

Xem lời giải
use std::collections::HashMap;
fn main() {
let text = "the cat and the hat and the bat";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split(' ') {
*counts.entry(word).or_insert(0) += 1;
}
// Giống [...counts]: biến map thành một danh sách các cặp (từ, số lần).
let mut sorted: Vec<(&str, u32)> = counts.into_iter().collect();
// Từ xuất hiện nhiều nhất đứng trước; bằng nhau thì xếp theo bảng chữ cái.
sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in sorted {
println!("{word}: {n}");
}
}

Kết quả:

the: 3
and: 2
bat: 1
cat: 1
hat: 1
▶ Tự chạy thử

entry(word).or_insert(0) làm cả phần counts.get(word) ?? 0 lẫn phần set trong một bước: nó cho bạn một tham chiếu khả biến tới số đếm, và tạo số đếm với giá trị 0 nếu chưa có. Ở đây việc sắp xếp là bắt buộc, chứ không chỉ cho đẹp: thiếu nó, một HashMap sẽ in ra theo thứ tự khác nhau giữa các lần chạy.

2. Chuyển các tùy chọn có giá trị mặc định

Phần tiêu đề “2. Chuyển các tùy chọn có giá trị mặc định”

Hàm TypeScript này nhận một object tùy chọn, trong đó field nào cũng là tùy chọn, và điền giá trị mặc định bằng ??. Hãy chuyển nó sang Rust. Gợi ý: tạo một struct có các field Option và thêm #[derive(Default)]. Khi đó ServerOptions { port: Some(3000), ..Default::default() } hoạt động giống như truyền { port: 3000 }. Với bool, giá trị mặc định là false.

interface ServerOptions {
host?: string;
port?: number;
verbose?: boolean;
}
function describe(opts: ServerOptions): string {
const host = opts.host ?? "localhost";
const port = opts.port ?? 8080;
const mode = opts.verbose ? "verbose" : "quiet";
return `${host}:${port} (${mode})`;
}
console.log(describe({}));
console.log(describe({ port: 3000 }));
console.log(describe({ host: "example.com", verbose: true }));
Xem lời giải
#[derive(Default)]
struct ServerOptions {
host: Option<String>,
port: Option<u16>,
verbose: bool,
}
fn describe(opts: &ServerOptions) -> String {
let host = opts.host.as_deref().unwrap_or("localhost");
let port = opts.port.unwrap_or(8080);
let mode = if opts.verbose { "verbose" } else { "quiet" };
format!("{host}:{port} ({mode})")
}
fn main() {
println!("{}", describe(&ServerOptions::default()));
println!("{}", describe(&ServerOptions { port: Some(3000), ..Default::default() }));
println!(
"{}",
describe(&ServerOptions {
host: Some("example.com".to_string()),
verbose: true,
..Default::default()
})
);
}

Kết quả:

localhost:8080 (quiet)
localhost:3000 (quiet)
example.com:8080 (verbose)
▶ Tự chạy thử

unwrap_or chính là ?? của bạn. as_deref() biến một Option<String> thành một Option<&str>, để có thể dùng văn bản "localhost" làm giá trị dự phòng mà không phải sao chép gì. Còn ..Default::default() là cách Rust nói “mọi field tôi không nhắc tới đều lấy giá trị mặc định”, đúng như việc bỏ trống một thuộc tính trong JavaScript. Dùng u16 cho cổng còn có nghĩa là một cổng như 70000 thậm chí không thể bị lưu vào do nhầm lẫn.

  • Phần lớn JavaScript và TypeScript mang sang được: closure, async/await, destructuring, và các phương thức kiểu mảng như map, filter và find đều có thứ tương đương gần gũi trong Rust.
  • Những thay đổi lớn là quyền sở hữu thay cho bộ thu gom rác, Option thay cho undefined và null, và Result + ? thay cho throw và try/catch.
  • Interface của TypeScript trở thành struct (cho dữ liệu) hoặc trait (cho hành vi), còn discriminated union trở thành enum dùng với match.
  • Future của Rust là lười và cần một runtime như tokio; tokio::join! đóng vai trò của Promise.all.
  • Cargo thay thế npm, package.json và các script của bạn; clippy và rustfmt thay thế ESLint và Prettier; kiểm thử có sẵn trong ngôn ngữ.
  • Thay Express hãy xem axum hoặc actix-web, thay fetch hãy xem reqwest, thay Prisma hãy xem SeaORM hoặc Diesel, và thay Electron hãy xem Tauri. Bạn cũng có thể đưa Rust vào một dự án JS qua WebAssembly hoặc napi-rs.

Kiểm tra nhanh

1. Rust dùng gì thay cho undefined và null?

2. Trong JavaScript, gọi một hàm async là bắt đầu chạy nó ngay. Điều gì xảy ra khi bạn gọi một async fn trong Rust mà không có .await?

3. Code TypeScript của bạn dùng một discriminated union có field kind. Tính năng nào của Rust gần nhất với nó?

4. Backend của nhóm bạn dùng Express. Crate Rust nào gần nhất?

Điểm: 0 / 4

  • The Rust Programming Language (tiếng Anh): cuốn sách chính thức. Chương 4 (ownership), 6 (enum và so khớp mẫu) và 9 (lỗi) quan trọng nhất với lập trình viên JavaScript.
  • Tokio tutorial (tiếng Anh): hướng dẫn chính thức về Rust bất đồng bộ với tokio, từ task đầu tiên đến channel và I/O.
  • The wasm-bindgen Guide (tiếng Anh): cách Rust và JavaScript nói chuyện với nhau trong WebAssembly, với nhiều ví dụ.
  • Compiling from Rust to WebAssembly (MDN) (tiếng Anh): hướng dẫn từng bước của MDN Web Docs, bao gồm cả việc publish lên npm.
  • napi-rs (tiếng Anh): tài liệu về cách viết Node.js addon bằng Rust.