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

Rust cho lập trình viên C#

Nếu bạn viết C# để kiếm sống thì có tin vui đây: rất nhiều thứ trong Rust sẽ khiến bạn thấy quen thuộc. Rust có generic với ràng buộc, có async/await, có so khớp mẫu (pattern matching) mạnh mẽ, có chuỗi iterator đọc lên rất giống LINQ, và có một công cụ dòng lệnh tuyệt vời (Cargo) làm những việc mà dotnet vẫn làm. Bạn vốn đã biết cách tư duy bằng kiểu, interface và lambda. Những kiến thức đó đều mang sang được.

Thay đổi lớn nằm ở bộ nhớ và cách xử lý lỗi. Rust không có bộ thu gom rác (garbage collector). Thay vào đó, 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 cũng không có null (bạn dùng Option) và không có exception (bạn dùng Result). Ba thay đổi này là nơi bạn sẽ tốn phần lớn thời gian học. Trang này đối chiếu những gì bạn biết từ C# và .NET 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.

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.

C# Rust Tìm hiểu thêm
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
interface trait Trait
Abstract class, 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ế
List<IShape> chứa nhiều class khác nhau Vec<Box<dyn Shape>> (một trait object) Trait object
Extension method Trait của riêng bạn, triển khai cho một kiểu không do bạn viết (như String) Trait
Generic với where T : IComparable<T> Generic với ràng buộc trait (trait bound): T: Ord hoặc where T: Ord Generic
null, Nullable<int>, nullable reference type Option<T>: hoặc Some(value) hoặc None Option
?? và ?. .unwrap_or(...), .map(...), .and_then(...) và ? Option
Exception, throw, try/catch Result<T, E> + toán tử ? Result, Lỗi tự định nghĩa
Exception cho một bug thật sự (như InvalidOperationException) panic! (dừng luồng hiện tại) Panic
IDisposable + using Drop, tự động chạy khi chủ sở hữu ra khỏi phạm vi (RAII) Phạm vi, Con trỏ thông minh
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
record struct với #[derive(Debug, Clone, PartialEq)] Struct, Các trait thông dụng
Biểu thức with trên record Cú pháp cập nhật struct: Order { total: 5, ..old } Struct
enum (chỉ là các số nguyên có tên) enum mà mỗi biến thể (variant) có thể mang dữ liệu riêng Enum
Biểu thức switch, pattern is 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
Tuple (int, string) Tuple (i32, String) Tuple
Tham số ref / out &mut T, hoặc trả về một tuple hay một Option Mượn
Lambda, Func<T>, Action<T>, delegate Closure, được mô tả bằng các trait Fn, FnMut, FnOnce Closure, Fn, FnMut và FnOnce
event Một channel, hoặc một danh sách closure đã đóng hộp (callback) Channel, Fn, FnMut và FnOnce
LINQ: Where, Select, Sum, OrderBy, ToList Iterator adapter: filter, map, sum, sort_by (trên Vec), collect Iterator, Sắp xếp và tìm kiếm
IEnumerable<T> và yield return impl Iterator<Item = T>, hoặc kiểu của riêng bạn triển khai Iterator Iterator tự định nghĩa
async Task<T> / await async fn trả về một Future; bạn cần một runtime như tokio để chạy nó Async/Await
Task.Run, Thread, lock std::thread::spawn hoặc tokio::spawn; Mutex<T> bọc chính dữ liệu mà nó bảo vệ Lập trình đồng thời, Trạng thái dùng chung
Property (get; set;) Không có property. Dùng field pub, hoặc phương thức getter và setter Struct
string và StringBuilder &str (một góc nhìn mượn vào văn bản) và String (sở hữu, có thể nới rộng) Chuỗi
Nội suy chuỗi $"Hi {name}" format!("Hi {name}") và println!("Hi {name}") In ra màn hình
var let (mặc định bất biến; viết let mut để cho phép thay đổi) Biến
const, readonly const, và let thông thường (vốn đã chỉ đọc) Biến
Field static readonly static, hoặc LazyLock cho giá trị được tạo lúc khởi động Biến
namespace và chỉ thị using Module (mod) và use Module
Assembly / project Crate (một thư viện hoặc một file binary) Cargo và crate
Attribute như [Serializable] Attribute như #[derive(Serialize)] và derive macro Macro, JSON với Serde
Reflection Gần như không có lúc chạy. Thay vào đó macro và derive sinh code lúc biên dịch Macro
unsafe và con trỏ Khối unsafe và con trỏ thô (raw pointer) Unsafe
Span<T>, ReadOnlySpan<char> Slice: &[T] và &str Slice
Ghi đè ToString(), IFormattable Triển khai Display (rồi .to_string() tự động dùng được) Các trait thông dụng
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (thường dùng derive) Các trait thông dụng
Toán tử chuyển đổi implicit / explicit From/Into (và TryFrom khi việc chuyển đổi có thể thất bại) Chuyển đổi
Nạp chồng toán tử (operator +) Triển khai các trait trong std::ops, như Add Trait nâng cao
Phép tính checked / unchecked checked_add, wrapping_add, saturating_add Tràn số nguyên
decimal Không có kiểu decimal có sẵn; dùng crate rust_decimal, hoặc lưu số xu trong một số nguyên Kiểu dữ liệu

