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

Generic nâng cao trong Rust: const generic, const fn và PhantomData

Ở trang Generic, tham số generic luôn là một kiểu: Pair<T> có thể chứa i32 hoặc &str. Trang này giới thiệu ba công cụ đẩy cùng ý tưởng đó đi xa hơn:

  • Const generic cho phép một con số làm tham số generic, như số 3 trong [i32; 3].
  • const fn cho phép trình biên dịch chạy hàm của bạn trong lúc dựng chương trình, nên kết quả đã sẵn sàng trước khi chương trình chạy.
  • PhantomData cho phép một kiểu mang theo một nhãn (như “đây là ID của người dùng” hay “độ dài này tính bằng feet”) mà không lưu thêm dữ liệu nào.

Hãy nghĩ tới một hộp đựng trứng. Hộp 6 quả và hộp 12 quả là hai sản phẩm khác nhau, dù cả hai đều là “hộp trứng”. Kích thước là một phần của chính bản chất của chúng. Const generic cho phép bạn nói điều đó trong Rust. Hãy đọc trang này chậm rãi: mỗi phần đứng độc lập, và bạn có thể quay lại các phần sau khi cần tới.

Const generic: một con số làm tham số generic

Phần tiêu đề “Const generic: một con số làm tham số generic”

Trong Rust, độ dài của một mảng là một phần của kiểu. [u32; 3] và [u32; 4] khác nhau chẳng kém gì i32 và String. Một hàm nhận [u32; 3] sẽ không chấp nhận bốn phần tử:

error[E0308]: mismatched types
--> src/main.rs:7:26
|
7 | println!("{}", total(basket));
| ----- ^^^^^^ expected an array with a size of 3, found one with a size of 4
| |
| arguments to this function are incorrect

Thông báo (bằng tiếng Anh) nói rằng hàm cần một mảng 3 phần tử nhưng lại nhận được mảng 4 phần tử. Const generic sửa được chuyện này. Bạn viết const N: usize trong cặp ngoặc nhọn, và hàm sẽ chạy được với mảng có độ dài bất kỳ:

fn summary<const N: usize>(prices: [u32; N]) -> String {
let mut sum = 0;
for price in prices {
sum += price;
}
// Bên trong hàm, N chỉ là một con số bình thường
format!("{N} món, tổng giá {sum}")
}
fn main() {
let small_basket = [4, 10, 2];
let big_basket = [3, 8, 1, 12, 6];
println!("{}", summary(small_basket));
println!("{}", summary(big_basket));
}

Kết quả:

3 món, tổng giá 16
5 món, tổng giá 30
▶ Tự chạy thử

Hãy đọc <const N: usize> là “với mọi số N có kiểu usize”. Rust tự suy ra N từ mảng bạn truyền vào, giống như nó suy ra T với generic thông thường. Và cũng như generic thông thường, trình biên dịch tạo một bản sao riêng của summary cho mỗi độ dài bạn dùng, nên không tốn chi phí lúc chạy.

Nói thật thì: với hàm nhỏ này, một slice (prices: &[u32]) cũng dùng tốt y như vậy và đơn giản hơn. Xem Slice. Const generic thật sự phát huy tác dụng khi độ dài phải là một phần của kiểu: được lưu trong một struct mà không cần cấp phát trên heap, hoặc được dùng trong kiểu trả về. Đó là phần tiếp theo.

Struct với const generic: một lưới ghế ngồi

Phần tiêu đề “Struct với const generic: một lưới ghế ngồi”

Hãy hình dung sơ đồ ghế trong rạp chiếu phim: vài hàng ghế, mỗi hàng có cùng số ghế. Đây là một ma trận mà cả số hàng R lẫn số cột C đều là một phần của kiểu:

struct Matrix<const R: usize, const C: usize> {
cells: [[i32; C]; R],
}
impl<const R: usize, const C: usize> Matrix<R, C> {
fn new(cells: [[i32; C]; R]) -> Self {
Matrix { cells }
}
fn size(&self) -> String {
format!("{R}x{C}")
}
// Hàng thành cột: ma trận R x C biến thành ma trận C x R
fn transpose(&self) -> Matrix<C, R> {
let mut cells = [[0; R]; C];
for row in 0..R {
for col in 0..C {
cells[col][row] = self.cells[row][col];
}
}
Matrix { cells }
}
}
fn main() {
let seats = Matrix::new([[1, 2, 3], [4, 5, 6]]);
let flipped = seats.transpose();
println!("{} {:?}", seats.size(), seats.cells);
println!("{} {:?}", flipped.size(), flipped.cells);
}

Kết quả:

2x3 [[1, 2, 3], [4, 5, 6]]
3x2 [[1, 4], [2, 5], [3, 6]]
▶ Tự chạy thử

Hãy nhìn kiểu trả về của transpose: Matrix<C, R>. Trình biên dịch biết rằng lật một ma trận 2x3 sẽ cho ra một ma trận 3x2. Nếu bạn làm sai gì đó bên trong, như dựng một ma trận sai hình dạng, code sẽ không biên dịch được. Toàn bộ lưới nằm bên trong struct, không có Vec, không có heap. Dòng impl<const R: usize, const C: usize> hoạt động như impl<T>: nó nói rằng “các phương thức này có cho mọi kích thước”.

Một ví dụ thực tế: ring buffer kích thước cố định

Phần tiêu đề “Một ví dụ thực tế: ring buffer kích thước cố định”

Camera hành trình trên ô tô chỉ giữ vài phút video gần nhất. Khi đầy, đoạn ghi mới nhất sẽ ghi đè lên đoạn cũ nhất. Đó là một ring buffer (bộ đệm vòng), và nó cực kỳ hợp với const generic: dung lượng là cố định, nên bạn có thể lưu các phần tử trong một mảng.

Cái này là generic theo cả một kiểu T lẫn một kích thước N, nên dòng impl viết là impl<T, const N: usize>:

struct RingBuffer<T, const N: usize> {
items: [T; N],
next: usize, // vị trí lần push tiếp theo
len: usize, // đang lưu bao nhiêu phần tử (không bao giờ quá N)
}
impl<T: Copy + Default, const N: usize> RingBuffer<T, N> {
fn new() -> Self {
RingBuffer { items: [T::default(); N], next: 0, len: 0 }
}
fn push(&mut self, item: T) {
self.items[self.next] = item; // ghi đè phần tử cũ nhất khi đầy
self.next = (self.next + 1) % N;
if self.len < N {
self.len += 1;
}
}
// Các phần tử đang lưu, cũ nhất trước
fn to_vec(&self) -> Vec<T> {
let start = (self.next + N - self.len) % N;
(0..self.len).map(|i| self.items[(start + i) % N]).collect()
}
}
fn main() {
// Chỉ giữ 3 lần đo nhiệt độ gần nhất
let mut recent: RingBuffer<i32, 3> = RingBuffer::new();
for temp in [18, 19, 21, 24, 22] {
recent.push(temp);
println!("sau {temp}: {:?}", recent.to_vec());
}
}

Kết quả:

sau 18: [18]
sau 19: [18, 19]
sau 21: [18, 19, 21]
sau 24: [19, 21, 24]
sau 22: [21, 24, 22]
▶ Tự chạy thử

[T::default(); N] lấp đầy mảng bằng N bản sao của một giá trị mặc định (0 với i32). Lặp lại một giá trị như vậy cần T: Copy, đó là lý do impl yêu cầu Copy + Default. Một RingBuffer<i32, 3> và một RingBuffer<i32, 60> là hai kiểu khác nhau, nên bạn không bao giờ truyền nhầm bộ đệm 3 ô vào chỗ cần bộ đệm 60 ô. Nếu bạn muốn phiên bản có thể lớn dần của thư viện chuẩn, hãy xem VecDeque và BinaryHeap.

Giá trị mặc định, dấu ngoặc nhọn và giới hạn hiện tại

Phần tiêu đề “Giá trị mặc định, dấu ngoặc nhọn và giới hạn hiện tại”

Một tham số const có thể có giá trị mặc định, viết là const N: usize = 4. Bạn cũng có thể truyền vào một hằng số có tên, hoặc một biểu thức trong cặp ngoặc nhọn:

struct Buffer<const N: usize = 4> {
data: [u8; N],
}
impl<const N: usize> Buffer<N> {
fn new() -> Self {
Buffer { data: [0; N] }
}
}
const PAGE: usize = 8;
fn main() {
let small: Buffer = Buffer::new(); // không có số: dùng mặc định, 4
let page = Buffer::<PAGE>::new(); // một hằng số có tên
let double = Buffer::<{ PAGE * 2 }>::new(); // biểu thức cần { }
println!("{} {} {}", small.data.len(), page.data.len(), double.data.len());
}

Kết quả:

4 8 16
▶ Tự chạy thử

Giá trị mặc định chỉ có tác dụng khi bạn viết kiểu ra mà không kèm con số, như let small: Buffer. Một câu let small = Buffer::new(); trơn sẽ thất bại với error[E0284]: type annotations needed for Buffer<_>, vì Rust không đoán giá trị mặc định từ một biểu thức.

