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

Chuyển đổi kiểu trong Rust: From, Into, TryFrom, FromStr, AsRef

Chương trình chuyển đổi dữ liệu suốt ngày. Chữ từ một biểu mẫu trở thành một con số. Một tuple ba số trở thành một Color. Kiểu lỗi này trở thành kiểu lỗi khác. Rust có một nhóm nhỏ các trait chuẩn cho việc này, và khi đã biết chúng, bạn sẽ thấy chúng ở khắp nơi trong code thật.

Hãy hình dung các trait này như những cục chuyển đổi phích cắm bạn mang theo khi đi nước ngoài. Mỗi cái được làm cho một việc. Có cái luôn vừa (From). Có cái có thể không vừa, và sẽ báo cho bạn (TryFrom). Có cái đọc nhãn rồi dựng ra thứ cần thiết (FromStr). Và có cái chỉ đơn giản cho bạn cắm bất cứ thứ gì bạn đang có (AsRef). Trang này đi qua từng cái, thật chậm. Bạn đã gặp as ở trang Ép kiểu và làm quen lần đầu với From ở trang Các trait thông dụng, nên chúng ta sẽ xây tiếp trên nền đó.

as nhanh gọn, nhưng có hai giới hạn. Nó chỉ dùng được giữa các kiểu dựng sẵn đơn giản như số, char và bool. Và nó không bao giờ thất bại: nếu giá trị không vừa, nó lặng lẽ đưa bạn một con số khác.

fn main() {
let items_in_cart: i32 = 300;
// `as` ép 300 vào một u8 và lặng lẽ cho ra kết quả sai
let squeezed = items_in_cart as u8;
println!("dùng as: {squeezed}");
// `try_from` phát hiện vấn đề và báo cho bạn
let checked = u8::try_from(items_in_cart);
println!("dùng try_from: {:?}", checked);
// `from` chỉ tồn tại khi việc chuyển đổi không bao giờ làm mất dữ liệu
let widened = i64::from(items_in_cart);
println!("dùng from: {widened}");
}

Kết quả:

dùng as: 44
dùng try_from: Err(TryFromIntError(PosOverflow))
dùng from: 300
▶ Tự chạy thử

Con số 44 đó là kiểu lỗi có thể ẩn nấp hàng tháng trời. Các trait chuyển đổi khắc phục điều này theo ba cách:

  • Chúng trung thực. From chỉ được cài đặt khi không có gì có thể sai. Khi có thể sai, bạn dùng TryFrom, trả về một Result.
  • Chúng dùng được cho kiểu của riêng bạn. Bạn không thể viết (255, 0, 0) as Color, nhưng có thể cài đặt From<(u8, u8, u8)> for Color.
  • Code khác hiểu chúng. Toán tử ?, .parse() và rất nhiều hàm thư viện được xây dựng trên các trait này.

Vậy quy tắc nhanh là: giữ as cho những mẹo nhanh, an toàn với số, và dùng các trait ở mọi chỗ khác.

From nói rằng “tôi biết cách dựng chính mình từ kiểu kia”. Bạn đã dùng nó rồi. Nhiều kiểu chuẩn cài đặt nó:

let greeting = String::from("xin chào"); // &str -> String
let big = i64::from(42_i32); // i32 -> i64, luôn vừa
let precise = f64::from(7_u8); // u8 -> f64, luôn chính xác
let scores = Vec::from([90, 85, 77]); // mảng -> Vec<i32>

Hãy để ý thứ còn thiếu: không có i32::from(some_i64). Một i64 có thể không vừa trong i32, nên Rust không giả vờ rằng việc đó luôn thành công. Chúng ta sẽ quay lại chuyện này ở phần TryFrom.

Ở đây một Color có thể được dựng từ một tuple gồm giá trị đỏ, xanh lá và xanh dương:

#[derive(Debug)]
struct Color {
red: u8,
green: u8,
blue: u8,
}
impl From<(u8, u8, u8)> for Color {
fn from((red, green, blue): (u8, u8, u8)) -> Self {
Color { red, green, blue }
}
}
fn main() {
let orange = Color::from((255, 136, 0));
println!("{:?}", orange);
// Ta chưa bao giờ viết `Into`, nhưng vẫn có nó miễn phí:
let sky: Color = (135, 206, 235).into();
println!("bầu trời: r={} g={} b={}", sky.red, sky.green, sky.blue);
}

Kết quả:

Color { red: 255, green: 136, blue: 0 }
bầu trời: r=135 g=206 b=235
▶ Tự chạy thử

