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

Rust cho lập trình viên Go: hướng dẫn thực hành

Nếu bạn viết Go, bạn sẽ thấy Rust quen thuộc hơn bạn tưởng. Cả hai ngôn ngữ đều biên dịch thành một file binary duy nhất chạy nhanh. Cả hai đều có struct kèm phương thức thay cho class, không có kế thừa, trả lỗi về dưới dạng giá trị thay vì exception, có một công cụ định dạng chuẩn mà ai cũng dùng, và có một công cụ dòng lệnh duy nhất để build, test và tải gói. Một struct Go với vài phương thức chuyển sang Rust gần như từng dòng một.

Cái mới chủ yếu liên quan đến bộ nhớ và việc phải viết rõ ràng. 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ó nil (bạn dùng Option), và if err != nil trở thành Result cùng toán tử ?. Interface vẫn tồn tại (chúng được gọi là trait), nhưng một kiểu phải nói rõ ra rằng nó triển khai trait đó. Hãy chuẩn bị dành phần lớn thời gian học cho quyền sở hữu. Phần còn lại sẽ giống một phiên bản Go khắt khe và chi tiết hơn.

Tại sao lập trình viên Go học Rust? Thường là vì một trong những lý do sau: một service mà những lần GC làm khựng hoặc lượng bộ nhớ dùng bắt đầu trở thành vấn đề, công việc nặng về CPU, code chạy không cần hệ điều hành (Rust nhúng) hoặc chạy trong trình duyệt (WebAssembly), hoặc đơn giản là muốn trình biên dịch bắt data race và những cú sập kiểu nil trước khi lên production. Nếu bạn muốn một bài so sánh rộng hơn giữa Rust với Go, C++ và Python, hãy xem Rust so với C++, Go và Python. Trang này đi sâu hơn và được viết riêng cho bạn, một lập trình viên Go.

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.

Go Rust Tìm hiểu thêm
Bộ thu gom rác Quyền sở hữu và mượn, được kiểm tra lúc biên dịch; bộ nhớ được giải phóng khi chủ sở hữu ra khỏi phạm vi Quyền sở hữu, Mượn
struct + phương thức struct cho dữ liệu + một khối impl cho các phương thức Struct
Value receiver func (a Account) &self (đọc, không sao chép) hoặc self (lấy quyền sở hữu) Struct
Pointer receiver func (a *Account) &mut self (có thể thay đổi giá trị) Struct
Hàm constructor NewAccount(...) Một associated function, theo quy ước là Account::new(...) Struct
Viết hoa chữ cái đầu = exported Từ khóa pub Module
Giá trị zero (var c Config) Không có giá trị zero ngầm định; mọi field phải được gán. #[derive(Default)] + Config::default() Struct, Các trait thông dụng
Struct embedding Không có embedding. Đặt struct vào một field và viết vài phương thức chuyển tiếp nhỏ, hoặc chia sẻ hành vi bằng trait Trait, Mẫu thiết kế
interface (được thỏa mãn ngầm định) trait (triển khai tường minh bằng impl Trait for Type) Trait
[]Shape chứa nhiều kiểu khác nhau Vec<Box<dyn Shape>> (một trait object) Trait object
any / interface{} + type switch Một enum với mỗi biến thể (variant) cho một trường hợp, rồi match Enum, match
Generic func Max[T cmp.Ordered](a, b T) T Generic với ràng buộc trait (trait bound): fn max<T: PartialOrd>(a: T, b: T) -> T Generic, Generic nâng cao
Con trỏ nil, map nil, interface nil Option<T>: hoặc Some(value) hoặc None Option
v, ok := m[k] m.get(&k) trả về một Option HashMap
(T, error) + if err != nil { return err } Result<T, E> + toán tử ? Result
errors.New, sentinel error, errors.Is / errors.As Một enum lỗi mà bạn match trên đó (crate thiserror hỗ trợ) Lỗi tự định nghĩa, anyhow và thiserror
fmt.Errorf("...: %w", err) anyhow với .context("..."), hoặc map_err anyhow và thiserror
panic / recover panic! cho bug thật sự; hiếm khi cần khôi phục (std::panic::catch_unwind) Panic
defer Drop, chạy khi một giá trị ra khỏi phạm vi (RAII) Phạm vi, Con trỏ thông minh
go f() std::thread::spawn(...), hoặc tokio::spawn(...) cho task async Lập trình đồng thời, Async/Await
chan T, make(chan T, n) std::sync::mpsc::channel() (không giới hạn) hoặc sync_channel(n); tokio::sync::mpsc trong code async Channel
select tokio::select! trong code async, hoặc select! từ crossbeam-channel Async chuyên sâu
sync.Mutex nằm cạnh dữ liệu Mutex<T> bọc chính dữ liệu; nó tự mở khóa khi guard bị drop Trạng thái dùng chung
sync.WaitGroup JoinHandle::join(), hoặc std::thread::scope Lập trình đồng thời
sync.Once std::sync::OnceLock / LazyLock Thành ngữ của std
context.Context Không có thứ tương đương duy nhất: drop một future để hủy nó, tokio::time::timeout, hoặc CancellationToken từ tokio-util Async chuyên sâu
Giá trị func và closure Closure, được mô tả bằng các trait Fn, FnMut, FnOnce Closure, Fn, FnMut và FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() Vòng lặp for, Iterator
Vòng lặp for duy nhất loop, while và for Vòng lặp
Iterator dạng range-over-func (iter.Seq) Trait Iterator, với các adapter như filter, map, sum, collect Iterator, Iterator tự định nghĩa
switch match (trình biên dịch kiểm tra bạn đã xử lý mọi trường hợp) match, So khớp mẫu
const + iota Một enum (có thể kèm số tường minh) Enum
:= let (mặc định bất biến; let mut để cho phép thay đổi) Biến
string (dãy byte, thường là UTF-8) String (sở hữu) và &str (mượn), luôn là UTF-8 hợp lệ Chuỗi
rune, for _, r := range s char, for c in s.chars() Chuỗi
[]byte Vec<u8> hoặc &[u8] Slice
fmt.Println, %v, %+v println!, {} (Display), {:?} và {:#?} (Debug, với #[derive(Debug)]) In ra màn hình
String() string (interface Stringer) Triển khai Display (rồi .to_string() tự động dùng được) Các trait thông dụng
Struct tag `json:"user_name"` Attribute: #[serde(rename = "user_name")] JSON với Serde
Chuyển đổi int(x), float64(n) as cho số, From/Into, và TryFrom khi việc chuyển đổi có thể thất bại Ép kiểu, Chuyển đổi
Tràn số nguyên quay vòng lặng lẽ Panic trong bản build debug, quay vòng trong bản release; checked_add, wrapping_add, saturating_add nói rõ bạn muốn gì Tràn số nguyên
*T và &x Tham chiếu &T và &mut T (không bao giờ null, không thể sống lâu hơn giá trị); Box<T> cho một giá trị sở hữu trên heap Mượn, Con trỏ thông minh
Package Module (mod) bên trong một crate; use thay cho import Module
go generate Macro và build script build.rs Macro, Procedural macro
Package unsafe Khối unsafe và con trỏ thô (raw pointer) Unsafe
cgo extern "C" và FFI (bindgen sinh binding từ header C) FFI (gọi C)
Build tag //go:build linux #[cfg(target_os = "linux")] và feature của Cargo Feature của Cargo và cfg

Go có slice và map được xây sẵn trong ngôn ngữ. Rust có chúng trong thư viện chuẩn, cùng với vài thứ khác.

Go Rust Tìm hiểu thêm
[]T mà bạn sở hữu và append vào Vec<T> Vector
[]T truyền vào một hàm chỉ đọc nó &[T] (một slice mượn từ một Vec hoặc một mảng) Slice
[N]T [T; N] Mảng
append(s, x) / append(s, other...) v.push(x) / v.extend(other) Vector
s[a:b] &v[a..b] Slice
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) Vector
map[K]V HashMap<K, V> HashMap
map[K]struct{} hoặc map[K]bool dùng làm set HashSet<T> HashSet và BTreeMap
Gom và sắp xếp khóa của map trước khi in BTreeMap<K, V> (khóa luôn được sắp xếp) HashMap
container/list VecDeque<T> (push_back / pop_front) VecDeque và BinaryHeap
container/heap (phải triển khai 5 phương thức) BinaryHeap<T>: chỉ cần push và pop; lớn nhất ra trước, bọc trong Reverse để nhỏ nhất ra trước VecDeque và BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) Sắp xếp và tìm kiếm
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) Vector
strings.Builder Một String với push_str, hoặc write! Chuỗi
sync.Map Mutex<HashMap<K, V>>, RwLock, hoặc crate dashmap Trạng thái dùng chung