Const generic trên Rust stable rất hữu ích, nhưng được giới hạn có chủ đích. Biết trước các giới hạn sẽ giúp bạn khỏi va phải chúng:

  • Các kiểu được phép: chỉ số nguyên (usize, u8, i32, …), bool và char. Thử const NAME: &'static str sẽ nhận được “&'static str is forbidden as the type of a const generic parameter” (kiểu này bị cấm làm tham số const generic).
  • Không được tính toán với tham số generic trong một kiểu. [f32; N * 2] bên trong một struct hay chữ ký hàm generic là lỗi (“generic parameters may not be used in const operations”). N trơn thì được, và N * 2 bên trong thân hàm cũng được, vì ở đó N chỉ là một giá trị. Tính năng cho phép N * 2 trong kiểu (generic_const_exprs) vẫn chỉ có trên nightly.
  • Kiểm tra một giá trị. Bạn không viết được ràng buộc kiểu “N phải lớn hơn 0”. Cách gần nhất là một khối const nội tuyến trong hàm: const { assert!(N > 0, "need at least one slot") };. Nó sẽ thất bại khi bạn build chương trình với một N không hợp lệ (cargo check có thể không bắt được).

Có những cửa hàng in sẵn bảng giá một lần, thay vì tính từng giá ở quầy thu ngân. Một const fn là hàm mà trình biên dịch được phép chạy trong lúc dựng chương trình, nên kết quả được “in sẵn” vào chương trình từ trước.

Bạn đã gặp các mục const ở trang Biến. Giá trị của chúng phải được biết lúc biên dịch, nên bình thường bạn chỉ dùng được giá trị literal và phép toán đơn giản. Với const fn, bạn có thể gọi cả hàm của chính mình ở đó:

const fn square(n: usize) -> usize {
n * n
}
const BOARD_SIDE: usize = 4;
const CELLS: usize = square(BOARD_SIDE); // trình biên dịch tính ra
fn main() {
// Độ dài mảng phải được biết lúc biên dịch, và CELLS thì đã biết
let board = [0u8; CELLS];
println!("bàn cờ {BOARD_SIDE}x{BOARD_SIDE} có {} ô", board.len());
println!("square(12) lúc chạy là {}", square(12));
}

Kết quả:

bàn cờ 4x4 có 16 ô
square(12) lúc chạy là 144
▶ Tự chạy thử

Một const fn vẫn là một hàm bình thường. square(12) trong main là một lời gọi thông thường lúc chạy. Nó chỉ bắt buộc chạy lúc biên dịch khi được dùng trong một ngữ cảnh const (const context): một mục const hoặc static, độ dài mảng, đối số const generic, hoặc một khối const { ... }.

Một cách dùng kinh điển là bảng tra cứu: tính một danh sách giá trị một lần, lúc biên dịch, rồi lúc chạy chỉ việc đọc. Ở đây trình biên dịch dựng bảng “mỗi tháng bắt đầu ở ngày thứ mấy trong năm” cho một năm không nhuận:

const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const fn days_before_each_month() -> [u32; 12] {
let mut table = [0; 12];
let mut month = 1;
while month < 12 {
table[month] = table[month - 1] + DAYS_IN_MONTH[month - 1];
month += 1;
}
table
}
// Trình biên dịch dựng một lần. Lúc chạy nó chỉ là 12 con số.
const DAYS_BEFORE: [u32; 12] = days_before_each_month();
fn day_of_year(month: usize, day: u32) -> u32 {
DAYS_BEFORE[month - 1] + day
}
fn main() {
println!("{:?}", DAYS_BEFORE);
println!("ngày 1 tháng 3 là ngày thứ {} trong năm", day_of_year(3, 1));
println!("ngày 25 tháng 12 là ngày thứ {} trong năm", day_of_year(12, 25));
}

Kết quả:

[0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
ngày 1 tháng 3 là ngày thứ 60 trong năm
ngày 25 tháng 12 là ngày thứ 359 trong năm
▶ Tự chạy thử

Để ý vòng lặp while thay vì vòng for. Đó không phải là chuyện phong cách. Đây là những gì bạn có thể dùng trong một const fn hiện nay:

  • let và let mut, phép toán số học, so sánh, if, match, loop và while
  • mảng, tuple, struct và enum, và truy cập theo chỉ số như table[month]
  • lời gọi tới các const fn khác (nhiều hàm chuẩn là const, như u32::pow hay slice.len())
  • tham chiếu &mut và phép toán số thực (stable từ Rust 1.83 và 1.82)
  • panic! và assert!, chúng trở thành lỗi biên dịch khi bị kích hoạt lúc biên dịch

Và những gì bạn chưa thể dùng: vòng lặp for (chúng cần trait Iterator, mà phương thức của trait chưa là const trên stable), lời gọi tới hàm thường như String::from hay println!, và bất cứ thứ gì cấp phát trên heap, như push vào một Vec.

const hay static? Cả hai đều là giá trị toàn cục không bao giờ thay đổi, nhưng được lưu khác nhau:

const static
Nằm ở đâu được chép vào mọi chỗ bạn dùng nó một chỗ cố định trong bộ nhớ
Địa chỉ có thể khác nhau mỗi lần luôn giống nhau, sống suốt chương trình ('static)
Có thể khả biến không bao giờ có static mut, nhưng cần unsafe
Dùng cho con số, bảng nhỏ, thiết lập một bảng lớn bạn muốn lưu một lần, hoặc một biến toàn cục bạn cần tham chiếu tới

Khi phân vân, hãy dùng const. Nếu bạn cần một biến toàn cục thay đổi được, hãy dùng một static chứa atomic hoặc Mutex bên trong thay vì static mut. Xem Trạng thái dùng chung.

PhantomData: một nhãn không thể nhầm lẫn

Phần tiêu đề “PhantomData: một nhãn không thể nhầm lẫn”

Đây là một lỗi xảy ra thường xuyên trong các hệ thống thực tế. ID người dùng và ID đơn hàng đều chỉ là số, nên chẳng có gì ngăn bạn truyền một ID người dùng vào cancel_order. Bạn muốn kiểu mang theo một nhãn: Id<User> khác với Id<Order>. Lần thử đầu tiên có thể trông như thế này:

struct Id<T> {
value: u32,
}
error[E0392]: type parameter `T` is never used
--> src/main.rs:1:11
|
1 | struct Id<T> {
| ^ unused type parameter
|
= help: consider removing `T`, referring to it in a field, or using a marker such as `PhantomData`

Rust từ chối một tham số kiểu mà không trường nào dùng tới (thông báo lỗi bằng tiếng Anh nói T không bao giờ được dùng). Cách sửa chính là điều trình biên dịch gợi ý. std::marker::PhantomData<T> là một trường nói rằng “struct này gắn với T” nhưng không chứa gì cả. Nó chiếm 0 byte:

use std::marker::PhantomData;
struct User;
struct Order;
struct Id<T> {
value: u32,
_marker: PhantomData<T>, // 0 byte, chỉ là một nhãn
}
impl<T> Id<T> {
fn new(value: u32) -> Self {
Id { value, _marker: PhantomData }
}
}
fn find_user(id: &Id<User>) -> &'static str {
if id.value == 7 { "Amara" } else { "không rõ" }
}
fn cancel_order(id: &Id<Order>) {
println!("đang hủy đơn hàng #{}", id.value);
}
fn main() {
let user_id: Id<User> = Id::new(7);
let order_id: Id<Order> = Id::new(7);
println!("người dùng #{} là {}", user_id.value, find_user(&user_id));
cancel_order(&order_id);
println!("một Id chiếm {} byte", std::mem::size_of::<Id<User>>());
}

Kết quả:

người dùng #7 là Amara
đang hủy đơn hàng #7
một Id chiếm 4 byte
▶ Tự chạy thử

Cả hai ID đều chứa số 7, và cả hai đều chiếm 4 byte, y hệt một u32 bình thường. Nhưng giờ hãy thêm cancel_order(&user_id); vào dòng cuối của main:

error[E0308]: mismatched types
--> src/main.rs:32:18
|
32 | cancel_order(&user_id);
| ------------ ^^^^^^^^ expected `&Id<Order>`, found `&Id<User>`
| |
| arguments to this function are incorrect

Sự nhầm lẫn bị bắt ngay trước khi chương trình kịp chạy: hàm cần &Id<Order> nhưng lại nhận &Id<User>. PhantomData vừa là một giá trị vừa là một kiểu, đó là lý do bạn viết _marker: PhantomData khi tạo struct. Dấu _ ở đầu chỉ là thói quen đặt tên phổ biến cho “bạn không bao giờ đọc trường này”.

Năm 1999, NASA mất tàu Mars Climate Orbiter vì một phần mềm tạo ra số liệu theo đơn vị Anh, còn phần mềm kia lại chờ đơn vị mét. Cả hai đều “chỉ là số”. Với PhantomData, đơn vị trở thành một phần của kiểu, và một Length<U> generic duy nhất dùng được cho mọi đơn vị:

use std::marker::PhantomData;
use std::ops::Add;
struct Meters;
struct Feet;
struct Length<U> {
value: f64,
_unit: PhantomData<U>,
}
impl<U> Length<U> {
fn new(value: f64) -> Self {
Length { value, _unit: PhantomData }
}
}
// Một impl cho mọi đơn vị, nhưng hai vế phải cùng đơn vị
impl<U> Add for Length<U> {
type Output = Length<U>;
fn add(self, other: Length<U>) -> Length<U> {
Length::new(self.value + other.value)
}
}
impl Length<Feet> {
fn to_meters(self) -> Length<Meters> {
Length::new(self.value * 0.3048)
}
}
fn main() {
let run: Length<Meters> = Length::new(400.0) + Length::new(250.0);
let climb: Length<Feet> = Length::new(1000.0);
let climb_m = climb.to_meters();
println!("leo dốc: {:.1} m", climb_m.value);
let total = run + climb_m;
println!("tổng: {:.1} m", total.value);
}

Kết quả:

leo dốc: 304.8 m
tổng: 954.8 m
▶ Tự chạy thử

Viết run + climb (quên to_meters()) và Rust sẽ từ chối, vì nó cần Length<Meters> nhưng lại nhận Length<Feet>:

error[E0308]: mismatched types
--> src/main.rs:39:23
|
39 | let total = run + climb;
| ^^^^^ expected `Length<Meters>`, found `Length<Feet>`

Ở trang Trait nâng cao, Meters và Centimeters là hai struct riêng, mỗi cái có impl Add riêng. Ở đây, một impl<U> Add for Length<U> duy nhất phục vụ mọi đơn vị bạn sẽ cộng.

Typestate: cánh cửa không thể khóa khi đang mở

Phần tiêu đề “Typestate: cánh cửa không thể khóa khi đang mở”

Cùng mẹo đó có thể theo dõi trạng thái của một thứ gì đó. Một cánh cửa có thể đang mở, đang đóng hoặc đã khóa. Bạn có thể khóa một cánh cửa đang đóng, nhưng khóa một cánh cửa đang mở thì vô lý. Với một nhãn trạng thái trong kiểu, các thao tác không hợp lệ đơn giản là không tồn tại:

use std::marker::PhantomData;
struct Open;
struct Closed;
struct Locked;
struct Door<State> {
room: String,
_state: PhantomData<State>,
}
// Có trong mọi trạng thái
impl<State> Door<State> {
fn room(&self) -> &str {
&self.room
}
// Cùng cánh cửa, nhãn trạng thái mới
fn into_state<Next>(self, action: &str) -> Door<Next> {
println!("{}: {action}", self.room);
Door { room: self.room, _state: PhantomData }
}
}
impl Door<Closed> {
fn new(room: &str) -> Self {
Door { room: room.to_string(), _state: PhantomData }
}
fn open(self) -> Door<Open> {
self.into_state("đã mở")
}
fn lock(self) -> Door<Locked> {
self.into_state("đã khóa")
}
}
impl Door<Open> {
fn close(self) -> Door<Closed> {
self.into_state("đã đóng")
}
}
impl Door<Locked> {
fn unlock(self) -> Door<Closed> {
self.into_state("đã mở khóa")
}
}
fn main() {
let door = Door::new("phòng máy chủ").open().close().lock();
println!("{} đã khóa qua đêm", door.room());
let door = door.unlock().open();
println!("{} lại mở rồi", door.room());
}

Kết quả:

phòng máy chủ: đã mở
phòng máy chủ: đã đóng
phòng máy chủ: đã khóa
phòng máy chủ đã khóa qua đêm
phòng máy chủ: đã mở khóa
phòng máy chủ: đã mở
phòng máy chủ lại mở rồi
▶ Tự chạy thử

Giờ hãy thử khóa một cánh cửa đang mở, Door::new("phòng máy chủ").open().lock():

error[E0599]: no method named `lock` found for struct `Door<Open>` in the current scope
--> src/main.rs:50:50
|
50 | let door = Door::new("phòng máy chủ").open().lock();
| ^^^^ method not found in `Door<Open>`
|
= note: the method was found for `Door<Closed>`

Trình biên dịch nói rằng không có phương thức lock cho Door<Open>, nó chỉ có cho Door<Closed>. Mỗi phương thức nhận self theo giá trị, nên cánh cửa cũ bị dùng hết và bạn không thể tiếp tục dùng một cánh cửa “đang đóng” sau khi đã mở nó. Trang Mẫu thiết kế cho thấy cùng ý tưởng typestate trong đó mỗi trạng thái còn giữ dữ liệu riêng. PhantomData là phiên bản nhẹ hơn khi các trạng thái chỉ thuần là nhãn.

Phương thức cho một kích thước, và associated const

Phần tiêu đề “Phương thức cho một kích thước, và associated const”

Bạn đã thấy impl Pair<f64> ở trang Generic: phương thức chỉ dành cho một kiểu cụ thể. Điều tương tự cũng làm được với const generic. Có những phương thức chỉ có nghĩa với một số kích thước nhất định:

struct Matrix<const R: usize, const C: usize> {
cells: [[i32; C]; R],
}
// Chỉ ma trận vuông: số hàng và số cột cùng là N
impl<const N: usize> Matrix<N, N> {
fn trace(&self) -> i32 {
let mut sum = 0;
for i in 0..N {
sum += self.cells[i][i];
}
sum
}
}
// Chỉ ma trận 2x2
impl Matrix<2, 2> {
fn determinant(&self) -> i32 {
let [[a, b], [c, d]] = self.cells;
a * d - b * c
}
}
fn main() {
let small = Matrix { cells: [[3, 8], [4, 6]] };
let big = Matrix { cells: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] };
println!("nhỏ: vết {}, định thức {}", small.trace(), small.determinant());
println!("lớn: vết {}", big.trace());
}

Kết quả:

nhỏ: vết 9, định thức -14
lớn: vết 15
▶ Tự chạy thử

big.determinant() sẽ không biên dịch được, và trace() trên ma trận 2x3 cũng vậy: bạn nhận được lỗi như error[E0599]: no method named trace found for struct Matrix<2, 3>, kèm ghi chú the method was found for Matrix<N, N>. Quy tắc “chỉ ma trận vuông mới có vết” giờ đã được viết vào kiểu.

Một associated const (hằng số liên kết) là hằng số thuộc về một kiểu hoặc một trait, giống như phương thức. Trong một trait, mỗi bản triển khai tự chọn giá trị của mình, và code generic có thể đọc nó dưới dạng U::NAME. Hãy cho các đơn vị ở trên một ký hiệu và một kích thước:

use std::fmt;
use std::marker::PhantomData;
trait Unit {
const SYMBOL: &'static str;
const IN_METERS: f64; // một đơn vị này bằng bao nhiêu mét
}
struct Meters;
struct Feet;
struct Miles;
impl Unit for Meters {
const SYMBOL: &'static str = "m";
const IN_METERS: f64 = 1.0;
}
impl Unit for Feet {
const SYMBOL: &'static str = "ft";
const IN_METERS: f64 = 0.3048;
}
impl Unit for Miles {
const SYMBOL: &'static str = "mi";
const IN_METERS: f64 = 1609.344;
}
struct Length<U> {
value: f64,
_unit: PhantomData<U>,
}
impl<U: Unit> Length<U> {
fn new(value: f64) -> Self {
Length { value, _unit: PhantomData }
}
// Dùng được giữa hai đơn vị bất kỳ, nhờ hằng số của chúng
fn convert<To: Unit>(&self) -> Length<To> {
Length::new(self.value * U::IN_METERS / To::IN_METERS)
}
}
impl<U: Unit> fmt::Display for Length<U> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.2} {}", self.value, U::SYMBOL)
}
}
fn main() {
let marathon: Length<Miles> = Length::new(26.2);
let everest: Length<Feet> = Length::new(29032.0);
println!("{} = {}", marathon, marathon.convert::<Meters>());
println!("{} = {}", everest, everest.convert::<Meters>());
println!("{} = {}", everest, everest.convert::<Miles>());
}