Đây là phần hay. Thư viện chuẩn nói: “nếu B cài đặt From<A>, thì A tự động cài đặt Into<B>”. Vậy một impl From cho bạn hai cách chuyển đổi:

  • Color::from(tuple) đọc là “dựng một Color từ cái này”.
  • tuple.into() đọc là “biến cái này thành thứ đang cần ở đây”.

Vì sao ta viết let sky: Color? Vì .into() không nói sẽ biến thành cái gì. Về lý thuyết, một tuple có thể được chuyển thành nhiều kiểu. Chú thích kiểu cho Rust biết bạn muốn kiểu nào. Nếu Rust đã thấy được kiểu đích, chẳng hạn từ tham số của hàm, bạn không cần nó.

Bạn cũng sẽ hay thấy .into() dùng trong tham số hàm. Một hàm như fn new(title: impl Into<String>) nhận cả &str lẫn String, rồi gọi title.into() bên trong. Ở đó Rust biết kiểu đích, nên không cần chú thích. Bạn sẽ tự thử khuôn mẫu này ở bài luyện tập 2.

From trong xử lý lỗi: cách ? chuyển đổi lỗi

Phần tiêu đề “From trong xử lý lỗi: cách ? chuyển đổi lỗi”

Đây là chỗ From âm thầm làm giúp bạn rất nhiều việc. Khi ? gặp một Err, nó không chỉ trả lỗi về. Trước hết nó gọi From::from trên lỗi, để biến lỗi thành kiểu lỗi mà hàm của bạn trả về.

Vậy nếu hàm của bạn trả về AppError của riêng bạn, và AppError cài đặt From<ParseIntError>, thì ? có thể tự biến lỗi chuyển đổi số thành một AppError:

use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
MissingEquals,
BadNumber(ParseIntError),
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::BadNumber(e)
}
}
// Đọc một dòng cấu hình như "max_users = 50"
fn read_setting(line: &str) -> Result<(String, u32), AppError> {
let (key, value) = line.split_once('=').ok_or(AppError::MissingEquals)?;
let number = value.trim().parse::<u32>()?; // ParseIntError -> AppError
Ok((key.trim().to_string(), number))
}
fn main() {
println!("{:?}", read_setting("max_users = 50"));
println!("{:?}", read_setting("max_users = nhiều"));
println!("{:?}", read_setting("max_users 50"));
}

Kết quả:

Ok(("max_users", 50))
Err(BadNumber(ParseIntError { kind: InvalidDigit }))
Err(MissingEquals)
▶ Tự chạy thử

Thiếu impl From, dấu ? thứ hai sẽ không biên dịch được, vì Rust không biết cách biến ParseIntError thành AppError. Có nó, thân hàm của bạn vẫn gọn gàng. Trang Lỗi tự định nghĩa đi sâu hơn vào việc thiết kế kiểu lỗi, còn trang anyhow và thiserror giới thiệu các crate viết sẵn những impl From này cho bạn.

TryFrom và TryInto: chuyển đổi có thể thất bại

Phần tiêu đề “TryFrom và TryInto: chuyển đổi có thể thất bại”

Có những chuyển đổi chỉ thành công đôi khi. 300 không vừa trong u8. -5 không phải phần trăm hợp lệ. Cho những trường hợp này, Rust có TryFrom. Nó hoạt động giống From, nhưng trả về một Result, nên bạn phải xử lý trường hợp “không”. Đó là điều tốt: thất bại không thể lọt qua mắt bạn.

Bạn đã thấy phiên bản dựng sẵn ở đầu trang này: u8::try_from(300_i32) trả về một Err thay vì một con số sai. Và cũng như From đi kèm Into, TryFrom đi kèm TryInto, nên let small: Result<u8, _> = big.try_into(); cũng chạy được. TryInto có sẵn trong prelude từ edition 2021, nên bạn không cần import gì cả.

Hãy tạo kiểu Percentage chỉ chứa được giá trị từ 0 đến 100. Nếu mọi Percentage đều được tạo qua try_from, thì mọi Percentage trong chương trình đều hợp lệ. Bạn kiểm tra một lần, ngay ở cửa, và không bao giờ phải kiểm tra lại.

#[derive(Debug)]
struct Percentage(u8);
#[derive(Debug)]
struct OutOfRange(i32);
impl TryFrom<i32> for Percentage {
type Error = OutOfRange;
fn try_from(value: i32) -> Result<Self, Self::Error> {
if (0..=100).contains(&value) {
Ok(Percentage(value as u8)) // an toàn: ta vừa kiểm tra khoảng giá trị
} else {
Err(OutOfRange(value))
}
}
}
fn main() {
println!("{:?}", Percentage::try_from(85));
println!("{:?}", Percentage::try_from(150));
let battery: Result<Percentage, _> = (-5).try_into();
println!("{:?}", battery);
}