Có hai điều cần biết. Thứ nhất, giống map của Go, HashMap của Rust không có thứ tự cố định, nên hãy sắp xếp trước khi in hoặc dùng BTreeMap. Thứ hai, cái bẫy slice nổi tiếng của Go (hai slice dùng chung một mảng nền, và một lần append làm thay đổi slice kia) không thể xảy ra trong Rust: khi một phần code của bạn đang giữ một borrow &mut của một Vec, không ai khác được nhìn vào nó.

Tin tốt: bạn đã quen với một công cụ làm mọi việc, và Rust cũng có một công cụ như thế. Cargo bao trọn go build, go run, go test, go mod và go install.

Go Rust Ghi chú
Lệnh go cargo Build, chạy, test, thêm gói, publish. Xem Cargo và crate
go mod init cargo new my_app (cargo new --lib cho thư viện) Tạo thư mục, Cargo.toml, src/main.rs và một git repo
go.mod Cargo.toml
go.sum Cargo.lock Được tạo và cập nhật tự động cho bạn
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Cập nhật trong phạm vi phiên bản ghi trong Cargo.toml
pkg.go.dev crates.io cho các gói, docs.rs (tiếng Anh) cho tài liệu API của chúng
go build / go run / go test cargo build / cargo run / cargo test Thêm --release để có bản build tối ưu; bản build debug chạy chậm hơn nhiều
go vet, staticcheck, golangci-lint cargo clippy Chính thức, với hàng trăm lint hữu ích
gofmt / goimports cargo fmt (rustfmt) Một phong cách chuẩn duy nhất, giống gofmt
go doc cargo doc --open Ví dụ code trong doc comment được chạy như test. Xem Tài liệu
go test -bench criterion (chạy bằng cargo bench) Xem Hiệu năng
go test -cover cargo llvm-cov hoặc cargo tarpaulin
go test -race Không cần với code an toàn: data race là lỗi biên dịch. Miri kiểm tra code unsafe
go test -fuzz cargo fuzz Cài bằng cargo install cargo-fuzz
testify assert! và assert_eq! có sẵn; pretty_assertions cho diff đẹp hơn Xem Kiểm thử và Kiểm thử nâng cao
gomock / mockery mockall, hoặc tự viết bản giả (fake) triển khai trait của bạn
go install pkg@latest cargo install name
go work (workspace) Một Cargo workspace Một Cargo.toml ở thư mục gốc liệt kê các crate thành viên
Dòng toolchain trong go.mod rustup và một file rust-toolchain.toml Xem Công cụ và cài đặt
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... Thường cần một linker cho target đó; cross hoặc cargo-zigbuild giúp việc này dễ hơn
govulncheck cargo audit (cảnh báo RustSec), cargo deny
gopls rust-analyzer Chạy trong VS Code, Neovim, Helix, Zed và nhiều editor khác
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb, hoặc CodeLLDB trong VS Code
pprof cargo flamegraph, perf, hoặc bất kỳ profiler native nào
air (tự tải lại) bacon hoặc cargo watch