Thư viện chuẩn bao gồm gần như mọi thứ trong System.Collections.Generic. Tên thì đổi, ý tưởng thì không.

C# Rust Tìm hiểu thêm
List<T> Vec<T> Vector
T[] (độ dài cố định) [T; N] khi biết độ dài lúc biên dịch, nếu không thì Vec<T> Mảng
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V> (khóa luôn được sắp xếp) HashMap
HashSet<T> HashSet<T> HashSet và BTreeMap
SortedSet<T> BTreeSet<T> HashSet và BTreeMap
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque và BinaryHeap
Stack<T> Vec<T> với push / pop Vector
PriorityQueue<TElement, TPriority> BinaryHeap<T> (lớn nhất ra trước; bọc trong Reverse để nhỏ nhất ra trước) VecDeque và BinaryHeap
LinkedList<T> LinkedList<T> có tồn tại, nhưng hiếm khi bạn cần; VecDeque thường nhanh hơn VecDeque và BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>, hoặc crate dashmap Trạng thái dùng chung

Một điều cần để ý: giống Dictionary, HashMap của Rust không có thứ tự cố định. Nếu bạn in nội dung của nó, hãy sắp xếp trước hoặc dùng BTreeMap.

Đây là phần mà hầu hết lập trình viên C# thích ngay lập tức. Cargo làm việc của dotnet CLI, MSBuild và NuGet trong cùng một công cụ.

.NET Rust Ghi chú
dotnet CLI cargo Build, chạy, test, thêm gói, publish. Xem Cargo và crate
.csproj Cargo.toml Ngắn gọn, dễ sửa bằng tay
packages.lock.json Cargo.lock Được tạo tự động cho bạn
.sln với nhiều project Một Cargo workspace Một Cargo.toml ở thư mục gốc liệt kê các crate thành viên
NuGet / nuget.org crates.io Tài liệu API của mọi crate đã publish đều có trên docs.rs (tiếng Anh)
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (cargo new --lib cho thư viện)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release Bản build debug chạy chậm hơn nhiều; hãy đo hiệu năng bằng bản release
dotnet watch cargo watch hoặc bacon (cài bằng cargo install)
dotnet tool install -g cargo install
global.json (phiên bản SDK) rustup và một file rust-toolchain.toml
Roslyn analyzer cargo clippy Hàng trăm lint hữu ích, chính thức
dotnet format cargo fmt Một phong cách chuẩn duy nhất cho mọi người
Visual Studio / Rider 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
XML doc comment /// 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
xUnit / NUnit / MSTest #[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ử
Moq mockall, hoặc tự viết bản giả (fake) triển khai trait của bạn
Coverlet (độ phủ code) cargo llvm-cov hoặc cargo tarpaulin
BenchmarkDotNet criterion Xem Hiệu năng
dotnet-trace, profiler cargo flamegraph, perf, hoặc bất kỳ profiler native nào

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

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

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 mà nhóm .NET nào cũng hỏi đầu tiên. “Bọn mình dùng Entity Framework. Trong Rust có gì?” Dưới đây là những thứ tương đương gần nhất. Rust không có một công ty duy nhất đứng sau một “bộ công nghệ mặc định” như Microsoft với .NET, nên bạn thường phải chọn giữa hai ba crate cộng đồng đều tốt.

