Tái cấu trúc để cải thiện tính module hóa và xử lý lỗi
Để cải thiện chương trình của chúng ta, chúng ta sẽ sửa bốn vấn đề liên quan đến
cấu trúc chương trình và cách nó xử lý các lỗi tiềm ẩn. Đầu tiên, hàm main
của
chúng ta hiện thực hiện hai nhiệm vụ: phân tích đối số và đọc tệp. Khi chương
trình của chúng ta phát triển, số lượng nhiệm vụ riêng biệt mà hàm main
xử lý
sẽ tăng lên. Khi một hàm có thêm nhiều trách nhiệm, nó trở nên khó hiểu hơn, khó
kiểm thử hơn, và khó thay đổi mà không làm hỏng một trong những phần của nó. Tốt
nhất là tách biệt chức năng để mỗi hàm chịu trách nhiệm cho một nhiệm vụ.
Vấn đề này cũng liên quan đến vấn đề thứ hai: mặc dù query
và file_path
là
các biến cấu hình cho chương trình của chúng ta, các biến như contents
được sử
dụng để thực hiện logic của chương trình. Càng dài main
trở nên, càng nhiều
biến chúng ta sẽ cần đưa vào phạm vi; càng nhiều biến chúng ta có trong phạm vi,
càng khó để theo dõi mục đích của mỗi biến. Tốt nhất là nhóm các biến cấu hình
vào một cấu trúc để làm rõ mục đích của chúng.
Vấn đề thứ ba là chúng ta đã sử dụng expect
để in một thông báo lỗi khi đọc
tệp thất bại, nhưng thông báo lỗi chỉ in ra
Should have been able to read the file
. Đọc một tệp có thể thất bại vì nhiều
lý do: ví dụ, tệp có thể không tồn tại, hoặc chúng ta có thể không có quyền để
mở nó. Hiện tại, bất kể tình huống nào, chúng ta sẽ in cùng một thông báo lỗi
cho mọi thứ, điều này sẽ không cung cấp cho người dùng bất kỳ thông tin nào!
Thứ tư, chúng ta sử dụng expect
để xử lý lỗi, và nếu người dùng chạy chương
trình của chúng ta mà không chỉ định đủ đối số, họ sẽ nhận được lỗi
index out of bounds
từ Rust không giải thích rõ ràng vấn đề. Sẽ tốt nhất nếu
tất cả mã xử lý lỗi đều ở một nơi để những người bảo trì trong tương lai chỉ có
một nơi để tham khảo mã nếu logic xử lý lỗi cần thay đổi. Có tất cả mã xử lý lỗi
ở một nơi cũng sẽ đảm bảo rằng chúng ta đang in các thông báo có ý nghĩa đối với
người dùng cuối của chúng ta.
Hãy giải quyết bốn vấn đề này bằng cách tái cấu trúc dự án của chúng ta.
Tách biệt các mối quan tâm cho các dự án nhị phân
Vấn đề tổ chức của việc phân bổ trách nhiệm cho nhiều nhiệm vụ cho hàm main
là
phổ biến đối với nhiều dự án nhị phân. Kết quả là, cộng đồng Rust đã phát triển
các hướng dẫn để tách các mối quan tâm riêng biệt của một chương trình nhị phân
khi main
bắt đầu trở nên lớn. Quá trình này có các bước sau:
- Chia chương trình của bạn thành một tệp main.rs và một tệp lib.rs và di chuyển logic chương trình của bạn sang lib.rs.
- Miễn là logic phân tích dòng lệnh của bạn nhỏ, nó có thể ở lại main.rs.
- Khi logic phân tích dòng lệnh bắt đầu trở nên phức tạp, hãy trích xuất nó từ main.rs và di chuyển nó đến lib.rs.
Các trách nhiệm còn lại trong hàm main
sau quá trình này nên được giới hạn
trong các nội dung sau:
- Gọi logic phân tích dòng lệnh với các giá trị đối số
- Thiết lập bất kỳ cấu hình nào khác
- Gọi hàm
run
trong lib.rs - Xử lý lỗi nếu
run
trả về lỗi
Mẫu này là về việc tách biệt các mối quan tâm: main.rs xử lý việc chạy chương
trình và lib.rs xử lý tất cả logic của nhiệm vụ hiện tại. Bởi vì bạn không thể
kiểm tra hàm main
trực tiếp, cấu trúc này cho phép bạn kiểm tra tất cả logic
chương trình của bạn bằng cách di chuyển nó vào các hàm trong lib.rs. Mã còn
lại trong main.rs sẽ đủ nhỏ để xác minh tính đúng đắn của nó bằng cách đọc nó.
Hãy làm lại chương trình của chúng ta bằng cách theo quy trình này.
Trích xuất trình phân tích đối số
Chúng ta sẽ trích xuất chức năng phân tích đối số vào một hàm mà main
sẽ gọi
để chuẩn bị cho việc di chuyển logic phân tích dòng lệnh đến src/lib.rs.
Listing 12-5 hiển thị phần bắt đầu mới của main
gọi một hàm mới
parse_config
, mà chúng ta sẽ định nghĩa trong src/main.rs tạm thời.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
// --snip--
println!("Searching for {query}");
println!("In file {file_path}");
let contents = fs::read_to_string(file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
Chúng ta vẫn đang thu thập các đối số dòng lệnh vào một vector, nhưng thay vì
gán giá trị đối số tại chỉ số 1 cho biến query
và giá trị đối số tại chỉ số 2
cho biến file_path
trong hàm main
, chúng ta truyền toàn bộ vector cho hàm
parse_config
. Hàm parse_config
sau đó giữ logic xác định đối số nào vào biến
nào và truyền các giá trị trở lại cho main
. Chúng ta vẫn tạo các biến query
và file_path
trong main
, nhưng main
không còn có trách nhiệm xác định cách
các đối số dòng lệnh và các biến tương ứng.
Việc tái cấu trúc này có vẻ như là quá mức cần thiết cho chương trình nhỏ của chúng ta, nhưng chúng ta đang tái cấu trúc theo các bước nhỏ, tăng dần. Sau khi thực hiện thay đổi này, chạy chương trình lần nữa để xác minh rằng việc phân tích đối số vẫn hoạt động. Tốt nhất là kiểm tra tiến trình của bạn thường xuyên, để giúp xác định nguyên nhân của các vấn đề khi chúng xảy ra.
Nhóm các giá trị cấu hình
Chúng ta có thể thực hiện một bước nhỏ nữa để cải thiện hàm parse_config
hơn
nữa. Hiện tại, chúng ta đang trả về một tuple, nhưng sau đó chúng ta ngay lập
tức phân chia tuple đó thành các phần riêng biệt một lần nữa. Đây là một dấu
hiệu cho thấy có lẽ chúng ta chưa có sự trừu tượng hóa đúng đắn.
Một chỉ báo khác cho thấy có chỗ để cải thiện là phần config
của
parse_config
, ngụ ý rằng hai giá trị mà chúng ta trả về có liên quan và đều là
một phần của một giá trị cấu hình. Hiện tại, chúng ta không truyền tải ý nghĩa
này trong cấu trúc của dữ liệu ngoài việc nhóm hai giá trị vào một tuple; thay
vào đó, chúng ta sẽ đặt hai giá trị vào một struct và đặt tên cho mỗi trường của
struct một cách có ý nghĩa. Làm như vậy sẽ giúp cho những người bảo trì mã này
trong tương lai dễ dàng hiểu các giá trị khác nhau liên quan đến nhau như thế
nào và mục đích của chúng là gì.
Listing 12-6 hiển thị các cải tiến cho hàm parse_config
.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
// --snip--
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
Chúng ta đã thêm một struct có tên Config
được định nghĩa để có các trường tên
là query
và file_path
. Chữ ký của parse_config
bây giờ chỉ ra rằng nó trả
về một giá trị Config
. Trong thân của parse_config
, nơi mà chúng ta đã từng
trả về các slice chuỗi tham chiếu đến các giá trị String
trong args
, bây giờ
chúng ta định nghĩa Config
để chứa các giá trị String
sở hữu. Biến args
trong main
là chủ sở hữu của các giá trị đối số và chỉ cho phép hàm
parse_config
mượn chúng, điều đó có nghĩa là chúng ta sẽ vi phạm quy tắc mượn
của Rust nếu Config
cố gắng nắm quyền sở hữu các giá trị trong args
.
Có một số cách chúng ta có thể quản lý dữ liệu String
; cách dễ nhất, mặc dù
không hiệu quả lắm, là gọi phương thức clone
trên các giá trị. Điều này sẽ tạo
một bản sao đầy đủ của dữ liệu cho thể hiện Config
để sở hữu, mà tốn nhiều
thời gian và bộ nhớ hơn việc lưu trữ một tham chiếu đến dữ liệu chuỗi. Tuy
nhiên, việc sao chép dữ liệu cũng làm cho mã của chúng ta rất đơn giản vì chúng
ta không phải quản lý thời gian sống của các tham chiếu; trong trường hợp này,
việc hi sinh một chút hiệu suất để đạt được sự đơn giản là một sự đánh đổi đáng
giá.
Sự đánh đổi khi sử dụng
clone
Có một xu hướng trong nhiều người dùng Rust là tránh sử dụng
clone
để sửa vấn đề sở hữu vì chi phí thời gian chạy của nó. Trong Chương 13, bạn sẽ học cách sử dụng các phương pháp hiệu quả hơn trong loại tình huống này. Nhưng hiện tại, sao chép một vài chuỗi để tiếp tục tiến triển là ổn vì bạn sẽ chỉ tạo các bản sao này một lần và đường dẫn tệp cùng chuỗi truy vấn của bạn rất nhỏ. Tốt hơn là có một chương trình hoạt động mà hơi kém hiệu quả một chút so với việc cố gắng tối ưu hóa quá mức mã trong lần đầu tiên của bạn. Khi bạn có nhiều kinh nghiệm hơn với Rust, sẽ dễ dàng hơn để bắt đầu với giải pháp hiệu quả nhất, nhưng hiện tại, việc gọiclone
là hoàn toàn chấp nhận được.
Chúng ta đã cập nhật main
để nó đặt thể hiện Config
được trả về bởi
parse_config
vào một biến tên là config
, và chúng ta đã cập nhật mã mà trước
đây sử dụng các biến query
và file_path
riêng biệt để bây giờ nó sử dụng các
trường trên struct Config
thay thế.
Bây giờ mã của chúng ta truyền tải rõ ràng hơn rằng query
và file_path
có
liên quan và mục đích của chúng là để cấu hình cách chương trình sẽ hoạt động.
Bất kỳ mã nào sử dụng các giá trị này đều biết tìm chúng trong thể hiện config
trong các trường được đặt tên theo mục đích của chúng.
Tạo một hàm khởi tạo cho Config
Cho đến giờ, chúng ta đã trích xuất logic chịu trách nhiệm phân tích các đối số
dòng lệnh từ main
và đặt nó trong hàm parse_config
. Làm như vậy đã giúp
chúng ta thấy rằng các giá trị query
và file_path
có liên quan, và mối quan
hệ đó nên được truyền tải trong mã của chúng ta. Sau đó, chúng ta đã thêm một
struct Config
để đặt tên cho mục đích liên quan của query
và file_path
và
để có thể trả lại tên của các giá trị dưới dạng tên trường struct từ hàm
parse_config
.
Do đó, bây giờ mục đích của hàm parse_config
là để tạo một thể hiện Config
,
chúng ta có thể thay đổi parse_config
từ một hàm đơn giản thành một hàm có tên
new
được liên kết với struct Config
. Thực hiện thay đổi này sẽ làm cho mã
trở nên thông dụng hơn. Chúng ta có thể tạo các thể hiện của các kiểu trong thư
viện chuẩn, chẳng hạn như String
, bằng cách gọi String::new
. Tương tự, bằng
cách thay đổi parse_config
thành một hàm new
liên kết với Config
, chúng ta
sẽ có thể tạo các thể hiện của Config
bằng cách gọi Config::new
. Listing
12-7 hiển thị các thay đổi mà chúng ta cần thực hiện.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
// --snip--
}
// --snip--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
Chúng ta đã cập nhật main
nơi chúng ta đã gọi parse_config
để thay vào đó
gọi Config::new
. Chúng ta đã thay đổi tên của parse_config
thành new
và di
chuyển nó trong một khối impl
, vốn liên kết hàm new
với Config
. Hãy thử
biên dịch mã này lần nữa để đảm bảo rằng nó hoạt động.
Sửa chữa việc xử lý lỗi
Bây giờ chúng ta sẽ làm việc để sửa chữa việc xử lý lỗi của mình. Hãy nhớ lại
rằng việc cố gắng truy cập các giá trị trong vector args
tại chỉ số 1 hoặc chỉ
số 2 sẽ khiến chương trình panic nếu vector chứa ít hơn ba phần tử. Hãy thử chạy
chương trình mà không có bất kỳ đối số nào; nó sẽ trông như thế này:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Dòng index out of bounds: the len is 1 but the index is 1
là một thông báo lỗi
dành cho lập trình viên. Nó sẽ không giúp người dùng cuối của chúng ta hiểu họ
nên làm gì thay thế. Hãy sửa điều đó ngay bây giờ.
Cải thiện thông báo lỗi
Trong Listing 12-8, chúng ta thêm một kiểm tra trong hàm new
sẽ xác minh rằng
slice đủ dài trước khi truy cập chỉ số 1 và chỉ số 2. Nếu slice không đủ dài,
chương trình panic và hiển thị một thông báo lỗi tốt hơn.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
// --snip--
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
Mã này tương tự như hàm Guess::new
mà chúng ta đã viết trong Listing
9-13, nơi chúng ta đã gọi panic!
khi đối số
value
nằm ngoài phạm vi các giá trị hợp lệ. Thay vì kiểm tra cho một phạm vi
giá trị ở đây, chúng ta kiểm tra rằng độ dài của args
ít nhất là 3
và phần
còn lại của hàm có thể hoạt động dưới giả định rằng điều kiện này đã được đáp
ứng. Nếu args
có ít hơn ba mục, điều kiện này sẽ là true
, và chúng ta gọi
macro panic!
để kết thúc chương trình ngay lập tức.
Với một vài dòng mã bổ sung này trong new
, hãy chạy chương trình mà không có
bất kỳ đối số nào một lần nữa để xem lỗi trông như thế nào bây giờ:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Đầu ra này tốt hơn: bây giờ chúng ta có một thông báo lỗi hợp lý. Tuy nhiên,
chúng ta cũng có thông tin không cần thiết mà chúng ta không muốn cung cấp cho
người dùng. Có lẽ kỹ thuật mà chúng ta đã sử dụng trong Listing 9-13 không phải
là cách tốt nhất để sử dụng ở đây: một cuộc gọi đến panic!
thích hợp hơn cho
một vấn đề lập trình hơn là một vấn đề sử dụng, như đã thảo luận trong Chương
9. Thay vào đó, chúng ta sẽ sử dụng kỹ
thuật khác mà bạn đã học về trong Chương 9—trả về một
Result
cho biết thành công hoặc một lỗi.
Trả về một Result
thay vì gọi panic!
Thay vào đó, chúng ta có thể trả về một giá trị Result
sẽ chứa một thể hiện
Config
trong trường hợp thành công và sẽ mô tả vấn đề trong trường hợp lỗi.
Chúng ta cũng sẽ thay đổi tên hàm từ new
thành build
vì nhiều lập trình viên
mong đợi các hàm new
không bao giờ thất bại. Khi Config::build
đang giao
tiếp với main
, chúng ta có thể sử dụng kiểu Result
để báo hiệu có một vấn
đề. Sau đó chúng ta có thể thay đổi main
để chuyển đổi một biến thể Err
thành một lỗi thực tế hơn cho người dùng của chúng ta mà không có các văn bản
xung quanh về thread 'main'
và RUST_BACKTRACE
mà một cuộc gọi đến panic!
gây ra.
Listing 12-9 hiển thị các thay đổi mà chúng ta cần thực hiện cho giá trị trả về
của hàm mà chúng ta hiện đang gọi là Config::build
và thân của hàm cần thiết
để trả về một Result
. Lưu ý rằng điều này sẽ không biên dịch cho đến khi chúng
ta cập nhật main
cũng vậy, điều mà chúng ta sẽ làm trong danh sách tiếp theo.
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Hàm build
của chúng ta trả về một Result
với một thể hiện Config
trong
trường hợp thành công và một chuỗi chữ trong trường hợp lỗi. Các giá trị lỗi của
chúng ta sẽ luôn luôn là các chuỗi chữ có thời gian sống 'static
.
Chúng ta đã thực hiện hai thay đổi trong thân hàm: thay vì gọi panic!
khi
người dùng không truyền đủ đối số, chúng ta hiện trả về một giá trị Err
, và
chúng ta đã bọc giá trị trả về Config
trong một Ok
. Những thay đổi này làm
cho hàm phù hợp với chữ ký kiểu mới của nó.
Việc trả về một giá trị Err
từ Config::build
cho phép hàm main
xử lý giá
trị Result
được trả về từ hàm build
và thoát khỏi quá trình một cách sạch sẽ
hơn trong trường hợp lỗi.
Gọi Config::build
và xử lý lỗi
Để xử lý trường hợp lỗi và in một thông báo thân thiện với người dùng, chúng ta
cần cập nhật main
để xử lý Result
được trả về bởi Config::build
, như hiển
thị trong Listing 12-10. Chúng ta cũng sẽ lấy trách nhiệm thoát khỏi công cụ
dòng lệnh với một mã lỗi khác không từ panic!
và thay vào đó thực hiện nó bằng
tay. Một trạng thái thoát khác không là một quy ước để báo hiệu cho quá trình
gọi chương trình của chúng ta rằng chương trình đã thoát với trạng thái lỗi.
use std::env;
use std::fs;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Trong danh sách này, chúng ta đã sử dụng một phương thức mà chúng ta chưa đề cập
đến chi tiết: unwrap_or_else
, nó được định nghĩa trên Result<T, E>
bởi thư
viện chuẩn. Sử dụng unwrap_or_else
cho phép chúng ta định nghĩa một số xử lý
lỗi tùy chỉnh, không phải panic!
. Nếu Result
là một giá trị Ok
, hành vi
của phương thức này giống với unwrap
: nó trả về giá trị bên trong mà Ok
đang
bọc. Tuy nhiên, nếu giá trị là một giá trị Err
, phương thức này gọi mã trong
closure, đó là một hàm ẩn danh mà chúng ta định nghĩa và truyền làm đối số cho
unwrap_or_else
. Chúng ta sẽ đề cập đến closure chi tiết hơn trong Chương
13. Hiện tại, bạn chỉ cần biết rằng unwrap_or_else
sẽ
truyền giá trị bên trong của Err
, trong trường hợp này là chuỗi tĩnh
"not enough arguments"
mà chúng ta đã thêm trong Listing 12-9, cho closure
trong đối số err
vốn xuất hiện giữa các đường dọc. Mã trong closure sau đó có
thể sử dụng giá trị err
khi nó chạy.
Chúng ta đã thêm một dòng use
mới để đưa process
từ thư viện chuẩn vào phạm
vi. Mã trong closure sẽ được chạy trong trường hợp lỗi chỉ có hai dòng: chúng ta
in giá trị err
và sau đó gọi process::exit
. Hàm process::exit
sẽ dừng
chương trình ngay lập tức và trả về số được truyền làm mã trạng thái thoát. Điều
này tương tự như xử lý dựa trên panic!
mà chúng ta đã sử dụng trong Listing
12-8, nhưng chúng ta không còn nhận được tất cả đầu ra bổ sung. Hãy thử nó:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
Tuyệt vời! Đầu ra này thân thiện hơn nhiều với người dùng của chúng ta.
Trích xuất logic từ main
Bây giờ chúng ta đã hoàn thành việc tái cấu trúc phân tích cấu hình, hãy chuyển
sang logic của chương trình. Như chúng ta đã nêu trong
"Tách biệt các mối quan tâm cho các dự án nhị phân",
chúng ta sẽ trích xuất một hàm tên là run
sẽ chứa tất cả logic hiện tại trong
hàm main
không liên quan đến thiết lập cấu hình hoặc xử lý lỗi. Khi chúng ta
hoàn thành, main
sẽ ngắn gọn và dễ dàng xác minh bằng việc kiểm tra, và chúng
ta sẽ có thể viết các bài kiểm tra cho tất cả các logic khác.
Listing 12-11 hiển thị hàm run
được trích xuất. Hiện tại, chúng ta chỉ đang
thực hiện sự cải thiện nhỏ, tăng dần của việc trích xuất hàm. Chúng ta vẫn định
nghĩa hàm này trong src/main.rs.
use std::env;
use std::fs;
use std::process;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
// --snip--
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Hàm run
hiện chứa tất cả logic còn lại từ main
, bắt đầu từ việc đọc tệp. Hàm
run
nhận thể hiện Config
làm một đối số.
Trả về lỗi từ hàm run
Với logic chương trình còn lại đã được tách biệt vào hàm run
, chúng ta có thể
cải thiện xử lý lỗi, như chúng ta đã làm với Config::build
trong Listing 12-9.
Thay vì cho phép chương trình panic bằng cách gọi expect
, hàm run
sẽ trả về
một Result<T, E>
khi có gì đó không ổn. Điều này sẽ cho phép chúng ta hợp nhất
hơn nữa logic xử lý lỗi vào main
theo một cách thân thiện với người dùng.
Listing 12-12 hiển thị các thay đổi chúng ta cần thực hiện cho chữ ký và thân
của run
.
use std::env;
use std::fs;
use std::process;
use std::error::Error;
// --snip--
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Chúng ta đã thực hiện ba thay đổi đáng kể ở đây. Đầu tiên, chúng ta đã thay đổi
kiểu trả về của hàm run
thành Result<(), Box<dyn Error>>
. Hàm này trước đây
trả về kiểu đơn vị, ()
, và chúng ta giữ giá trị đó làm giá trị được trả về
trong trường hợp Ok
.
Đối với kiểu lỗi, chúng ta đã sử dụng trait object Box<dyn Error>
(và chúng
ta đã đưa std::error::Error
vào phạm vi với một câu lệnh use
ở đầu). Chúng
ta sẽ nói về trait object trong Chương 18. Hiện tại, chỉ
cần biết rằng Box<dyn Error>
có nghĩa là hàm sẽ trả về một kiểu mà thực hiện
trait Error
, nhưng chúng ta không phải chỉ định kiểu cụ thể mà giá trị trả về
sẽ là. Điều này cung cấp cho chúng ta sự linh hoạt để trả về các giá trị lỗi có
thể thuộc các kiểu khác nhau trong các trường hợp lỗi khác nhau. Từ khóa dyn
là viết tắt của dynamic.
Thứ hai, chúng ta đã loại bỏ lệnh gọi đến expect
để ủng hộ toán tử ?
, như
chúng ta đã nói trong Chương 9. Thay vì
panic!
khi có lỗi, ?
sẽ trả về giá trị lỗi từ hàm hiện tại để người gọi xử
lý.
Thứ ba, hàm run
bây giờ trả về một giá trị Ok
trong trường hợp thành công.
Chúng ta đã khai báo kiểu thành công của hàm run
là ()
trong chữ ký, có
nghĩa là chúng ta cần bọc giá trị kiểu đơn vị trong giá trị Ok
. Cú pháp
Ok(())
này có vẻ hơi kỳ lạ lúc đầu, nhưng sử dụng ()
như thế này là cách
thông dụng để chỉ ra rằng chúng ta đang gọi run
chỉ vì các hiệu ứng phụ của
nó; nó không trả về một giá trị mà chúng ta cần.
Khi bạn chạy mã này, nó sẽ biên dịch nhưng sẽ hiển thị một cảnh báo:
$ cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^
|
= note: this `Result` may be an `Err` variant, which should be handled
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
19 | let _ = run(config);
| +++++++
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Rust cho chúng ta biết rằng mã của chúng ta đã bỏ qua giá trị Result
và giá
trị Result
có thể chỉ ra rằng một lỗi đã xảy ra. Nhưng chúng ta không kiểm tra
xem liệu có hay không có lỗi, và trình biên dịch nhắc nhở chúng ta rằng có lẽ
chúng ta muốn có mã xử lý lỗi ở đây! Hãy sửa vấn đề đó ngay bây giờ.
Xử lý lỗi trả về từ run
trong main
Chúng ta sẽ kiểm tra lỗi và xử lý chúng bằng một kỹ thuật tương tự như một kỹ
thuật chúng ta đã sử dụng với Config::build
trong Listing 12-10, nhưng với một
chút khác biệt:
Tên tệp: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
struct Config {
query: String,
file_path: String,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
Chúng ta sử dụng if let
thay vì unwrap_or_else
để kiểm tra xem run
có trả
về một giá trị Err
hay không và gọi process::exit(1)
nếu nó trả về. Hàm
run
không trả về một giá trị mà chúng ta muốn unwrap
theo cùng cách mà
Config::build
trả về thể hiện Config
. Bởi vì run
trả về ()
trong trường
hợp thành công, chúng ta chỉ quan tâm đến việc phát hiện một lỗi, vì vậy chúng
ta không cần unwrap_or_else
để trả về giá trị unwrapped, chỉ là ()
.
Thân của if let
và các hàm unwrap_or_else
là giống nhau trong cả hai trường
hợp: chúng ta in lỗi và thoát.
Chia mã thành một Crate thư viện
Dự án minigrep
của chúng ta đang hoạt động tốt cho đến nay! Bây giờ chúng ta
sẽ chia tệp src/main.rs và đặt một số mã vào tệp src/lib.rs. Bằng cách đó,
chúng ta có thể kiểm tra mã và có một tệp src/main.rs với ít trách nhiệm hơn.
Hãy chuyển tất cả mã không ở trong hàm main
từ src/main.rs đến src/lib.rs:
- Định nghĩa hàm
run
- Các câu lệnh
use
liên quan - Định nghĩa của
Config
- Định nghĩa hàm
Config::build
Nội dung của src/lib.rs nên có các chữ ký được hiển thị trong Listing 12-13 (chúng ta đã bỏ qua thân của các hàm để ngắn gọn). Lưu ý rằng điều này sẽ không biên dịch cho đến khi chúng ta sửa đổi src/main.rs trong Listing 12-14.
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
// --snip--
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
// --snip--
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
Chúng ta đã sử dụng từ khóa pub
một cách rộng rãi: trên Config
, trên các
trường và phương thức build
của nó, và trên hàm run
. Bây giờ chúng ta có một
crate thư viện có một API công khai mà chúng ta có thể kiểm tra!
Bây giờ chúng ta cần đưa mã mà chúng ta đã di chuyển đến src/lib.rs vào phạm vi của crate nhị phân trong src/main.rs, như hiển thị trong Listing 12-14.
use std::env;
use std::process;
use minigrep::Config;
fn main() {
// --snip--
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = minigrep::run(config) {
// --snip--
println!("Application error: {e}");
process::exit(1);
}
}
Chúng ta thêm một dòng use minigrep::Config
để đưa kiểu Config
từ crate thư
viện vào phạm vi của crate nhị phân, và chúng ta thêm tiền tố minigrep::
trước
hàm run
. Bây giờ tất cả chức năng nên được kết nối và nên hoạt động. Chạy
chương trình với cargo run
và đảm bảo mọi thứ hoạt động đúng.
Chà! Đó là một công việc lớn, nhưng chúng ta đã thiết lập bản thân để thành công trong tương lai. Bây giờ việc xử lý lỗi dễ dàng hơn nhiều và chúng ta đã làm cho mã trở nên module hơn. Gần như tất cả công việc của chúng ta sẽ được thực hiện trong src/lib.rs từ bây giờ.
Hãy tận dụng tính module hóa mới này bằng cách làm một điều mà lẽ ra đã khó với mã cũ nhưng dễ dàng với mã mới: chúng ta sẽ viết một số bài kiểm tra!