Một Cargo.toml nhỏ đặt cạnh file go.mod mà bạn đã biết:

module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0
[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Có một khác biệt bạn sẽ nhận ra ngay: thư viện chuẩn của Go có sẵn HTTP server, JSON, template và mã hóa. Thư viện chuẩn của Rust cố ý nhỏ hơn, nên những thứ như JSON, HTTP, async và số ngẫu nhiên đến từ các crate. Thêm một crate chỉ cần một lệnh cargo add, và có một nhóm nhỏ crate (serde, tokio, clap, regex, rand) mà gần như ai cũng dùng.

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ì?””

Đây là câu hỏi thực tế đầu tiên. “Bọn mình dùng Gin và GORM. Trong Rust có gì?” Dưới đây là những thứ tương đương gần nhất. Giống Go, Rust thường có hai ba lựa chọn cộng đồng đều tốt thay vì một câu trả lời chính thức.

Go Rust Ghi chú
Gin, Echo, Chi, Fiber 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
Server net/http hyper (cấp thấp), thường được dùng qua axum Thư viện chuẩn không có HTTP server
Middleware http.Handler Các layer của tower và tower-http (CORS, nén, tracing, timeout) axum dùng trực tiếp tower
Client net/http reqwest Mặc định bất đồng bộ, có client blocking tùy chọn. Xem Lập trình mạng
grpc-go tonic (kèm prost cho protobuf)
gorilla/websocket tokio-tungstenite, hoặc feature ws của axum
html/template, text/template askama (kiểm tra lúc biên dịch), tera, minijinja
net (TCP/UDP) std::net, hoặc tokio::net cho code async Xem Lập trình mạng
errgroup tokio::task::JoinSet
Go Rust Ghi chú
encoding/json + struct tag serde + serde_json Còn làm được TOML (crate toml) và các định dạng khác. Xem JSON với Serde
database/sql + pgx sqlx (bất đồng bộ, SQL thuần được kiểm tra với cơ sở dữ liệu của bạn lúc biên dịch), tokio-postgres Không liên quan gì tới jmoiron/sqlx của Go, dù trùng tên
sqlc Các macro query! của sqlx Cả hai đều bắt đầu từ SQL do bạn viết và cho bạn kết quả có kiểu
GORM SeaORM (bất đồng bộ, kiểu entity), Diesel (query builder được kiểm tra kiểu) Xem Cơ sở dữ liệu
go-sqlite3 rusqlite
go-redis redis

CLI, cấu hình, ghi log và công cụ hằng ngày

Phần tiêu đề “CLI, cấu hình, ghi log và công cụ hằng ngày”
Go Rust Ghi chú
cobra, urfave/cli, flag clap (derive đối số từ một struct) Xem Đối số dòng lệnh và Dự án: công cụ CLI với clap
viper config hoặc figment Xếp chồng file và biến môi trường, rồi deserialize bằng serde
log/slog, zap, zerolog tracing + tracing-subscriber, hoặc log + env_logger Log có cấu trúc. Xem Ghi log
regexp regex Cùng ý tưởng với RE2 của Go: so khớp thời gian tuyến tính, không có backreference. Xem Biểu thức chính quy
time std::time cho khoảng thời gian; chrono hoặc jiff cho ngày tháng và múi giờ Xem Ngày và giờ
embed include_str! / include_bytes!, hoặc rust-embed cho cả thư mục
bubbletea ratatui Giao diện terminal
Prometheus client_golang prometheus hoặc metrics
sync/atomic std::sync::atomic
Go Rust Ghi chú
Wails Tauri (giao diện web + backend Rust) Ý tưởng rất giống nhau. Xem Ứng dụng giao diện (GUI)
Fyne egui, Iced, Slint Không có bộ công cụ GUI chính thức
Ebitengine macroquad, Bevy 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
TinyGo embedded-hal, Embassy Rust chạy được mà không cần hệ điều hành và không cần heap. Xem Rust nhúng (embedded)
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus Binary nhỏ, không phải mang theo GC. Xem Rust và WebAssembly

Đặt cạnh nhau: struct và phương thức (gần như giống hệt)

Phần tiêu đề “Đặt cạnh nhau: struct và phương thức (gần như giống hệt)”

Đây là chỗ lập trình viên Go thấy như ở nhà. Đây là một kiểu tài khoản nhỏ viết bằng Go:

type Account struct {
Owner string // exported: viết hoa chữ cái đầu
balance int // không exported
}
func NewAccount(owner string, balance int) *Account {
return &Account{Owner: owner, balance: balance}
}
func (a *Account) Deposit(amount int) { a.balance += amount }
func (a *Account) Balance() int { return a.balance }
func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() {
account := NewAccount("Ada", 100)
account.Deposit(50)
fmt.Println(account)
fmt.Println("số dư là", account.Balance())
}

Và bằng Rust:

use std::fmt;
// `pub` làm việc của chữ cái viết hoa trong Go.
pub struct Account {
pub owner: String,
balance: i64, // private, giống một field viết thường trong Go
}
impl Account {
// Giống NewAccount của Go: một hàm bình thường. `new` chỉ là một quy ước.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// Giống một pointer receiver: phương thức này thay đổi tài khoản.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Chỉ đọc. Không có bản sao nào được tạo, khác với value receiver của Go.
pub fn balance(&self) -> i64 {
self.balance
}
}
// Phương thức String() của Go (interface Stringer) trở thành Display.
impl fmt::Display for Account {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}", self.owner, self.balance)
}
}
fn main() {
let mut account = Account::new("Ada", 100);
account.deposit(50);
println!("{account}");
println!("số dư là {}", account.balance());
}