.NET Rust Ghi chú
Entity Framework Core Diesel: ORM và query builder; truy vấn của bạn được kiểm tra kiểu lúc biên dịch. Migration bằng diesel_cli Trưởng thành (2.x), mặc định là đồng bộ (có diesel-async)
Entity Framework Core (async, kiểu entity) SeaORM: ORM bất đồng bộ với entity, quan hệ và migration (sea-orm-cli) Cảm giác gần EF nhất; xây dựng trên sqlx
Dapper 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. Migration bằng sqlx-cli Bất đồng bộ, rất phổ biến, không phải ORM
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) Driver cấp thấp hơn
StackExchange.Redis redis

Nếu bạn thích EF, hãy thử SeaORM trước. Nếu bạn thích Dapper và tự viết SQL, hãy thử sqlx. Nếu bạn muốn đảm bảo mạnh nhất lúc biên dịch, hãy thử Diesel. Bài Cơ sở dữ liệu trình bày cả ba với code chạy được.

.NET Rust Ghi chú
ASP.NET Core Web API 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
Minimal API axum: route là các handler async fn bình thường Phong cách rất giống nhau
Pipeline middleware Các layer của tower và crate tower-http (CORS, nén, tracing, timeout) axum dùng trực tiếp tower
Dependency injection có sẵn State trong axum, constructor thông thường và trait Xem dòng DI bên dưới
Swashbuckle / OpenAPI utoipa
SignalR WebSocket trong axum (feature ws), hoặc tokio-tungstenite Không có sẵn tầng giao thức kiểu SignalR
gRPC (Grpc.AspNetCore) tonic (kèm prost cho protobuf)
HttpClient reqwest Mặc định bất đồng bộ, có client blocking tùy chọn
Xác thực JWT bearer jsonwebtoken
.NET Rust Ghi chú
System.Text.Json / Newtonsoft.Json 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
ILogger / Serilog tracing (+ tracing-subscriber), hoặc log + env_logger Log có cấu trúc với span. Xem Ghi log
IConfiguration / appsettings.json config hoặc figment Xếp chồng file và biến môi trường, rồi deserialize bằng serde
DI container (IServiceCollection) Thường là không cần gì: truyền phụ thuộc vào constructor, dùng trait cho những thứ bạn muốn thay thế khi test Có shaku, nhưng ít dự án dùng container
AutoMapper Viết các triển khai From / Into Rõ ràng và được trình biên dịch kiểm tra. Xem Chuyển đổi
FluentValidation / DataAnnotations validator hoặc garde (kiểm tra dữ liệu dựa trên derive)
Polly (thử lại) backon, hoặc middleware retry của tower
MediatR Không có thứ tương đương trực tiếp Trait thông thường, hoặc channel giữa các task
Hangfire / tác vụ nền tokio::spawn cho task trong tiến trình; apalis cho hàng đợi job lưu bền
CancellationToken Drop future đó, hoặc dùng CancellationToken từ tokio-util
.NET Rust Ghi chú
Blazor WebAssembly Leptos, Yew, Dioxus Đều chưa tới phiên bản 1.0; API vẫn còn thay đổi. Xem Rust và WebAssembly
WPF / WinForms / MAUI Tauri (giao diện web + backend Rust), Slint, egui, Iced Tauri 2 và Slint 1.x ổn định nhất. Không có bộ công cụ GUI chính thức
Unity Bevy, hoặc Rust bên trong Godot với crate godot Bevy chưa tới 1.0 và thay đổi API giữa các phiên bản
ML.NET candle, burn, linfa Trẻ hơn nhiều so với công cụ ML của Python
Azure SDK cho .NET Azure SDK chính thức cho Rust: azure_core, azure_identity, azure_storage_blob, … Hỗ trợ ít dịch vụ hơn SDK .NET

Đặt cạnh nhau: class trở thành struct + impl

Phần tiêu đề “Đặt cạnh nhau: class trở thành struct + impl”