Kết quả:

Ok(Percentage(85))
Err(OutOfRange(150))
Err(OutOfRange(-5))
▶ Tự chạy thử

Dòng type Error = OutOfRange; cho Rust biết phép chuyển đổi này trả về loại lỗi nào. Bạn tự chọn. Ở đây nó mang theo giá trị sai, để ai nhận lỗi cũng có thể hiện ra một thông báo hữu ích.

FromStr và .parse(): dựng giá trị từ văn bản

Phần tiêu đề “FromStr và .parse(): dựng giá trị từ văn bản”

Văn bản là thứ bạn chuyển đổi nhiều nhất. File, tham số dòng lệnh và biểu mẫu web đều đưa cho bạn chuỗi. Trait FromStr mô tả “cách dựng kiểu này từ một &str”, và .parse() là cách thân thiện để gọi nó.

Khi bạn viết "42".parse::<i32>(), Rust tìm phần cài đặt FromStr của i32 và chạy nó. Phần ::<i32> (người ta gọi là “turbofish”) cho Rust biết bạn muốn kiểu nào. Chú thích kiểu trên biến cũng được.

let age = "42".parse::<i32>(); // Ok(42)
let price: Result<f64, _> = "4.99".parse(); // Ok(4.99)
let broken = "bốn mươi hai".parse::<i32>(); // Err(ParseIntError { kind: InvalidDigit })

Mỗi lời gọi trả về một Result, vì văn bản lúc nào cũng có thể sai.

Hãy tưởng tượng một tiệm làm khung tranh, nơi khách gõ kích thước dạng "30x40". Hãy dạy Rust đọc nó:

use std::str::FromStr;
#[derive(Debug)]
struct Size {
width: u32,
height: u32,
}
#[derive(Debug)]
enum SizeError {
MissingX,
BadNumber,
}
impl FromStr for Size {
type Err = SizeError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let (w, h) = text.split_once('x').ok_or(SizeError::MissingX)?;
let width = w.trim().parse().map_err(|_| SizeError::BadNumber)?;
let height = h.trim().parse().map_err(|_| SizeError::BadNumber)?;
Ok(Size { width, height })
}
}
fn main() {
println!("{:?}", "30x40".parse::<Size>());
println!("{:?}", "30 nhân 40".parse::<Size>());
println!("{:?}", "30xlớn".parse::<Size>());
}

Kết quả:

Ok(Size { width: 30, height: 40 })
Err(MissingX)
Err(BadNumber)
▶ Tự chạy thử

Hai chi tiết nhỏ. Thứ nhất, FromStr đặt tên kiểu lỗi là Err, không phải Error như TryFrom. Đó là một kiểu đặt tên lạ từ hồi xưa, nên đừng lo nếu lúc đầu bạn nhầm; trình biên dịch sẽ nhắc bạn. Thứ hai, bạn cần use std::str::FromStr; để cài đặt trait, nhưng không cần để gọi .parse().

.map_err(|_| SizeError::BadNumber) đổi lỗi chuyển đổi số thành lỗi của riêng ta. Đó là một lựa chọn tiện lợi thay cho việc viết impl From khi bạn chỉ cần nó ở một chỗ.

AsRef: nhận mọi thứ có thể cho bạn mượn một góc nhìn

Phần tiêu đề “AsRef: nhận mọi thứ có thể cho bạn mượn một góc nhìn”

Các trait từ đầu đến giờ đều tạo ra một giá trị mới. AsRef thì khác. Nó không dựng gì cả. Nó chỉ nói “tôi có thể cho bạn mượn một góc nhìn rẻ về chính tôi dưới dạng kiểu kia”.

Sao bạn lại cần điều đó? Giả sử bạn viết một hàm đếm từ. Nếu nó nhận &str, người gọi có String phải viết &name. Nếu nó nhận String, người gọi có &str phải cấp phát một String mới. Với impl AsRef<str>, mọi trường hợp đều chạy được:

fn word_count(text: impl AsRef<str>) -> usize {
text.as_ref().split_whitespace().count()
}
fn main() {
let note = String::from("mua sữa và trứng");
println!("{}", word_count("xin chào rust")); // &str
println!("{}", word_count(&note)); // &String
println!("{}", word_count(note)); // String
}

Kết quả:

3
4
4
▶ Tự chạy thử

Bên trong hàm, text.as_ref() cho bạn một &str thuần, bất kể người gọi truyền vào gì.