Kết quả:

Ada: 150
số dư là 150
▶ Tự chạy thử

Hình dạng thì giống nhau: dữ liệu nằm trong một struct, phương thức gắn vào nó, constructor chỉ là một hàm. Những khác biệt tuy nhỏ nhưng quan trọng. Các phương thức nằm chung trong một khối impl. Receiver nói chính xác phương thức làm gì: &self thì đọc, &mut self thì thay đổi, còn self thuần thì lấy giá trị và dùng hết nó. Và vì deposit thay đổi tài khoản, biến phải được khai báo let mut. Trong Go, ai giữ một *Account cũng có thể thay đổi nó; trong Rust, quyền được thay đổi một thứ hiện rõ ngay trong code.

Đặt cạnh nhau: interface ngầm định trở thành trait tường minh

Phần tiêu đề “Đặt cạnh nhau: interface ngầm định trở thành trait tường minh”

Trong Go, một kiểu thỏa mãn một interface chỉ cần có đúng các phương thức:

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// Không có "implements" ở đâu cả: có đủ phương thức là được.
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Name() string { return "hình tròn" }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Name() string { return "hình chữ nhật" }
func describe[T Shape](s T) {
fmt.Printf("%s: %.2f\n", s.Name(), s.Area())
}
func main() {
describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("tổng diện tích: %.2f\n", total)
}

Trong Rust, bạn viết rõ mối liên hệ đó bằng impl Shape for Circle:

use std::f64::consts::PI;
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle {
r: f64,
}
struct Rect {
w: f64,
h: f64,
}
// Dòng này là phần mà Go bỏ qua.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"hình tròn"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"hình chữ nhật"
}
}
// Generic, giống describe[T Shape](s T) của Go.
fn describe<T: Shape>(s: &T) {
println!("{}: {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Giống []Shape của Go: nhiều kiểu khác nhau đứng sau một trait.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { r: 1.0 }),
Box::new(Rect { w: 2.0, h: 3.0 }),
];
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("tổng diện tích: {total:.2}");
}

Kết quả:

hình tròn: 3.14
tổng diện tích: 9.14
▶ Tự chạy thử

Viết tường minh tốn thêm một dòng cho mỗi kiểu, và đổi lại bạn có vài thứ mà Go không làm được. Một trait có thể có phương thức mặc định, nên các kiểu có hành vi dùng chung mà không phải viết gì. Bạn có thể triển khai trait của riêng mình cho những kiểu không do bạn viết, kể cả i32 hay String (Go chỉ cho bạn thêm phương thức vào các kiểu trong package của chính bạn). Và hàm generic describe được biên dịch riêng cho từng kiểu cụ thể, nên lời gọi không phải tra cứu gì lúc chạy. Box<dyn Shape> là phong cách interface của Go: một giá trị trỏ tới “một kiểu nào đó triển khai Shape”, với phương thức được tra cứu lúc chạy. Xem Trait và Trait object.

Trong Go, một con trỏ có thể bị thiếu là nil, và bạn phải nhớ kiểm tra:

type User struct {
Name string
Manager *User // nil nghĩa là "không có quản lý"
}
func findUser(users []*User, name string) *User {
for _, u := range users {
if u.Name == name {
return u
}
}
return nil
}
func main() {
ada := &User{Name: "Ada"}
users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} {
u := findUser(users, name)
if u == nil {
fmt.Println(name + ": không có người dùng này")
continue
}
boss := "không ai"
if u.Manager != nil {
boss = u.Manager.Name
}
fmt.Println(name, "báo cáo cho", boss)
}
}

Trong Rust, “có thể bị thiếu” là một phần của kiểu. Option<&User> hoặc là Some(user) hoặc là None, và trình biên dịch không cho bạn dùng user cho tới khi bạn đã xử lý None:

struct User {
name: String,
manager: Option<String>, // Go: Manager *User, có thể là nil
}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> {
users.iter().find(|u| u.name == name)
}
fn main() {
let users = vec![
User { name: "Ada".to_string(), manager: None },
User { name: "Bob".to_string(), manager: Some("Ada".to_string()) },
];
for name in ["Bob", "Ada", "Eve"] {
match find_user(&users, name) {
Some(user) => {
let boss = user.manager.as_deref().unwrap_or("không ai");
println!("{name} báo cáo cho {boss}");
}
None => println!("{name}: không có người dùng này"),
}
}
}