Kết quả:

26.20 mi = 42164.81 m
29032.00 ft = 8848.95 m
29032.00 ft = 5.50 mi
▶ Tự chạy thử

U::IN_METERS được tra lúc biên dịch, nên lúc chạy không có bảng nào phải tìm. Thêm một đơn vị mới, chẳng hạn Kilometers, chỉ là một khối impl Unit nhỏ, và convert cùng Display dùng được với nó ngay. Một impl riêng của kiểu (inherent impl) cũng có thể có associated const, ví dụ impl<T, const N: usize> RingBuffer<T, N> { const CAPACITY: usize = N; }, đọc ra bằng RingBuffer::<i32, 3>::CAPACITY.

Nếu bạn muốn một bản triển khai trait cho mọi kiểu thỏa một điều kiện, đó là blanket impl, được nói tới ở trang Trait nâng cao.

Những tính năng này mạnh, và chính điều đó khiến chúng dễ bị lạm dụng. Vài lời khuyên thật lòng:

  • Một slice hoặc một Vec thường là đủ. Nếu một hàm chỉ đọc các phần tử, &[T] chấp nhận mọi độ dài và dễ đọc hơn. Nếu độ dài chỉ biết lúc chạy (một tệp, dữ liệu người dùng nhập), dù sao bạn cũng cần một Vec.
  • Mảng lớn nằm trên stack. Một RingBuffer<u8, 10_000_000> là mười megabyte nằm trong struct, có thể làm tràn stack. Hãy đặt nó trong một Box (xem Con trỏ thông minh) hoặc dùng Vec.
  • Mỗi kích thước hay kiểu mới là một bản sao code mới. Dùng một generic với 20 giá trị N khác nhau sẽ build ra 20 phiên bản. Điều đó tốn thời gian biên dịch và kích thước tệp nhị phân. Xem Hiệu năng.
  • Typestate thêm kiểu và thông báo lỗi dài hơn. Nó rất tốt cho một API nhiều người dùng (một builder, một kết nối, một luồng thanh toán). Với trạng thái thay đổi theo dữ liệu lúc chạy, như trạng thái đọc từ cơ sở dữ liệu, hãy dùng một enum và match.

