Kiểu Dữ Liệu Nâng Cao
Hệ thống kiểu dữ liệu của Rust có một số tính năng mà chúng ta đã đề cập nhưng
chưa thảo luận chi tiết. Chúng ta sẽ bắt đầu bằng việc thảo luận về newtype nói
chung khi chúng ta xem xét tại sao newtype là kiểu dữ liệu hữu ích. Sau đó,
chúng ta sẽ chuyển sang bí danh kiểu (type aliases), một tính năng tương tự như
newtype nhưng có ngữ nghĩa hơi khác một chút. Chúng ta cũng sẽ thảo luận về kiểu
!
và các kiểu có kích thước động.
Sử Dụng Mẫu Newtype cho An Toàn Kiểu và Trừu Tượng Hóa
Phần này giả định bạn đã đọc phần trước "Sử Dụng Mẫu Newtype để Triển Khai
Trait Bên Ngoài trên Kiểu Bên Ngoài."
Mẫu newtype cũng hữu ích cho các nhiệm vụ ngoài những gì chúng ta đã thảo luận
cho đến nay, bao gồm việc đảm bảo tĩnh rằng các giá trị không bao giờ bị nhầm
lẫn và chỉ ra đơn vị của một giá trị. Bạn đã thấy một ví dụ về việc sử dụng
newtype để chỉ ra đơn vị trong Listing 20-16: nhớ lại rằng các struct
Millimeters
và Meters
bọc các giá trị u32
trong một newtype. Nếu chúng ta
viết một hàm với tham số có kiểu Millimeters
, chúng ta sẽ không thể biên dịch
một chương trình mà vô tình cố gắng gọi hàm đó với một giá trị có kiểu Meters
hoặc một u32
đơn giản.
Chúng ta cũng có thể sử dụng mẫu newtype để trừu tượng hóa một số chi tiết triển khai của một kiểu: kiểu mới có thể hiển thị một API công khai khác với API của kiểu bên trong riêng tư.
Newtype cũng có thể ẩn triển khai nội bộ. Ví dụ, chúng ta có thể cung cấp một
kiểu People
để bọc một HashMap<i32, String>
lưu trữ ID của một người kết hợp
với tên của họ. Mã sử dụng People
sẽ chỉ tương tác với API công khai mà chúng
ta cung cấp, chẳng hạn như một phương thức để thêm một chuỗi tên vào bộ sưu tập
People
; mã đó sẽ không cần biết rằng chúng ta gán một ID i32
cho tên một
cách nội bộ. Mẫu newtype là một cách nhẹ nhàng để đạt được sự đóng gói để ẩn chi
tiết triển khai, điều mà chúng ta đã thảo luận trong "Đóng Gói Ẩn Chi Tiết
Triển Khai"
trong Chương 18.
Tạo Đồng Nghĩa Kiểu với Bí Danh Kiểu
Rust cung cấp khả năng khai báo một bí danh kiểu để đặt tên khác cho một kiểu
hiện có. Đối với điều này, chúng ta sử dụng từ khóa type
. Ví dụ, chúng ta có
thể tạo bí danh Kilometers
cho i32
như sau:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Bây giờ, bí danh Kilometers
là một từ đồng nghĩa cho i32
; không giống như
các kiểu Millimeters
và Meters
mà chúng ta đã tạo trong Listing 20-16,
Kilometers
không phải là một kiểu mới, riêng biệt. Các giá trị có kiểu
Kilometers
sẽ được xử lý giống như các giá trị kiểu i32
:
fn main() { type Kilometers = i32; let x: i32 = 5; let y: Kilometers = 5; println!("x + y = {}", x + y); }
Bởi vì Kilometers
và i32
là cùng một kiểu, chúng ta có thể cộng các giá trị
của cả hai kiểu và chúng ta có thể truyền các giá trị Kilometers
cho các hàm
nhận tham số i32
. Tuy nhiên, khi sử dụng phương pháp này, chúng ta không nhận
được lợi ích kiểm tra kiểu mà chúng ta nhận được từ mẫu newtype đã thảo luận
trước đó. Nói cách khác, nếu chúng ta nhầm lẫn giữa các giá trị Kilometers
và
i32
ở đâu đó, trình biên dịch sẽ không đưa ra lỗi.
Trường hợp sử dụng chính cho bí danh kiểu là giảm sự lặp lại. Ví dụ, chúng ta có thể có một kiểu dài như thế này:
Box<dyn Fn() + Send + 'static>
Viết kiểu dài này trong chữ ký hàm và chú thích kiểu trên khắp mã có thể nhàm chán và dễ gây lỗi. Hãy tưởng tượng có một dự án đầy mã như trong Listing 20-25.
fn main() { let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi")); fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) { // --snip-- } fn returns_long_type() -> Box<dyn Fn() + Send + 'static> { // --snip-- Box::new(|| ()) } }
Một bí danh kiểu làm cho mã này dễ quản lý hơn bằng cách giảm sự lặp lại. Trong
Listing 20-26, chúng ta đã giới thiệu một bí danh có tên Thunk
cho kiểu dài
dòng và có thể thay thế tất cả các sử dụng của kiểu đó bằng bí danh ngắn gọn hơn
Thunk
.
fn main() { type Thunk = Box<dyn Fn() + Send + 'static>; let f: Thunk = Box::new(|| println!("hi")); fn takes_long_type(f: Thunk) { // --snip-- } fn returns_long_type() -> Thunk { // --snip-- Box::new(|| ()) } }
Mã này dễ đọc và viết hơn nhiều! Việc chọn một tên có ý nghĩa cho bí danh kiểu có thể giúp truyền đạt ý định của bạn (thunk là một từ chỉ mã được đánh giá vào thời điểm sau, vì vậy đó là một tên thích hợp cho một closure được lưu trữ).
Bí danh kiểu cũng thường được sử dụng với kiểu Result<T, E>
để giảm sự lặp
lại. Xem xét module std::io
trong thư viện chuẩn. Các hoạt động I/O thường trả
về một Result<T, E>
để xử lý các tình huống khi các hoạt động không thành
công. Thư viện này có một struct std::io::Error
đại diện cho tất cả các lỗi
I/O có thể xảy ra. Nhiều hàm trong std::io
sẽ trả về Result<T, E>
trong đó
E
là std::io::Error
, chẳng hạn như các hàm trong trait Write
:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
Result<..., Error>
được lặp lại rất nhiều. Do đó, std::io
có khai báo bí
danh kiểu này:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Vì khai báo này nằm trong module std::io
, chúng ta có thể sử dụng bí danh đầy
đủ std::io::Result<T>
; đó là một Result<T, E>
với E
được điền là
std::io::Error
. Chữ ký hàm của trait Write
cuối cùng trông như thế này:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Bí danh kiểu giúp ích theo hai cách: nó làm cho mã dễ viết hơn và cung cấp cho
chúng ta một giao diện nhất quán trên toàn bộ std::io
. Bởi vì nó là một bí
danh, nó chỉ là một Result<T, E>
khác, có nghĩa là chúng ta có thể sử dụng bất
kỳ phương thức nào hoạt động với Result<T, E>
với nó, cũng như cú pháp đặc
biệt như toán tử ?
.
Kiểu Never Không Bao Giờ Trả Về
Rust có một kiểu đặc biệt có tên là !
được biết đến trong thuật ngữ lý thuyết
kiểu là kiểu rỗng vì nó không có giá trị. Chúng ta thích gọi nó là kiểu
never vì nó đứng ở vị trí của kiểu trả về khi một hàm không bao giờ trả về. Đây
là một ví dụ:
fn bar() -> ! {
// --snip--
panic!();
}
Mã này được đọc là "hàm bar
không bao giờ trả về." Các hàm không bao giờ trả
về được gọi là hàm phân kỳ. Chúng ta không thể tạo giá trị của kiểu !
nên
bar
không thể trả về.
Nhưng ích lợi gì của một kiểu mà bạn không bao giờ có thể tạo giá trị cho nó? Nhớ lại mã từ Listing 2-5, một phần của trò chơi đoán số; chúng ta đã tái hiện một phần của nó ở đây trong Listing 20-27.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
Tại thời điểm đó, chúng ta đã bỏ qua một số chi tiết trong mã này. Trong "Toán
Tử Điều Khiển Luồng match
"
trong Chương 6, chúng ta đã thảo luận rằng các nhánh của match
phải trả về
cùng một kiểu. Vì vậy, ví dụ, mã sau đây không hoạt động:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
Kiểu của guess
trong mã này sẽ phải là một số nguyên và một chuỗi, và Rust
yêu cầu guess
chỉ có một kiểu. Vậy continue
trả về gì? Làm thế nào mà chúng
ta được phép trả về một u32
từ một nhánh và có một nhánh khác kết thúc bằng
continue
trong Listing 20-27?
Như bạn có thể đã đoán, continue
có một giá trị !
. Nghĩa là, khi Rust tính
toán kiểu của guess
, nó xem xét cả hai nhánh match, nhánh trước với giá trị
u32
và nhánh sau với giá trị !
. Bởi vì !
không bao giờ có thể có một giá
trị, Rust quyết định rằng kiểu của guess
là u32
.
Cách chính thức để mô tả hành vi này là các biểu thức của kiểu !
có thể được
ép buộc vào bất kỳ kiểu nào khác. Chúng ta được phép kết thúc nhánh match
này
với continue
bởi vì continue
không trả về giá trị; thay vào đó, nó chuyển
điều khiển trở lại đầu vòng lặp, vì vậy trong trường hợp Err
, chúng ta không
bao giờ gán giá trị cho guess
.
Kiểu never cũng hữu ích với macro panic!
. Nhớ lại hàm unwrap
mà chúng ta gọi
trên các giá trị Option<T>
để tạo ra một giá trị hoặc gây panic với định nghĩa
này:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
Trong mã này, điều tương tự xảy ra như trong match
ở Listing 20-27: Rust thấy
rằng val
có kiểu T
và panic!
có kiểu !
, vì vậy kết quả của toàn bộ biểu
thức match
là T
. Mã này hoạt động bởi vì panic!
không tạo ra một giá trị;
nó kết thúc chương trình. Trong trường hợp None
, chúng ta sẽ không trả về giá
trị từ unwrap
, vì vậy mã này hợp lệ.
Một biểu thức cuối cùng có kiểu !
là vòng lặp loop
:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
Ở đây, vòng lặp không bao giờ kết thúc, vì vậy !
là giá trị của biểu thức. Tuy
nhiên, điều này sẽ không đúng nếu chúng ta đưa vào một break
, bởi vì vòng lặp
sẽ kết thúc khi nó đến break
.
Kiểu Có Kích Thước Động và Trait Sized
Rust cần biết một số chi tiết về các kiểu của nó, chẳng hạn như cần bao nhiêu không gian để cấp phát cho một giá trị của một kiểu cụ thể. Điều này để lại một góc của hệ thống kiểu hơi khó hiểu lúc đầu: khái niệm về kiểu có kích thước động. Đôi khi được gọi là DST hoặc kiểu không có kích thước, những kiểu này cho phép chúng ta viết mã sử dụng các giá trị mà chúng ta chỉ có thể biết kích thước vào thời điểm chạy.
Hãy đi sâu vào chi tiết của một kiểu có kích thước động có tên là str
, mà
chúng ta đã sử dụng trong suốt cuốn sách. Đúng vậy, không phải &str
, mà là
str
tự nó, là một DST. Chúng ta không thể biết chuỗi dài bao nhiêu cho đến
thời điểm chạy, có nghĩa là chúng ta không thể tạo một biến kiểu str
, cũng
không thể nhận một đối số kiểu str
. Xem xét mã sau, mã này không hoạt động:
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust cần biết cần cấp phát bao nhiêu bộ nhớ cho bất kỳ giá trị nào của một kiểu
cụ thể, và tất cả các giá trị của một kiểu phải sử dụng cùng một lượng bộ nhớ.
Nếu Rust cho phép chúng ta viết mã này, hai giá trị str
này sẽ cần chiếm cùng
một lượng không gian. Nhưng chúng có độ dài khác nhau: s1
cần 12 byte lưu trữ
và s2
cần 15. Đó là lý do tại sao không thể tạo một biến chứa một kiểu có kích
thước động.
Vậy chúng ta làm gì? Trong trường hợp này, bạn đã biết câu trả lời: chúng ta làm
cho kiểu của s1
và s2
là &str
thay vì str
. Nhớ lại từ "Slice
Chuỗi" trong Chương 4 rằng cấu trúc dữ liệu slice
chỉ lưu trữ vị trí bắt đầu và độ dài của slice. Vì vậy, mặc dù một &T
là một
giá trị đơn lẻ lưu trữ địa chỉ bộ nhớ của nơi T
nằm, một &str
là hai giá
trị: địa chỉ của str
và độ dài của nó. Do đó, chúng ta có thể biết kích thước
của một giá trị &str
tại thời điểm biên dịch: nó gấp đôi độ dài của một
usize
. Nghĩa là, chúng ta luôn biết kích thước của một &str
, dù chuỗi mà nó
tham chiếu đến dài bao nhiêu. Nói chung, đây là cách mà các kiểu có kích thước
động được sử dụng trong Rust: chúng có một bit siêu dữ liệu bổ sung lưu trữ kích
thước của thông tin động. Quy tắc vàng của các kiểu có kích thước động là chúng
ta phải luôn đặt các giá trị của kiểu có kích thước động sau một con trỏ nào đó.
Chúng ta có thể kết hợp str
với tất cả các loại con trỏ: ví dụ, Box<str>
hoặc Rc<str>
. Thực tế, bạn đã thấy điều này trước đây nhưng với một kiểu có
kích thước động khác: trait. Mỗi trait là một kiểu có kích thước động mà chúng
ta có thể tham chiếu bằng cách sử dụng tên của trait. Trong "Sử Dụng Đối Tượng
Trait Cho Phép Cho Giá Trị Của Các Kiểu Khác
Nhau"
trong Chương 18, chúng ta đã đề cập rằng để sử dụng trait làm đối tượng trait,
chúng ta phải đặt chúng sau một con trỏ, chẳng hạn như &dyn Trait
hoặc
Box<dyn Trait>
(Rc<dyn Trait>
cũng sẽ hoạt động).
Để làm việc với DST, Rust cung cấp trait Sized
để xác định liệu kích thước của
một kiểu có được biết tại thời điểm biên dịch hay không. Trait này được tự động
triển khai cho mọi thứ có kích thước được biết tại thời điểm biên dịch. Ngoài
ra, Rust ngầm thêm một ràng buộc về Sized
vào mọi hàm generic. Nghĩa là, một
định nghĩa hàm generic như thế này:
fn generic<T>(t: T) {
// --snip--
}
thực tế được xử lý như thể chúng ta đã viết điều này:
fn generic<T: Sized>(t: T) {
// --snip--
}
Theo mặc định, các hàm generic sẽ chỉ hoạt động trên các kiểu có kích thước đã biết tại thời điểm biên dịch. Tuy nhiên, bạn có thể sử dụng cú pháp đặc biệt sau đây để nới lỏng hạn chế này:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
Một ràng buộc trait trên ?Sized
có nghĩa là "T
có thể hoặc có thể không là
Sized
" và ký hiệu này ghi đè mặc định rằng các kiểu generic phải có kích thước
đã biết tại thời điểm biên dịch. Cú pháp ?Trait
với ý nghĩa này chỉ có sẵn cho
Sized
, không cho bất kỳ trait nào khác.
Cũng lưu ý rằng chúng ta đã chuyển kiểu của tham số t
từ T
sang &T
. Bởi vì
kiểu có thể không phải là Sized
, chúng ta cần sử dụng nó sau một loại con trỏ
nào đó. Trong trường hợp này, chúng ta đã chọn một tham chiếu.
Tiếp theo, chúng ta sẽ nói về các hàm và closure!