Kết quả:

Bob báo cáo cho Ada
Ada báo cáo cho không ai
Eve: không có người dùng này
▶ Tự chạy thử

Cùng ý tưởng đó thay thế cách tra cứu comma-ok của Go. if email, ok := emails[name]; ok { ... } trở thành if let Some(email) = emails.get(name) { ... }. Có một điều cần để ý: trong Go, emails["missing"] lặng lẽ cho bạn giá trị zero "". Trong Rust, emails["missing"] gây panic, nên hãy dùng .get() mỗi khi khóa có thể không tồn tại. Chữ 'a trong find_user là một lifetime (vòng đời): nó cho trình biên dịch biết rằng &User trả về trỏ vào bên trong users. Bạn sẽ sớm gặp chúng.

Đặt cạnh nhau: if err != nil trở thành Result và ?

Phần tiêu đề “Đặt cạnh nhau: if err != nil trở thành Result và ?”

Cách xử lý lỗi của Go nổi tiếng với mẫu ba dòng của nó:

func parsePort(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("cổng không hợp lệ %q: %w", s, err)
}
if n < 1 || n > 65535 {
return 0, fmt.Errorf("cổng %d nằm ngoài phạm vi cho phép", n)
}
return n, nil
}
func address(host, port string) (string, error) {
p, err := parsePort(port)
if err != nil {
return "", err
}
return fmt.Sprintf("%s:%d", host, p), nil
}
func main() {
for _, port := range []string{"8080", "abc", "70000", "0"} {
addr, err := address("localhost", port)
if err != nil {
fmt.Println("lỗi:", err)
continue
}
fmt.Println("đang lắng nghe trên", addr)
}
}

Rust giữ lại ý tưởng (lỗi là giá trị bạn trả về) nhưng gói cả mẫu đó vào một ký tự, ?:

fn parse_port(s: &str) -> Result<u16, String> {
// Một u16 không chứa nổi 70000, nên parse() bắt lỗi “ngoài phạm vi” giúp chúng ta.
let port: u16 = s.parse().map_err(|e| format!("cổng không hợp lệ {s:?}: {e}"))?;
if port == 0 {
return Err("cổng 0 được dành riêng".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?` nghĩa là: nếu đây là lỗi, trả nó về ngay lập tức.
let port = parse_port(port)?;
Ok(format!("{host}:{port}"))
}
fn main() {
for port in ["8080", "abc", "70000", "0"] {
match address("localhost", port) {
Ok(addr) => println!("đang lắng nghe trên {addr}"),
Err(e) => println!("lỗi: {e}"),
}
}
}

Kết quả:

đang lắng nghe trên localhost:8080
lỗi: cổng không hợp lệ "abc": invalid digit found in string
lỗi: cổng không hợp lệ "70000": number too large to fit in target type
lỗi: cổng 0 được dành riêng
▶ Tự chạy thử

Hai phần “invalid digit found in string” (gặp chữ số không hợp lệ trong chuỗi) và “number too large to fit in target type” (số quá lớn so với kiểu đích) là thông báo lỗi có sẵn của Rust, nên chúng vẫn là tiếng Anh.

Có một khác biệt sâu xa hơn việc tiết kiệm vài dòng. Trong Go, n, err := strconv.Atoi(s) cho bạn cả hai giá trị, và chẳng gì ngăn bạn dùng n khi err không phải nil. Một Result thì hoặc là giá trị hoặc là lỗi, nên bạn không thể chạm tới giá trị mà không xử lý lỗi trước. Và nếu bạn bỏ qua hoàn toàn một Result, trình biên dịch sẽ cảnh báo.

Trong dự án thật, lỗi kiểu String được thay bằng thứ phong phú hơn. Một enum lỗi (thường viết bằng crate thiserror) đóng vai trò của sentinel error và errors.Is: nơi gọi match trên biến thể. Crate anyhow cho bạn .context("loading config")?, chính là fmt.Errorf("loading config: %w", err) của Rust. Xem Lỗi tự định nghĩa và anyhow và thiserror.

Đặt cạnh nhau: goroutine và channel trở thành thread, mpsc và task

Phần tiêu đề “Đặt cạnh nhau: goroutine và channel trở thành thread, mpsc và task”

Đây là mẫu fan-in kinh điển của Go: khởi động vài goroutine, gửi kết quả xuống một channel, đóng channel khi chúng xong việc:

func main() {
results := make(chan int)
var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} {
wg.Add(1)
go func(n int) {
defer wg.Done()
results <- n * n
}(n)
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for r := range results {
total += r
}
fmt.Println("tổng:", total)
}

Phiên bản Rust dùng luồng (thread) và channel của thư viện chuẩn còn ngắn hơn một chút:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] {
let tx = tx.clone(); // mỗi luồng có sender riêng
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// Drop sender của chính chúng ta. Khi mọi sender đều đã mất, channel
// đóng lại và vòng lặp bên dưới kết thúc: không cần WaitGroup.
drop(tx);
let total: i32 = rx.iter().sum();
println!("tổng: {total}");
}

Kết quả:

tổng: 14
▶ Tự chạy thử

Vài thứ ánh xạ khác đi. mpsc nghĩa là “multi-producer, single-consumer” (nhiều bên gửi, một bên nhận): bạn có thể clone sender, nhưng không clone được receiver (với channel nhiều bên đọc kiểu Go, hãy dùng crossbeam-channel). mpsc::channel() là không giới hạn, thứ mà Go không có; mpsc::sync_channel(n) là make(chan T, n) của Go, và sync_channel(0) là một channel không có bộ đệm. Không ai gọi close: channel tự đóng khi sender cuối cùng bị drop. move trao cho mỗi luồng bản sao n và tx của riêng nó, đó là cách Rust bảo đảm các luồng không bao giờ dùng chung dữ liệu một cách không an toàn. Xem Channel và Lập trình đồng thời.

thread::spawn khởi động một luồng thật của hệ điều hành, nặng hơn một goroutine. Với vài worker thì không sao. Với hàng nghìn kết nối đồng thời, Rust dùng task async trên một runtime như tokio, thứ gần với goroutine hơn nhiều:

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// Giống `go func() { ... }()`, nhưng bạn nhận lại một handle.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("tổng: {total}");
}

Kết quả:

tổng: 14
▶ Tự chạy thử

Nói thật: đây là một chỗ Go đơn giản hơn. Trong Go, hàm nào cũng có thể block và runtime lo việc đó. Trong Rust, bạn đánh dấu hàm là async, gọi chúng bằng .await, và chọn một runtime. Bù lại, trình biên dịch từ chối build code mà hai luồng có thể cùng thay đổi một dữ liệu một lúc, nên go test -race chẳng còn gì để tìm. Xem Async/Await, Async chuyên sâu và Trạng thái dùng chung để biết về Arc<Mutex<T>>, phiên bản Rust của một struct có sync.Mutex nằm cạnh dữ liệu.

Trong Go, bạn lên lịch dọn dẹp bằng defer, và các lời gọi defer chạy theo thứ tự ngược lại khi hàm trả về:

func process() {
fmt.Println("mở file")
defer fmt.Println("đóng file")
fmt.Println("khóa mutex")
defer fmt.Println("mở khóa mutex")
fmt.Println("đang làm việc")
}
func main() {
process()
fmt.Println("xong")
}

Trong Rust, việc dọn dẹp thuộc về chính giá trị. Khi một giá trị ra khỏi phạm vi (scope), phần triển khai Drop của nó chạy, và các giá trị được drop theo thứ tự ngược lại, y như các lệnh defer của bạn:

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("mở file");
let _file = Guard("đóng file");
println!("khóa mutex");
let _lock = Guard("mở khóa mutex");
println!("đang làm việc");
} // _lock bị drop ở đây, rồi tới _file
fn main() {
process();
println!("xong");
}

Kết quả:

mở file
khóa mutex
đang làm việc
mở khóa mutex
đóng file
xong
▶ Tự chạy thử

Trong code thật, bạn hiếm khi tự viết Drop: File tự đóng, MutexGuard tự mở khóa, TcpStream tự ngắt kết nối. Chẳng có gì để quên. Có hai khác biệt so với defer đáng để biết. Drop chạy ở cuối khối lệnh, không phải cuối hàm, nên cái bẫy defer chồng chất bên trong vòng lặp của Go không tồn tại: mỗi lượt lặp tự dọn dẹp sau khi xong. Và hãy cẩn thận với let _ = Guard(...): dấu _ đứng một mình không giữ giá trị lại chút nào, nên nó bị drop ngay lập tức. Hãy dùng một cái tên như _lock.

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

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

Ai đến từ Go 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ị có thể move nó. Trong Go, truyền một slice vào hàm sẽ sao chép một header nhỏ và cả hai bên vẫn dùng tiếp được. Trong Rust, truyền một Vec sẽ chuyển (move) nó vào hàm, và bạn không dùng được nó sau đó nữa:

    fn total(prices: Vec<u32>) -> u32 {
    prices.iter().sum()
    }
    fn main() {
    let prices = vec![3, 4, 5];
    let sum = total(prices);
    println!("{sum} from {} items", prices.len());
    }
    error[E0382]: borrow of moved value: `prices`
    --> src/main.rs:8:37
    |
    6 | let prices = vec![3, 4, 5];
    | ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    Trình biên dịch báo rằng prices đã bị move vào total ở dòng 7, nên dòng 8 không được dùng nó nữa. Cách sửa chính là thói quen của Go trong hình hài Rust: cho hàm mượn một slice, fn total(prices: &[u32]), và gọi nó bằng total(&prices). Hãy đọc Quyền sở hữu trước; bài đó giải thích quy tắc này. Trang E0382 nói chi tiết về lỗi này.

  • Borrow checker. Tại một thời điểm, bạn có thể có nhiều người đọc (&T) hoặc một người ghi (&mut T), không bao giờ cả hai. Code Go mà nhiều struct cùng giữ con trỏ tới một struct dùng chung rồi thay đổi nó cần một thiết kế khác trong Rust, hoặc con trỏ thông minh như Rc<RefCell<T>> hay Arc<Mutex<T>>. 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.

  • Không có giá trị zero. let x: i32; rồi println!("{x}") là lỗi biên dịch, và mọi field của struct phải được gán khi bạn tạo struct. Nếu bạn muốn giá trị mặc định kiểu Go, hãy derive Default và viết Config { port: 8080, ..Default::default() }.

  • Triển khai trait là việc tường minh. Có một phương thức area không biến kiểu của bạn thành một Shape. Bạn cần impl Shape for Circle. Mặt tốt: bạn có thể thấy mọi phần triển khai chỉ bằng cách tìm kiếm.

  • String và &str. Go có một kiểu string. Rust có hai: String sở hữu văn bản của nó, &str thì mượn. Hàm thường nhận &str, struct thường lưu String. Và bạn không thể đánh chỉ số vào chuỗi bằng s[0] như trong Go; hãy dùng s.chars() hoặc s.as_bytes()[0]. Xem Chuỗi.

  • Thứ không dùng đến chỉ là cảnh báo, không phải lỗi. Go từ chối biên dịch khi có import hay biến không dùng. Rust chỉ cảnh báo, và bạn có thể thêm tiền tố _ vào tên để nói “cố ý không dùng”.

  • Tràn số nguyên. Go quay vòng lặng lẽ. Rust gây panic trong bản build debug và quay vòng trong bản build release, nên hãy chọn checked_add, wrapping_add hoặc saturating_add khi có thể bị tràn. Số nguyên viết trực tiếp mặc định là i32, và chỉ số có kiểu usize. Xem Tràn số nguyên.

  • Thư viện chuẩn nhỏ hơn. Không có HTTP server, không có JSON, không có runtime async trong std. Bạn sẽ cargo add chúng. Lúc đầu thấy hơi lạ, nhưng các crate phổ biến đều ổn định và có tài liệu tốt.

  • Async cần một runtime và lan rộng ra. Một async fn không làm gì cả cho tới khi được await bên trong một runtime như tokio, và chỉ code async mới .await được. Go không có sự chia tách này. Hãy bắt đầu với luồng bình thường; chỉ dùng async khi bạn cần nhiều kết nối đồng thời.

  • Thời gian biên dịch. Go được tạo ra để biên dịch rất nhanh. Rust biên dịch chậm hơn, nhất là build sạch có nhiều crate. Hãy dùng cargo check trong lúc sửa code; nó bỏ qua bước sinh mã và nhanh hơn nhiều.

  • Biên dịch chéo cần thiết lập một chút. GOOS=linux go build là chạy luôn. Trong Rust, bạn thêm target bằng rustup và thường cần cả một linker cho target đó. Những công cụ như cross lo việc đó giúp bạn.

Cả hai đều là lựa chọn tốt, và nhiều nhóm dùng cả hai. Đây là một quy tắc kinh nghiệm công bằng.

Hãy chọn Go khi bạn muốn nhanh chóng đưa ra các network service và công cụ nội bộ, nhóm của bạn coi trọng code mà ai cũng đọc hiểu được sau một tuần, tốc độ build rất quan trọng, hoặc bạn làm việc trong thế giới cloud-native (Kubernetes, Docker, Terraform) nơi Go là ngôn ngữ chung. Thư viện chuẩn của Go và mô hình đồng thời đơn giản của nó khiến nhiều chương trình server rất dễ viết.

Hãy chọn Rust khi bạn cần độ trễ có thể dự đoán mà không có những lần khựng vì GC, cần dùng ít bộ nhớ, hoặc cần từng chút sức mạnh CPU (parser, cơ sở dữ liệu, game engine, video, nén dữ liệu). Rust cũng phù hợp hơn cho code không cần hệ điều hành, cho WebAssembly, và cho thư viện mà bạn muốn gọi từ các ngôn ngữ khác. Và nó đáng giá khi một cú sập vì data race hay vì quên kiểm tra nil gây tốn kém. Discord từng viết một bài nổi tiếng về việc chuyển một service từ Go sang Rust (tiếng Anh) vì những đợt tăng độ trễ do bộ thu gom rác gây ra.

Cái giá của Rust là thời gian học và build chậm hơn. Cái giá của Go là một bộ thu gom rác và những bug lộ ra lúc chạy thay vì lúc biên dịch. Cái giá nào đau hơn thì tùy vào dự án của bạn.

Bạn không cần đọc trang này theo thứ tự. Là lập trình viên Go, 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 rust-analyzer, và chạy cargo new.
  2. Biến và Kiểu dữ liệu: đọc lướt, chủ yếu để biết let mut và các kiểu số.
  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. Enum có dữ liệu của Rust thay thế được nhiều type switch của Go.
  6. Option và Result, rồi Lỗi tự định nghĩa và anyhow và thiserror.
  7. Trait, Generic, Các trait thông dụng và Trait object.
  8. Closure và Iterator: Rust dựa vào chuỗi iterator nhiều hơn Go rất nhiều.
  9. Vector, HashMap và HashSet và BTreeMap.
  10. Module, Cargo và crate và Kiểm thử.
  11. Lifetime và Con trỏ thông minh.
  12. Lập trình đồng thời, Channel, Trạng thái dùng chung, rồi Async/Await.
  13. JSON với Serde và Ghi log, rồi xây dự án Web API.

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

Chương trình Go này đếm số từ và in chúng theo thứ tự 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 thay cho counts[w]++ của Go. text.split_whitespace() chính là strings.Fields.

func main() {
text := "the cat and the dog and the bird"
counts := map[string]int{}
for _, w := range strings.Fields(text) {
counts[w]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, counts[k])
}
}

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 dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
let mut keys: Vec<&str> = counts.keys().copied().collect();
keys.sort();
for key in keys {
println!("{key}: {}", counts[key]);
}
}

Kết quả:

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Tự chạy thử

Ở đây không có giá trị zero nào để dựa vào như trong Go, nên entry(word).or_insert(0) nói rằng “bắt đầu từ 0 nếu từ này mới”. Đánh chỉ số bằng counts[key] là an toàn vì mọi khóa đều lấy từ chính map đó. Còn ngắn hơn nữa: dùng BTreeMap thay cho HashMap, các khóa sẽ ra theo thứ tự đã sắp xếp, và bạn bỏ được vector keys.

Đoạn Go này trả về một sentinel error và kiểm tra nó bằng errors.Is. Hãy chuyển nó sang Rust sao cho withdraw trả về Result<(), WithdrawError>, trong đó WithdrawError là một enum có hai biến thể. Gợi ý: triển khai fmt::Display cho enum để có thể in nó, và match trên biến thể ở chỗ Go dùng errors.Is.

var ErrInsufficientFunds = errors.New("không đủ tiền")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error {
if n <= 0 {
return fmt.Errorf("số tiền không hợp lệ %d", n)
}
if n > a.Balance {
return ErrInsufficientFunds
}
a.Balance -= n
return nil
}
func main() {
acc := &Account{Balance: 100}
for _, n := range []int{30, 0, 500} {
if err := acc.Withdraw(n); err != nil {
if errors.Is(err, ErrInsufficientFunds) {
fmt.Println("bị từ chối:", err)
} else {
fmt.Println("lỗi:", err)
}
continue
}
fmt.Println("ok, số dư", acc.Balance)
}
}
Xem lời giải
use std::fmt;
enum WithdrawError {
InvalidAmount(i64),
InsufficientFunds,
}
impl fmt::Display for WithdrawError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
WithdrawError::InvalidAmount(n) => write!(f, "số tiền không hợp lệ {n}"),
WithdrawError::InsufficientFunds => write!(f, "không đủ tiền"),
}
}
}
struct Account {
balance: i64,
}
impl Account {
fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> {
if n <= 0 {
return Err(WithdrawError::InvalidAmount(n));
}
if n > self.balance {
return Err(WithdrawError::InsufficientFunds);
}
self.balance -= n;
Ok(())
}
}
fn main() {
let mut acc = Account { balance: 100 };
for n in [30, 0, 500] {
match acc.withdraw(n) {
Ok(()) => println!("ok, số dư {}", acc.balance),
Err(e @ WithdrawError::InsufficientFunds) => println!("bị từ chối: {e}"),
Err(e) => println!("lỗi: {e}"),
}
}
}

Kết quả:

ok, số dư 70
lỗi: số tiền không hợp lệ 0
bị từ chối: không đủ tiền
▶ Tự chạy thử

Sentinel error và lỗi tạo bằng fmt.Errorf trở thành hai biến thể của cùng một enum, còn errors.Is trở thành một nhánh match. e @ WithdrawError::InsufficientFunds vừa khớp với biến thể vừa cho bạn cả giá trị dưới tên e để in ra. Một điểm cộng nữa: nếu sau này bạn thêm biến thể thứ ba, trình biên dịch sẽ chỉ cho bạn mọi match cần cập nhật. Crate thiserror có thể viết phần triển khai Display giúp bạn.

  • Phần lớn Go mang sang được: struct có phương thức, không có kế thừa, lỗi là giá trị, một công cụ định dạng chuẩn, và một công cụ để build và test.
  • Những thay đổi lớn là quyền sở hữu thay cho bộ thu gom rác, Option thay cho nil, và Result + ? thay cho if err != nil.
  • Interface trở thành trait, và việc triển khai trait là tường minh (impl Trait for Type). defer trở thành Drop tự động ở cuối một phạm vi.
  • Goroutine và channel trở thành thread::spawn + mpsc khi có vài worker, hoặc task của tokio khi có rất nhiều; trình biên dịch loại bỏ data race.
  • Cargo thay thế go build, go test và go mod; clippy và rustfmt thay thế go vet và gofmt. Thay Gin hãy xem axum hoặc actix-web, thay GORM hãy xem SeaORM hoặc Diesel, thay pgx và sqlc hãy xem sqlx, và thay cobra hãy xem clap.

Kiểm tra nhanh

1. Trong Go, một kiểu thỏa mãn một interface chỉ cần có đúng các phương thức. Một kiểu trong Rust triển khai một trait như thế nào?

2. ? làm gì khi đặt sau một lời gọi trả về Result?

3. Với std::sync::mpsc, khi nào for msg in rx (hoặc rx.iter()) dừng lại?

4. Nhóm Go của bạn xây HTTP API bằng Gin. 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), 9 (lỗi), 10 (generic và trait) và 16 (lập trình đồng thời) quan trọng nhất với lập trình viên Go.
  • Tokio tutorial (tiếng Anh): runtime async mà phần lớn server Rust dùng, thứ gần nhất với bộ lập lịch goroutine của Go.
  • Tài liệu axum (tiếng Anh): web framework nên xem đầu tiên nếu bạn đến từ Gin, Echo hay Chi.
  • Why Discord is switching from Go to Rust (tiếng Anh): câu chuyện thật của một nhóm khi chuyển một service nhạy cảm với độ trễ từ Go sang Rust.
  • Are We Web Yet? (tiếng Anh): tổng quan của cộng đồng về các thư viện và framework web của Rust.