Một quy tắc tốt: bắt đầu đơn giản, và chỉ dùng tới các công cụ này khi xuất hiện một lỗi thật hoặc một chi phí thật mà chúng có thể ngăn được.

Tính toán với tham số const trong một kiểu. Viết vậy nghe rất tự nhiên, nhưng Rust stable chưa cho phép:

struct Pairs<const N: usize> {
values: [f32; N * 2],
}
error: generic parameters may not be used in const operations
--> src/main.rs:2:19
|
2 | values: [f32; N * 2],
| ^ cannot perform const operation using `N`
|
= help: const parameters may only be used as standalone arguments here, i.e. `N`

Thông báo nói rằng ở đây tham số const chỉ được dùng một mình, như N. Cách sửa: dùng [[f32; 2]; N] (một mảng các cặp), thêm một tham số thứ hai, hoặc dùng Vec nếu kích thước không cần nằm trong kiểu.

Quên dấu ngoặc nhọn quanh một biểu thức. Buffer::<2 + 2>::new() cho ra error: expressions must be enclosed in braces to be used as const generic arguments, và dòng help chỉ cách sửa: Buffer::<{ 2 + 2 }>::new(). Một con số trơn hoặc tên một const thì không cần ngoặc nhọn.

Gọi thứ không phải const trong ngữ cảnh const. Một const không thể gọi hàm thường, và một const fn không thể dùng vòng for:

const GREETING: String = String::from("hello");
error[E0015]: cannot call non-const associated function `<String as From<&str>>::from` in constants
--> src/main.rs:1:26
|
1 | const GREETING: String = String::from("hello");
| ^^^^^^^^^^^^^^^^^^^^^
|
= note: calls in constants are limited to constant functions, tuple structs and tuple variants

Thông báo nói rằng trong hằng số chỉ được gọi các hàm const. Với văn bản, hãy dùng const GREETING: &str = "hello";. Bên trong một const fn, hãy thay for bằng vòng while và một chỉ số, như bảng tra cứu ở trên.

#[derive(Clone, Copy)] trên một struct có PhantomData. Bạn sẽ nghĩ Id<User> là Copy, vì nó chỉ chứa một u32. Nhưng derive thêm ràng buộc T: Copy, mà User không phải Copy, nên ID bị move thay vì được sao chép:

error[E0382]: use of moved value: `id`
--> src/main.rs:16:10
|
14 | let id: Id<User> = Id { value: 1, _marker: PhantomData };
| -- move occurs because `id` has type `Id<User>`, which does not implement the `Copy` trait
15 | show(id);
| -- value moved here
16 | show(id);
| ^^ value used here after move
|
note: if all bounds were met, you could clone the value
--> src/main.rs:4:1
|
3 | #[derive(Clone, Copy)]
| ----- derived `Clone` adds implicit bounds on type parameters

Cách sửa đơn giản nhất là derive Clone, Copy cho cả các kiểu nhãn (#[derive(Clone, Copy)] struct User;). Hoặc tự viết các impl, cách này không cần ràng buộc nào trên T:

impl<T> Clone for Id<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Id<T> {}

Viết fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N] để chép các điểm số vào một mảng có đúng N phần tử. Điền 0 vào các chỗ còn thiếu, và bỏ đi các điểm thừa. Gọi nó dưới dạng pad_scores::<5>(&[90, 85]) và let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]);. Gợi ý: bắt đầu với [0; N], rồi lặp qua 0..N và dùng scores.get(i).

Xem lời giải
fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N] {
let mut out = [0; N];
for i in 0..N {
if let Some(&score) = scores.get(i) {
out[i] = score;
}
}
out
}
fn main() {
let five = pad_scores::<5>(&[90, 85]);
let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]);
println!("{:?}", five);
println!("{:?}", top3);
}

Kết quả:

[90, 85, 0, 0, 0]
[70, 95, 88]
▶ Tự chạy thử