Hãy xem code thật. Đây là một tài khoản ngân hàng nhỏ viết bằng C#:

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner} có {account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

Và bằng Rust:

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` chỉ là một quy ước, không phải từ khóa.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`: phương thức này thay đổi tài khoản.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`: phương thức này chỉ đọc.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{} có {}", account.owner, account.balance());
}

Kết quả:

Ada có 150
▶ Tự chạy thử

Dữ liệu và hành vi nằm ở hai khối riêng biệt. Không có constructor, chỉ có những hàm trả về Self. Mỗi phương thức nói rõ ngay từ đầu rằng nó chỉ đọc (&self) hay thay đổi (&mut self) giá trị. Và vì deposit thay đổi tài khoản, biến phải là let mut. Trong C#, phương thức nào cũng có thể thay đổi field nào cũng được; trong Rust, việc thay đổi hiện rõ ngay trong chữ ký hàm.

Trong C#, bạn có thể có một interface IShape với hai class và một List<IShape>:

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

Trong Rust, interface là một trait, và mỗi struct triển khai nó trong khối impl của riêng mình:

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> đóng vai trò “một tham chiếu tới một IShape nào đó”.
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

Kết quả:

3.14
6.00
▶ Tự chạy thử

Trong C#, mọi lời gọi qua interface đều là lời gọi ảo (virtual call). Trong Rust, bạn được chọn: Box<dyn Shape> dùng lời gọi ảo (xem Trait object), còn một hàm generic như fn print_area<T: Shape>(s: &T) được biên dịch riêng cho từng kiểu, hoàn toàn không có lời gọi ảo. Một điểm cộng nữa: 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ả String hay i32. Đó là cách Rust làm điều mà extension method làm trong C#.

C# với nullable reference type sẽ cảnh báo bạn về null, nhưng null vẫn tồn tại lúc chạy:

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("không có email trong hồ sơ");
Console.WriteLine(FindEmail(2) ?? "không rõ");

Trong Rust, một giá trị có thể bị thiếu sẽ có kiểu Option<T>. 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:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("không có email trong hồ sơ"),
}
// `if let` là cách viết ngắn khi bạn chỉ quan tâm đến một trường hợp.
if let Some(email) = find_email(2) {
println!("tìm thấy {email}");
} else {
println!("không có email trong hồ sơ");
}
// unwrap_or hoạt động giống toán tử ?? của C#.
let email = find_email(2).unwrap_or("không rõ".to_string());
println!("{email}");
}

Kết quả:

không có email trong hồ sơ
không rõ
▶ Tự chạy thử

Rust không có NullReferenceException, vì chẳng có null nào để dereference 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>. 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 C#, việc parse có thể ném exception, và bạn bắt exception ở đâu đó phía trên call stack:

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "ba mươi"));
}
catch (FormatException e)
{
Console.WriteLine($"Lỗi: {e.Message}");
}

Trong Rust, một hàm có thể thất bại sẽ nói rõ điều đó trong kiểu trả về. 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:

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "ba mươi")] {
match total_seats(a, b) {
Ok(total) => println!("Tổng: {total}"),
Err(e) => println!("Lỗi: {e}"),
}
}
}

Kết quả:

Tổng: 42
Lỗi: invalid digit found in string
▶ Tự chạy thử

Phần “invalid digit found in string” là thông báo lỗi có sẵn của Rust (nghĩa là “gặp chữ số không hợp lệ trong chuỗi”), nên nó vẫn là tiếng Anh.

Khác biệt lớn: trong C#, phương thức nào cũng có thể ném exception, và chữ ký hàm không cho bạn biết điều đó. Trong Rust, Result là một phần của chữ ký hàm, nên nơi gọi không thể quên xử lý nó. 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 exception” (xem anyhow và thiserror).

Đặt cạnh nhau: LINQ trở thành chuỗi iterator

Phần tiêu đề “Đặt cạnh nhau: LINQ trở thành chuỗi iterator”

Một truy vấn LINQ điển hình:

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"Doanh thu: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

Phiên bản Rust dùng các iterator adapter và closure:

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...): việc sắp xếp diễn ra trên Vec, không phải trên iterator.
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("Doanh thu: {revenue}");
}

Kết quả:

Linus, Ken, Ada
Doanh thu: 550
▶ Tự chạy thử

Iterator của Rust là lười (lazy), giống như cơ chế thực thi trì hoãn (deferred execution) của LINQ: chẳng có gì chạy cho tới khi bạn gọi một thứ như collect(), sum() hoặc dùng vòng lặp for. collect() chính là ToList() của bạn. Khác với LINQ, chuỗi iterator thường được biên dịch thành cùng mã máy như một vòng lặp viết tay, nên bạn không phải trả giá cho phong cách viết đẹp. Việc sắp xếp được trình bày ở trang Sắp xếp và tìm kiếm.

Đặt cạnh nhau: using/IDisposable trở thành Drop

Phần tiêu đề “Đặt cạnh nhau: using/IDisposable trở thành Drop”

Trong C#, bạn phải nhớ dùng using để giải phóng tài nguyên đúng lúc:

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"đang truy vấn {conn.Name}");
}
Console.WriteLine("sau khối lệnh");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"đóng kết nối tới {Name}");
}

Trong Rust, việc dọn dẹp là tự động. Khi chủ sở hữu của một giá trị ra khỏi phạm vi (scope), Rust gọi phần triển khai Drop của nó. Chẳng có gì để quên cả:

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("đóng kết nối tới {}", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("đang truy vấn {}", conn.name);
} // `conn` ra khỏi phạm vi ở đây, nên drop() được chạy.
println!("sau khối lệnh");
}

Kết quả:

đang truy vấn orders-db
đóng kết nối tới orders-db
sau khối lệnh
▶ Tự chạy thử

Cách này gọi là RAII, và Rust xử lý mọi thứ theo cách đó: file, socket, khóa và cả chính bộ nhớ. Một file được đóng khi giá trị File của nó bị drop. Một Mutex được mở khóa khi guard của nó bị drop. Không có finalizer và không có lúc GC khựng lại; việc dọn dẹp xảy ra vào một thời điểm đoán trước được.

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

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

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

  • Phép gán là move, không phải sao chép tham chiếu. Trong C#, var b = a; với một class cho bạn hai tham chiếu tới cùng một object. Trong Rust, let b = a; chuyển (move) quyền sở hữu một String hay Vec sang b, và a không dùng được nữa:

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} and {copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner} and {copy}");
    | ^^^^^ value borrowed here after move

    Trình biên dịch báo rằng owner đã bị move sang copy ở dòng 3, nên dòng 4 không được dùng nó nữa. Cách sửa là mượn (let copy = &owner;) hoặc chủ động clone (owner.clone()). Hãy đọc bài Quyền sở hữu trước; bài đó giải thích quy tắc 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. Những mẫu bình thường trong C#, như object trỏ ngược về cha của nó, hay nhiều object cùng thay đổi một object dùng chung, cần đến con trỏ thông minh hoặc 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.

  • String và &str. C# 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 còn struct thường lưu String. Xem Chuỗi.

  • Không có kế thừa. Bạn không thể viết một base class với field dùng chung và ghi đè phương thức. Hãy dùng trait (có phương thức mặc định) cho hành vi dùng chung, và đặt dữ liệu dùng chung vào một struct mà các struct khác chứa bên trong.

  • Không có exception. Lỗi là giá trị. Bạn sẽ viết ? rất nhiều, và main của bạn cũng có thể trả về Result. Gọi .unwrap() khi đang học thì không sao, nhưng nó là phiên bản Rust của “kệ exception và để chương trình sập”.

  • Không có null. Lúc đầu, Option ở khắp nơi có vẻ như thêm việc. Một thời gian sau bạn sẽ nhận ra mình không còn gặp lỗi null reference nữa.

  • Clone tường minh. Rust không bao giờ lén sao chép một giá trị trên heap sau lưng bạn. Nếu bạn thấy .clone(), một bản sao thật sự được tạo ra. Đó là một tính năng: chi phí hiện rõ ra.

  • Thời gian biên dịch. Build sạch một dự án có nhiều crate lâu hơn build .NET. Build tăng dần (incremental) và cargo check (bỏ qua bước sinh mã) nhanh hơn nhiều; hãy dùng cargo check trong lúc sửa code.

  • Async cần một runtime. Trong .NET, runtime và thread pool lúc nào cũng có sẵn. Trong Rust, một async fn không làm gì cả cho tới khi có executor chạy nó, nên bạn thêm tokio và đặt #[tokio::main] lên main. Xem Async/Await.

Bạn không cần đọc trang này theo thứ tự. Là lập trình viên C#, 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: cài Rust 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.
  6. Option và Result, rồi Lỗi tự định nghĩa.
  7. Trait, Generic, Các trait thông dụng và Trait object.
  8. Closure và Iterator: LINQ của bạn.
  9. Vector, HashMap và VecDeque và BinaryHeap.
  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, Trạng thái dùng chung và 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 C#. Hãy chuyển nó sang Rust.

1. Chuyển một record và một truy vấn LINQ

Phần tiêu đề “1. Chuyển một record và một truy vấn LINQ”

Chuyển đoạn code C# này. Nó phải in ra tên những sản phẩm còn hàng và có giá dưới 20. Gợi ý: tạo một struct với #[derive(Debug, Clone, PartialEq)], rồi dùng filter, map và collect vào một Vec<&str>. Vec<&str> có phương thức .join(", ").

var products = new List<Product>
{
new("Sổ tay", 5, true),
new("Tai nghe", 45, true),
new("Bút", 2, false),
new("Cốc", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
Xem lời giải
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Sổ tay", 5, true),
product("Tai nghe", 45, true),
product("Bút", 2, false),
product("Cốc", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

Kết quả:

Sổ tay, Cốc
▶ Tự chạy thử

Một hàm trợ giúp nhỏ đóng vai trò constructor theo vị trí của record. Các derive cho bạn những gì một record C# có sẵn miễn phí: in bằng {:?}, sao chép bằng .clone() và so sánh bằng ==.

Đoạn code C# này lấy một món ra khỏi một dictionary tồn kho và ném exception khi có gì đó sai. Hãy chuyển nó sang Rust sao cho take trả về Result<u32, String> thay vì ném exception. Gợi ý: stock.get_mut(item) trả về một Option<&mut u32>. Biến nó thành Result bằng .ok_or(...), rồi dùng ?.

var stock = new Dictionary<string, int> { ["táo"] = 3, ["lê"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"không có món nào tên {item}");
if (count == 0)
throw new InvalidOperationException($"{item} đã hết hàng");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "táo", "lê", "kiwi" })
{
try { Console.WriteLine($"đã lấy một quả {item}, còn lại {Take(item)}"); }
catch (Exception e) { Console.WriteLine($"lỗi: {e.Message}"); }
}
Xem lời giải
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("không có món nào tên {item}"))?;
if *count == 0 {
return Err(format!("{item} đã hết hàng"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("táo".to_string(), 3);
stock.insert("lê".to_string(), 0);
for item in ["táo", "lê", "kiwi"] {
match take(&mut stock, item) {
Ok(left) => println!("đã lấy một quả {item}, còn lại {left}"),
Err(e) => println!("lỗi: {e}"),
}
}
}

Kết quả:

đã lấy một quả táo, còn lại 2
lỗi: lê đã hết hàng
lỗi: không có món nào tên kiwi
▶ Tự chạy thử

TryGetValue với tham số out trở thành get_mut, trả về một Option. Hai lệnh throw trở thành giá trị Err, còn try/catch trở thành một match. &mut stock trong lời gọi cho thấy rõ rằng take thay đổi dictionary.

  • Phần lớn C# mang sang được: generic, async/await, lambda, so khớp mẫu và iterator kiểu LINQ đề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 null, và Result + ? thay cho exception.
  • Class trở thành struct + impl, interface trở thành trait, kế thừa trở thành composition cộng với trait. using/IDisposable trở thành Drop tự động.
  • Cargo thay thế dotnet CLI, MSBuild và NuGet; clippy và rustfmt thay thế analyzer và dotnet format; kiểm thử có sẵn trong ngôn ngữ.
  • Thay EF Core hãy xem SeaORM hoặc Diesel, thay Dapper hãy xem sqlx, và thay ASP.NET Core hãy xem axum hoặc actix-web. Các lựa chọn cho GUI, game và ML thì còn trẻ hơn.

Kiểm tra nhanh

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

2. Nhóm của bạn dùng Dapper và thích tự viết SQL. Crate Rust nào gần nhất?

3. Thứ tương đương gần nhất trong Rust với khối using dùng IDisposable của C# là gì?

4. Trong C#, var b = a; với một class sẽ sao chép tham chiếu. Còn let b = a; với một String trong Rust thì làm gì?

Điểm: 0 / 4