Có lẽ bạn đã dùng AsRef mà không để ý. Các hàm làm việc với file trong std::fs trông như thế này:

pub fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String>

Đó là lý do fs::read_to_string("notes.txt"), fs::read_to_string(my_string) và fs::read_to_string(my_path_buf) đều chạy được. Bạn có thể dùng cùng mẹo đó trong code của mình. Ví dụ này chỉ xem tên đường dẫn, nên không động vào file thật nào:

use std::path::{Path, PathBuf};
fn extension_of(path: impl AsRef<Path>) -> String {
match path.as_ref().extension() {
Some(ext) => ext.to_string_lossy().to_string(),
None => String::from("(không có)"),
}
}
fn main() {
println!("{}", extension_of("notes.txt"));
println!("{}", extension_of(String::from("holiday.jpeg")));
println!("{}", extension_of(PathBuf::from("backups/2024/site.tar.gz")));
println!("{}", extension_of("README"));
}

Kết quả:

txt
jpeg
gz
(không có)
▶ Tự chạy thử

AsMut cùng ý tưởng đó, nhưng cho mượn một góc nhìn khả biến. Ví dụ, cả Vec lẫn mảng đều có thể cho mượn chính mình dưới dạng slice khả biến:

fn reset_all(scores: &mut impl AsMut<[u32]>) {
for score in scores.as_mut() {
*score = 0;
}
}
// Cả hai đều chạy được:
// reset_all(&mut vec![12, 7, 30]);
// reset_all(&mut [3, 1, 4]);

Bạn sẽ dùng AsMut ít hơn AsRef nhiều. Chỉ cần nhận ra nó khi gặp là đủ.

Khi chưa chắc, bảng này là điểm khởi đầu tốt:

Bạn muốn… Dùng Ví dụ
Chuyển nhanh giữa các kiểu số, chấp nhận bị cắt bớt as x as f64
Chuyển đổi khi không bao giờ thất bại From i64::from(n), String::from("hi")
Như trên, khi kiểu đích đã biết Into let s: String = "hi".into();
Chuyển đổi khi có thể thất bại TryFrom / TryInto u8::try_from(300)
Dựng giá trị từ văn bản FromStr / .parse() "42".parse::<i32>()
Nhận nhiều dạng đầu vào giống chuỗi hoặc đường dẫn một cách rẻ AsRef fn f(p: impl AsRef<Path>)
Biến giá trị thành văn bản cho người đọc Display → .to_string() 42.to_string()

Dòng cuối có sẵn miễn phí: cài đặt Display (xem Các trait thông dụng) và mọi giá trị thuộc kiểu của bạn sẽ có phương thức .to_string().

Gọi .into() khi Rust không biết kiểu đích.

fn main() {
let name = "Ferris".into();
println!("{name}");
}
error[E0283]: type annotations needed
--> src/main.rs:2:9
|
2 | let name = "Ferris".into();
| ^^^^ ---- type must be known at this point
|
= note: the type must implement `From<&str>`
= note: required for `&str` to implement `Into<_>`
help: consider giving `name` an explicit type
|
2 | let name: /* Type */ = "Ferris".into();
| ++++++++++++

Rustc báo cần chú thích kiểu và gợi ý ghi rõ kiểu cho name. Một &str có thể biến thành String, Box<str>, Vec<u8> và nhiều kiểu khác. Rust sẽ không đoán. Hãy thêm kiểu: let name: String = "Ferris".into();, hoặc viết luôn String::from("Ferris").

Cài đặt Into thay vì From. Trông có vẻ như là một, nên đây là cái bẫy rất dễ sa vào:

use std::num::ParseIntError;
#[derive(Debug)]
struct AppError(String);
impl Into<AppError> for ParseIntError {
fn into(self) -> AppError {
AppError(self.to_string())
}
}
fn read_port(text: &str) -> Result<u16, AppError> {
let port = text.parse::<u16>()?;
Ok(port)
}
error[E0277]: `?` couldn't convert the error to `AppError`
--> src/main.rs:13:35
|
12 | fn read_port(text: &str) -> Result<u16, AppError> {
| --------------------- expected `AppError` because of this
13 | let port = text.parse::<u16>()?;
| --------------^ the trait `From<ParseIntError>` is not implemented for `AppError`
|
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait

Rustc báo ? không chuyển được lỗi sang AppError, vì AppError chưa cài đặt From<ParseIntError>. Món quà miễn phí chỉ đi một chiều: cài đặt From cho bạn Into, nhưng cài đặt Into thì không cho bạn From. Và ?, cùng rất nhiều code thư viện, đòi hỏi From. Vậy hãy luôn cài đặt From, và để Into tự đi theo. (Clippy, công cụ lint của Rust, thậm chí còn cảnh báo về trường hợp này.)

Hãy tạo struct Time với hour: u8 và minute: u8. Cài đặt FromStr để "09:45".parse::<Time>() chạy được. Trả về lỗi nếu không có :, nếu một phần không phải là số, hoặc nếu giờ lớn hơn 23 hay phút lớn hơn 59. Thử với "09:45", "25:00" và "trưa". Gợi ý: split_once(':') cho bạn hai nửa. Dùng một String đơn giản làm kiểu lỗi là được.

Xem lời giải
use std::str::FromStr;
#[derive(Debug)]
struct Time {
hour: u8,
minute: u8,
}
impl FromStr for Time {
type Err = String;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let (h, m) = text.split_once(':').ok_or("thiếu ':'")?;
let hour: u8 = h.parse().map_err(|_| "giờ không phải là số")?;
let minute: u8 = m.parse().map_err(|_| "phút không phải là số")?;
if hour > 23 || minute > 59 {
return Err(format!("{text} không phải là giờ có thật"));
}
Ok(Time { hour, minute })
}
}
fn main() {
for input in ["09:45", "25:00", "trưa"] {
match input.parse::<Time>() {
Ok(t) => println!("{input} -> {} giờ {} phút", t.hour, t.minute),
Err(e) => println!("{input} -> lỗi: {e}"),
}
}
}

Kết quả:

09:45 -> 9 giờ 45 phút
25:00 -> lỗi: 25:00 không phải là giờ có thật
trưa -> lỗi: thiếu ':'
▶ Tự chạy thử

Dấu ? sau .ok_or("thiếu ':'") chạy được vì String cài đặt From<&str>. Đó chính là mẹo From ở phần xử lý lỗi, chỉ là lần này với một kiểu chuẩn.

Hãy tạo struct Timer với minutes: u32 và seconds: u32. Cài đặt From<u32> để tổng số giây trở thành một Timer (150 thành 2 phút 30 giây). Rồi viết fn start(timer: impl Into<Timer>) in ra thời gian, và gọi nó với 150 và với Timer { minutes: 1, seconds: 5 }. Gợi ý: số phút là total / 60 và phần còn lại là total % 60. Mọi kiểu vốn đã chuyển đổi được thành chính nó, nên truyền một Timer cũng chạy được.

Xem lời giải
struct Timer {
minutes: u32,
seconds: u32,
}
impl From<u32> for Timer {
fn from(total: u32) -> Self {
Timer { minutes: total / 60, seconds: total % 60 }
}
}
fn start(timer: impl Into<Timer>) {
let t = timer.into();
println!("Đã hẹn giờ {}:{:02}", t.minutes, t.seconds);
}
fn main() {
start(150_u32);
start(Timer { minutes: 1, seconds: 5 });
}

Kết quả:

Đã hẹn giờ 2:30
Đã hẹn giờ 1:05
▶ Tự chạy thử

Ta chỉ viết From<u32>, vậy mà start nhận cả một con số lẫn một Timer. Con số chạy được nhờ From của ta, còn Timer chạy được vì thư viện chuẩn quy định mọi kiểu đều chuyển đổi được thành chính nó.

  • as dùng cho ép kiểu số nhanh và có thể lặng lẽ làm mất dữ liệu. Các trait chuyển đổi thì tường minh, an toàn, và dùng được cho kiểu của riêng bạn.
  • Cài đặt From cho những chuyển đổi luôn thành công, và bạn có Into miễn phí. .into() cần biết kiểu đích.
  • Toán tử ? gọi From để chuyển đổi kiểu lỗi, nên impl From<OtherError> for MyError giữ cho phần xử lý lỗi ngắn gọn.
  • Dùng TryFrom / TryInto khi chuyển đổi có thể thất bại. Chúng trả về một Result.
  • Cài đặt FromStr để .parse::<YourType>() chạy được trên văn bản.
  • Dùng impl AsRef<str> hoặc impl AsRef<Path> để nhận nhiều kiểu đầu vào một cách rẻ, không cần sao chép.

Kiểm tra nhanh

1. Bạn cài đặt From<Celsius> for Fahrenheit. Bạn tự động có thêm gì?

2. Lời gọi nào là cách an toàn để biến một i32 có thể bằng 300 thành u8?

3. Bạn cài đặt gì để "30x40".parse::<Size>() chạy được?

4. Vì sao let name = "Ferris".into(); không biên dịch được?

Điểm: 0 / 4