Ở đây N không thể lấy từ đối số (một slice không có độ dài trong kiểu), nên nó đến từ turbofish ::<5> hoặc từ kiểu bạn yêu cầu, [u32; 3].

Viết hai const fn, minutes(n: u64) -> u64 và hours(n: u64) -> u64, trả về số giây. Dùng chúng để định nghĩa const SESSION_TIMEOUT: u64 = hours(1) + minutes(30); và một mảng const RETRY_DELAYS: [u64; 3] gồm 1, 5 và 15 phút. In cả hai ra. Gợi ý: hours có thể gọi minutes.

Xem lời giải
const fn minutes(n: u64) -> u64 {
n * 60
}
const fn hours(n: u64) -> u64 {
minutes(n * 60)
}
const SESSION_TIMEOUT: u64 = hours(1) + minutes(30);
const RETRY_DELAYS: [u64; 3] = [minutes(1), minutes(5), minutes(15)];
fn main() {
println!("thời gian chờ của phiên: {SESSION_TIMEOUT} giây");
println!("độ trễ khi thử lại: {:?}", RETRY_DELAYS);
}

Kết quả:

thời gian chờ của phiên: 5400 giây
độ trễ khi thử lại: [60, 300, 900]
▶ Tự chạy thử

Trình biên dịch tính ra 5400 và bảng độ trễ trong lúc build, và hours(1) + minutes(30) dễ đọc hơn nhiều so với một con số 5400 trơ trọi. Nếu bạn bỏ const khỏi minutes, các mục const sẽ không biên dịch được nữa, với lỗi E0015.

Tạo struct Euro; và struct Dollar;, cùng một Money<C> có trường cents: i64 và một trường PhantomData<C>. Cho nó new(cents) và plus(self, other: Money<C>) -> Money<C>. Cộng một bữa trưa và một ly cà phê tính bằng euro rồi in tổng. Gợi ý: vì other có cùng C, một Money<Euro> và một Money<Dollar> không thể cộng với nhau.

Xem lời giải
use std::marker::PhantomData;
struct Euro;
struct Dollar;
struct Money<C> {
cents: i64,
_currency: PhantomData<C>,
}
impl<C> Money<C> {
fn new(cents: i64) -> Self {
Money { cents, _currency: PhantomData }
}
fn plus(self, other: Money<C>) -> Money<C> {
Money::new(self.cents + other.cents)
}
}
fn main() {
let lunch: Money<Euro> = Money::new(1250);
let coffee: Money<Euro> = Money::new(320);
let ticket: Money<Dollar> = Money::new(4999);
let total = lunch.plus(coffee);
println!("tổng: {}.{:02} EUR", total.cents / 100, total.cents % 100);
println!("vé: {}.{:02} USD", ticket.cents / 100, ticket.cents % 100);
}

Kết quả:

tổng: 15.70 EUR
vé: 49.99 USD
▶ Tự chạy thử

Hãy thử lunch.plus(ticket): nó thất bại với expected Money<Euro>, found Money<Dollar>. Lưu tiền dưới dạng số nguyên (cent) thay vì f64 cũng giúp tránh lỗi làm tròn khi làm việc với tiền.

  • Const generic (const N: usize) biến một con số thành một phần của kiểu. Các mảng khác độ dài là các kiểu khác nhau, và impl<T, const N: usize> viết phương thức cho mọi kích thước.
  • Trên Rust stable, tham số const có thể là số nguyên, bool hoặc char. Biểu thức làm đối số cần ngoặc nhọn ({ PAGE * 2 }), và chưa được tính toán với N generic bên trong một kiểu.
  • Một const fn có thể chạy lúc biên dịch trong ngữ cảnh const (const, static, độ dài mảng). Dùng while thay cho for, và chỉ gọi các const fn khác.
  • PhantomData<T> là một trường kích thước 0 gắn một struct với một kiểu mà nó không lưu. Dùng nó cho ID có kiểu, đơn vị đo và typestate.
  • impl Matrix<2, 2> và impl<const N: usize> Matrix<N, N> chỉ thêm phương thức cho một số kích thước. Associated const như U::IN_METERS cho mỗi kiểu một giá trị riêng lúc biên dịch.
  • Các công cụ này tốn thời gian biên dịch và làm code khó đọc hơn. Hãy dùng chúng khi chúng ngăn được một lỗi thật.

Kiểm tra nhanh

1. Vì sao bạn không thể truyền một [u32; 4] vào hàm nhận [u32; 3]?

2. PhantomData<T> thêm gì vào một struct lúc chạy?

3. Thứ nào sau đây bạn KHÔNG thể dùng bên trong một const fn trên Rust stable?

4. impl<const N: usize> Matrix<N, N> nghĩa là gì?

Điểm: 0 / 4