Ngôn ngữ lập trình Rust

bởi Steve Klabnik, Carol Nichols, và Chris Krycho, với sự đóng góp của cộng đồng Rust

bản dịch Tiếng Việt được thực hiện bởi Tuấn Em, với sự đóng góp của cộng đồng Rust tại Việt Nam

Phiên bản này của cuốn sách giả định rằng bạn đang sử dụng Rust 1.85.0 (phát hành ngày 17-02-2025) hoặc mới hơn với edition = "2024" trong tệp Cargo.toml của tất cả các dự án để cấu hình chúng sử dụng các quy ước của Rust phiên bản 2024. Xem phần “Cài đặt“ của Chương 1 để cài đặt hoặc cập nhật Rust.

Định dạng HTML có sẵn trực tuyến tại https://rust-book.tuanem.com/ và ngoại tuyến với các bản cài đặt Rust được thực hiện với rustup; chạy lệnh rustup doc --book để mở.

Một số bản dịch từ cộng đồng cũng có sẵn.

Cuốn sách này có bản in và định dạng ebook từ No Starch Press.

🚨 Bạn muốn có trải nghiệm học tập tương tác hơn? Hãy thử phiên bản khác của Sách Rust, với các tính năng: câu đố, làm nổi bật, trực quan hóa và nhiều hơn nữa: https://rust-book.cs.brown.edu

Lời nói đầu

Không phải lúc nào cũng rõ ràng, nhưng ngôn ngữ lập trình Rust về cơ bản là sự trao quyền: bất kể bạn đang viết loại mã nào, Rust trao quyền cho bạn vươn xa hơn, lập trình với sự tự tin trong nhiều lĩnh vực đa dạng hơn so với trước đây.

Ví dụ, hãy xem xét công việc "cấp hệ thống" liên quan đến các chi tiết cấp thấp của quản lý bộ nhớ, biểu diễn dữ liệu và lập trình đồng thời. Theo truyền thống, lĩnh vực lập trình này được coi là khó hiểu, chỉ tiếp cận được với một số ít người đã dành nhiều năm học tập để tránh những lỗi tiềm ẩn phổ biến của nó. Và thậm chí những người làm việc với nó cũng làm với sự thận trọng, để tránh mã của họ bị khai thác, gặp sự cố hoặc bị hỏng.

Rust phá vỡ những rào cản này bằng cách loại bỏ những lỗi tiềm ẩn cũ và cung cấp một bộ công cụ thân thiện, hoàn thiện để giúp bạn trên suốt chặng đường. Các lập trình viên cần "đi sâu" vào kiểm soát cấp thấp hơn có thể làm như vậy với Rust mà không phải chấp nhận rủi ro thông thường của sự cố hoặc lỗ hổng bảo mật, và không cần phải học các chi tiết tinh vi của một công cụ không ổn định. Tốt hơn nữa, ngôn ngữ được thiết kế để hướng dẫn bạn viết mã đáng tin cậy một cách tự nhiên, hiệu quả về tốc độ và sử dụng bộ nhớ.

Các lập trình viên đã làm việc với mã cấp thấp có thể sử dụng Rust để nâng cao tham vọng của họ. Ví dụ, việc đưa tính song song vào Rust là một hoạt động tương đối ít rủi ro: trình biên dịch sẽ phát hiện các lỗi cổ điển cho bạn. Và bạn có thể giải quyết các tối ưu hóa mạnh mẽ hơn trong mã của mình với sự tự tin rằng bạn sẽ không vô tình gây ra sự cố hoặc lỗ hổng.

Nhưng Rust không giới hạn ở lập trình hệ thống cấp thấp. Nó đủ linh hoạt và tiện dụng để làm cho các ứng dụng CLI, máy chủ web và nhiều loại mã khác trở nên khá dễ chịu để viết — bạn sẽ tìm thấy các ví dụ đơn giản về cả hai trong phần sau của cuốn sách. Làm việc với Rust cho phép bạn xây dựng kỹ năng có thể chuyển giao từ lĩnh vực này sang lĩnh vực khác; bạn có thể học Rust bằng cách viết một ứng dụng web, sau đó áp dụng những kỹ năng tương tự đó để nhắm đến Raspberry Pi của bạn.

Cuốn sách này hoàn toàn đón nhận tiềm năng của Rust trong việc trao quyền cho người dùng của nó. Đây là một văn bản thân thiện và dễ tiếp cận nhằm giúp bạn nâng cao không chỉ kiến thức về Rust, mà còn mở rộng hiểu biết và sự tự tin của bạn với tư cách là một lập trình viên nói chung. Vì vậy, hãy bắt đầu, sẵn sàng để học và chào mừng đến với cộng đồng Rust!

— Nicholas Matsakis và Aaron Turon

Giới thiệu

Lưu ý: Phiên bản sách này giống với The Rust Programming Language có sẵn ở dạng in và sách điện tử từ No Starch Press.

Chào mừng bạn đến với Ngôn ngữ Lập trình Rust, một cuốn sách nhập môn về Rust. Ngôn ngữ lập trình Rust giúp bạn viết phần mềm nhanh hơn và đáng tin cậy hơn. Tính tiện dụng cấp cao và kiểm soát cấp thấp thường mâu thuẫn với nhau trong thiết kế ngôn ngữ lập trình; Rust thách thức xung đột đó. Thông qua cân bằng giữa khả năng kỹ thuật mạnh mẽ và trải nghiệm phát triển tuyệt vời, Rust cho bạn lựa chọn để kiểm soát chi tiết cấp thấp (như sử dụng bộ nhớ) mà không gặp phải tất cả những rắc rối thường liên quan đến việc kiểm soát như vậy.

Rust dành cho ai

Rust lý tưởng cho nhiều người vì nhiều lý do khác nhau. Hãy xem xét một vài nhóm quan trọng nhất.

Các nhóm nhà phát triển

Rust đang chứng minh là một công cụ hiệu quả cho việc hợp tác giữa các nhóm lớn gồm các nhà phát triển với các mức độ kiến thức lập trình hệ thống khác nhau. Mã cấp thấp dễ bị lỗi tinh vi, mà trong hầu hết các ngôn ngữ khác chỉ có thể phát hiện thông qua kiểm tra kỹ lưỡng và xem xét mã cẩn thận bởi các nhà phát triển có kinh nghiệm. Trong Rust, trình biên dịch đóng vai trò như một người gác cổng bằng cách từ chối biên dịch mã với những lỗi khó nắm bắt này, bao gồm cả lỗi đồng thời. Bằng cách làm việc cùng với trình biên dịch, nhóm có thể tập trung vào logic của chương trình thay vì săn lùng lỗi.

Rust cũng mang đến các công cụ phát triển hiện đại cho thế giới lập trình hệ thống:

  • Cargo, trình quản lý phụ thuộc và công cụ xây dựng đi kèm, giúp thêm, biên dịch và quản lý các phụ thuộc một cách dễ dàng và nhất quán trên toàn bộ hệ sinh thái Rust.
  • Công cụ định dạng Rustfmt đảm bảo một phong cách mã hóa nhất quán giữa các nhà phát triển.
  • Rust-analyzer cung cấp tích hợp với Môi trường Phát triển Tích hợp (IDE) cho việc hoàn thành mã và báo lỗi trực tiếp.

Bằng cách sử dụng các công cụ này và các công cụ khác trong hệ sinh thái Rust, các nhà phát triển có thể làm việc hiệu quả trong khi viết mã cấp hệ thống.

Học sinh

Rust dành cho học sinh và những người quan tâm đến việc học về các khái niệm hệ thống. Sử dụng Rust, nhiều người đã học về các chủ đề như phát triển hệ điều hành. Cộng đồng rất chào đón và sẵn sàng trả lời các câu hỏi của học sinh. Thông qua các nỗ lực như cuốn sách này, các nhóm Rust muốn làm cho các khái niệm hệ thống trở nên dễ tiếp cận hơn với nhiều người, đặc biệt là những người mới bắt đầu lập trình.

Các công ty

Hàng trăm công ty, lớn và nhỏ, sử dụng Rust trong sản xuất cho nhiều nhiệm vụ khác nhau, bao gồm các công cụ dòng lệnh, dịch vụ web, công cụ DevOps, thiết bị nhúng, phân tích và chuyển mã âm thanh và video, tiền điện tử, tin sinh học, công cụ tìm kiếm, ứng dụng Internet of Things, học máy, và thậm chí là các phần quan trọng của trình duyệt web Firefox.

Các nhà phát triển mã nguồn mở

Rust dành cho những người muốn xây dựng ngôn ngữ lập trình Rust, cộng đồng, công cụ phát triển và thư viện. Chúng tôi rất mong bạn đóng góp vào ngôn ngữ Rust.

Những người coi trọng tốc độ và sự ổn định

Rust dành cho những người khao khát tốc độ và sự ổn định trong một ngôn ngữ. Khi nói đến tốc độ, chúng tôi muốn nói về cả tốc độ chạy của mã Rust và tốc độ mà Rust cho phép bạn viết chương trình. Các kiểm tra của trình biên dịch Rust đảm bảo sự ổn định thông qua việc thêm tính năng và tái cấu trúc. Điều này trái ngược với mã cũ khó sửa đổi trong các ngôn ngữ không có các kiểm tra này, điều làm các nhà phát triển thường sợ sửa đổi. Bằng cách cố gắng đạt được các trừu tượng cấp cao không tốn kém biên dịch thành mã cấp thấp nhanh như mã viết thủ công - Rust cố gắng làm cho mã an toàn cũng trở thành mã nhanh.

Ngôn ngữ Rust hy vọng hỗ trợ nhiều người dùng khác nữa; những người được đề cập ở đây chỉ là một số bên liên quan nhất. Nhìn chung, tham vọng lớn nhất của Rust là loại bỏ các sự đánh đổi mà các lập trình viên đã chấp nhận trong nhiều thập kỷ bằng cách cung cấp sự an toàn năng suất, tốc độ tính tiện dụng. Hãy thử Rust và xem liệu các lựa chọn của nó có phù hợp với bạn không.

Cuốn sách này dành cho ai

Cuốn sách này giả định rằng bạn đã viết mã trong một ngôn ngữ lập trình khác nhưng không giả định bạn đã viết mã trong ngôn ngữ nào. Chúng tôi đã cố gắng làm cho tài liệu này dễ tiếp cận rộng rãi với những người có nền tảng lập trình đa dạng. Chúng tôi không dành nhiều thời gian để nói về lập trình là gì hoặc cách suy nghĩ về nó. Nếu bạn hoàn toàn mới với lập trình, bạn sẽ được phục vụ tốt hơn bằng cách đọc một cuốn sách cung cấp một giới thiệu về lập trình.

Cách sử dụng cuốn sách này

Nói chung, cuốn sách này giả định rằng bạn đang đọc nó theo thứ tự từ đầu đến cuối. Các chương sau xây dựng trên các khái niệm trong các chương trước, và các chương trước có thể không đi sâu vào chi tiết về một chủ đề cụ thể nhưng sẽ xem xét lại chủ đề đó trong một chương sau.

Bạn sẽ tìm thấy hai loại chương trong cuốn sách này: các chương khái niệm và các chương dự án. Trong các chương khái niệm, bạn sẽ học về một khía cạnh của Rust. Trong các chương dự án, chúng ta sẽ cùng nhau xây dựng các chương trình nhỏ, áp dụng những gì bạn đã học được cho đến nay. Chương 2, 12 và 21 là các chương dự án; phần còn lại là các chương khái niệm.

Chương 1 giải thích cách cài đặt Rust, cách viết một chương trình “Hello, world!”, và cách sử dụng Cargo, trình quản lý gói và công cụ xây dựng của Rust. Chương 2 là một phần giới thiệu thực hành về việc viết một chương trình trong Rust, cho bạn xây dựng một trò chơi đoán số. Ở đây chúng tôi đề cập đến các khái niệm ở mức cao, và các chương sau sẽ cung cấp thêm chi tiết. Nếu bạn muốn bắt tay vào làm ngay lập tức, Chương 2 là nơi dành cho bạn. Chương 3 đề cập đến các đặc điểm của Rust tương tự như các ngôn ngữ lập trình khác, và trong Chương 4 bạn sẽ học về hệ thống quyền sở hữu của Rust. Nếu bạn là một người học tỉ mỉ đặc biệt thích học mọi thứ chi tiết trước khi tiếp tục, bạn có thể muốn bỏ qua Chương 2 và đi thẳng đến Chương 3, quay lại Chương 2 khi bạn muốn làm việc trên một dự án áp dụng các chi tiết bạn đã học.

Chương 5 thảo luận về struct và phương thức, và Chương 6 đề cập đến enum, biểu thức match, và cấu trúc luồng điều khiển if let. Bạn sẽ sử dụng struct và enum để tạo các kiểu dữ liệu tùy chỉnh trong Rust.

Trong Chương 7, bạn sẽ học về hệ thống module của Rust và về các quy tắc bảo mật để tổ chức mã của bạn và Giao diện Lập trình Ứng dụng (API) công khai của nó. Chương 8 thảo luận về một số cấu trúc dữ liệu collection phổ biến mà thư viện tiêu chuẩn cung cấp, chẳng hạn như vector, chuỗi và hash map. Chương 9 khám phá triết lý và kỹ thuật xử lý lỗi của Rust.

Chương 10 đi sâu vào generics, traits và lifetimes, cho bạn khả năng để định nghĩa mã áp dụng cho nhiều kiểu dữ liệu. Chương 11 là tất cả về kiểm thử, mà ngay cả với các đảm bảo an toàn của Rust cũng cần thiết để đảm bảo logic chương trình của bạn là chính xác. Trong Chương 12, chúng ta sẽ triển khai xây dựng cho riêng mình cho một phần chức năng của công cụ dòng lệnh grep tìm kiếm văn bản trong các tệp. Để làm điều này, chúng ta sẽ sử dụng nhiều khái niệm đã thảo luận trong các chương trước.

Chương 13 khám phá closure và iterator: các đặc điểm của Rust đến từ các ngôn ngữ lập trình hàm. Trong Chương 14, chúng ta sẽ xem xét Cargo sâu hơn và nói về các thực hành tốt nhất để chia sẻ thư viện của bạn với người khác. Chương 15 thảo luận về các con trỏ thông minh mà thư viện tiêu chuẩn cung cấp và các traits cần thiết cho chức năng của chúng.

Trong Chương 16, chúng ta sẽ đi qua các mô hình lập trình đồng thời khác nhau và nói về cách Rust giúp bạn lập trình trong nhiều luồng một cách không sợ hãi. Trong Chương 17, chúng ta xây dựng trên đó bằng cách khám phá cú pháp async và await của Rust, cùng với các task, future và stream, và mô hình đồng thời nhẹ mà chúng cho phép.

Chương 18 xem xét cách các đặc trưng của Rust so sánh với các nguyên tắc lập trình hướng đối tượng mà bạn có thể quen thuộc. Chương 19 là một tài liệu tham khảo về mẫu và khớp mẫu, là những cách mạnh mẽ để biểu đạt ý tưởng trong suốt các chương trình Rust. Chương 20 chứa một loạt các chủ đề nâng cao thú vị, bao gồm Rust không an toàn, macro, và tìm hiểu sâu hơn về lifetimes, traits, kiểu dữ liệu, hàm và closure.

Trong Chương 21, chúng ta sẽ hoàn thành một dự án trong đó chúng ta sẽ triển khai một máy chủ web đa luồng cấp thấp!

Cuối cùng, một số phụ lục chứa thông tin hữu ích về ngôn ngữ theo định dạng tham khảo hơn. Phụ lục A đề cập đến các từ khóa của Rust, Phụ lục B đề cập đến các toán tử và ký hiệu của Rust, Phụ lục C đề cập đến các traits có thể được dẫn xuất do thư viện tiêu chuẩn cung cấp, Phụ lục D đề cập đến một số công cụ phát triển hữu ích, và Phụ lục E giải thích các phiên bản Rust. Trong Phụ lục F, bạn có thể tìm thấy các bản dịch của cuốn sách, và trong Phụ lục G chúng tôi sẽ đề cập đến cách Rust được tạo ra và Rust nightly là gì.

Không có cách nào sai để đọc cuốn sách này: nếu bạn muốn bỏ qua, hãy làm điều đó! Bạn có thể phải quay lại các chương trước nếu bạn gặp bất kỳ sự nhầm lẫn nào. Nhưng hãy làm bất cứ điều gì phù hợp với bạn.

Một phần quan trọng của quá trình học Rust là học cách đọc các thông báo lỗi mà trình biên dịch hiển thị: những thông báo này sẽ hướng dẫn bạn đến mã chạy được. Vì vậy, chúng tôi sẽ cung cấp nhiều ví dụ không biên dịch cùng với thông báo lỗi mà trình biên dịch sẽ hiển thị cho bạn trong mỗi tình huống. Hãy biết rằng nếu bạn nhập và chạy một ví dụ ngẫu nhiên, nó có thể không biên dịch! Hãy chắc chắn rằng bạn đọc văn bản xung quanh để xem liệu ví dụ bạn đang cố gắng chạy sẽ gây lỗi hay không. Ferris cũng sẽ giúp bạn phân biệt mã không chạy được:

FerrisÝ nghĩa
Ferris với dấu chấm hỏiMã này không biên dịch!
Ferris giơ tay lênMã này gây hoảng loạn! (panic!)
Ferris với một móng vuốt lên, nhún vaiMã này không tạo ra hành vi mong muốn.

Trong hầu hết các tình huống, chúng tôi sẽ dẫn bạn đến phiên bản chính xác của bất kỳ mã nào không biên dịch.

Mã nguồn

Các tệp mã nguồn mà từ đó cuốn sách này được tạo ra có thể được tìm thấy trên GitHub.

Bắt đầu

Hãy bắt đầu hành trình Rust của bạn! Có rất nhiều điều để học, nhưng mọi hành trình đều có điểm khởi đầu. Trong chương này, chúng ta sẽ thảo luận:

  • Cài đặt Rust trên Linux, macOS và Windows
  • Viết một chương trình in ra Hello, world!
  • Sử dụng cargo, trình quản lý gói và hệ thống build của Rust

Cài đặt

Bước đầu tiên là cài đặt Rust. Chúng ta sẽ tải Rust thông qua rustup, một công cụ dòng lệnh để quản lý các phiên bản Rust và các công cụ liên quan. Bạn sẽ cần kết nối internet để tải xuống.

Lưu ý: Nếu bạn không muốn sử dụng rustup vì một số lý do, vui lòng xem trang Phương pháp Cài đặt Rust khác để biết thêm lựa chọn.

Các bước sau đây cài đặt phiên bản ổn định mới nhất của trình biên dịch Rust. Sự đảm bảo về tính ổn định của Rust đảm bảo rằng tất cả các ví dụ trong sách này biên dịch được sẽ tiếp tục biên dịch được với các phiên bản Rust mới hơn. Đầu ra có thể khác nhau một chút giữa các phiên bản vì Rust thường cải thiện thông báo lỗi và cảnh báo. Nói cách khác, bất kỳ phiên bản Rust ổn định, mới hơn nào mà bạn cài đặt bằng các bước này sẽ hoạt động như mong đợi với nội dung của cuốn sách này.

Ký hiệu Dòng lệnh

Trong chương này và xuyên suốt cuốn sách, chúng tôi sẽ hiển thị một số lệnh được sử dụng trong terminal. Các dòng mà bạn nên nhập vào terminal đều bắt đầu bằng $. Bạn không cần phải gõ ký tự $; đó là dấu nhắc dòng lệnh được hiển thị để chỉ ra điểm bắt đầu của mỗi lệnh. Các dòng không bắt đầu bằng $ thường hiển thị đầu ra của lệnh trước đó. Ngoài ra, các ví dụ dành riêng cho PowerShell sẽ sử dụng > thay vì $.

Cài đặt rustup trên Linux hoặc macOS

Nếu bạn đang sử dụng Linux hoặc macOS, mở terminal và nhập lệnh sau:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Lệnh này tải xuống một script và bắt đầu cài đặt công cụ rustup, công cụ này sẽ cài đặt phiên bản ổn định mới nhất của Rust. Bạn có thể được yêu cầu nhập mật khẩu. Nếu cài đặt thành công, dòng sau sẽ xuất hiện:

Rust is installed now. Great!

Bạn cũng sẽ cần một linker, là một chương trình mà Rust sử dụng để kết hợp các đầu ra đã biên dịch thành một tệp. Có khả năng bạn đã có sẵn một cái. Nếu bạn gặp lỗi linker, bạn nên cài đặt một trình biên dịch C, thường sẽ bao gồm một linker. Một trình biên dịch C cũng hữu ích vì một số gói Rust phổ biến phụ thuộc vào mã C và sẽ cần một trình biên dịch C.

Trên macOS, bạn có thể có một trình biên dịch C bằng cách chạy:

$ xcode-select --install

Người dùng Linux nên cài đặt GCC hoặc Clang, theo tài liệu của bản phân phối của họ. Ví dụ, nếu bạn sử dụng Ubuntu, bạn có thể cài đặt gói build-essential.

Cài đặt rustup trên Windows

Trên Windows, truy cập https://www.rust-lang.org/tools/install và làm theo hướng dẫn để cài đặt Rust. Tại một số điểm trong quá trình cài đặt, bạn sẽ được yêu cầu cài đặt Visual Studio. Điều này cung cấp một linker và các thư viện gốc cần thiết để biên dịch chương trình. Nếu bạn cần thêm trợ giúp với bước này, xem https://rust-lang.github.io/rustup/installation/windows-msvc.html

Phần còn lại của cuốn sách này sử dụng các lệnh hoạt động trong cả cmd.exe và PowerShell. Nếu có sự khác biệt cụ thể, chúng tôi sẽ giải thích nên sử dụng cái nào.

Khắc phục sự cố

Để kiểm tra xem bạn đã cài đặt Rust đúng cách chưa, mở shell và nhập dòng này:

$ rustc --version

Bạn sẽ thấy số phiên bản, commit hash và ngày commit cho phiên bản ổn định mới nhất đã được phát hành, theo định dạng sau:

rustc x.y.z (abcabcabc yyyy-mm-dd)

Nếu bạn thấy thông tin này, bạn đã cài đặt Rust thành công! Nếu bạn không thấy thông tin này, kiểm tra xem Rust có trong biến hệ thống %PATH% của bạn như sau.

Trong Windows CMD, sử dụng:

> echo %PATH%

Trong PowerShell, sử dụng:

> echo $env:Path

Trong Linux và macOS, sử dụng:

$ echo $PATH

Nếu tất cả đều đúng và Rust vẫn không hoạt động, có một số nơi bạn có thể nhận trợ giúp. Tìm hiểu cách liên hệ với các Rustaceans khác (một biệt danh ngớ ngẩn mà chúng tôi tự gọi mình) trên trang cộng đồng.

Cập nhật và gỡ cài đặt

Khi Rust đã được cài đặt qua rustup, việc cập nhật lên phiên bản mới phát hành rất dễ dàng. Từ shell của bạn, chạy script cập nhật sau:

$ rustup update

Để gỡ cài đặt Rust và rustup, chạy script gỡ cài đặt sau từ shell của bạn:

$ rustup self uninstall

Tài liệu cục bộ

Việc cài đặt Rust cũng bao gồm một bản sao cục bộ của tài liệu để bạn có thể đọc nó khi không có kết nối internet. Chạy rustup doc để mở tài liệu cục bộ trong trình duyệt của bạn.

Bất cứ khi nào một kiểu dữ liệu hoặc hàm được cung cấp bởi thư viện tiêu chuẩn và bạn không chắc chắn nó làm gì hoặc cách sử dụng nó, hãy sử dụng tài liệu giao diện lập trình ứng dụng (API) để tìm hiểu!

Trình soạn thảo văn bản và Môi trường Phát triển Tích hợp

Cuốn sách này không đưa ra giả định về công cụ bạn sử dụng để viết mã Rust. Hầu hết các trình soạn thảo văn bản đều có thể hoàn thành công việc! Tuy nhiên, nhiều trình soạn thảo văn bản và môi trường phát triển tích hợp (IDEs) có hỗ trợ tích hợp cho Rust. Bạn luôn có thể tìm thấy danh sách khá cập nhật của nhiều trình soạn thảo và IDE trên trang công cụ trên trang web Rust.

Làm việc ngoại tuyến với cuốn sách này

Trong một số ví dụ, chúng tôi sẽ sử dụng các gói Rust ngoài thư viện tiêu chuẩn. Để làm việc qua các ví dụ đó, bạn sẽ cần có kết nối internet hoặc đã tải xuống các phụ thuộc đó trước. Để tải xuống các phụ thuộc trước, bạn có thể chạy các lệnh sau. (Chúng tôi sẽ giải thích cargo là gì và từng lệnh này làm gì chi tiết sau.)

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.8.5 trpl@0.2.0

Điều này sẽ lưu trữ các gói đã tải xuống để bạn không cần phải tải xuống chúng sau này. Sau khi bạn đã chạy lệnh này, bạn không cần giữ thư mục get-dependencies. Nếu bạn đã chạy lệnh này, bạn có thể sử dụng cờ --offline với tất cả các lệnh cargo trong phần còn lại của cuốn sách để sử dụng các phiên bản đã lưu trữ này thay vì cố gắng sử dụng mạng.

Xin chào, Thế giới!

Bây giờ bạn đã cài đặt Rust, đã đến lúc viết chương trình Rust đầu tiên của bạn. Theo truyền thống khi học một ngôn ngữ mới, chúng ta thường viết một chương trình nhỏ in ra dòng chữ Hello, world! lên màn hình, vì vậy chúng ta sẽ làm điều tương tự ở đây!

Lưu ý: Cuốn sách này giả định bạn đã có kiến thức cơ bản về dòng lệnh. Rust không đưa ra yêu cầu cụ thể nào về công cụ soạn thảo hoặc nơi lưu trữ mã của bạn, vì vậy nếu bạn thích sử dụng môi trường phát triển tích hợp (IDE) thay vì dòng lệnh, hãy tự nhiên sử dụng IDE yêu thích của bạn. Nhiều IDE hiện nay có một số mức độ hỗ trợ Rust; hãy kiểm tra tài liệu của IDE để biết chi tiết. Đội ngũ Rust đã tập trung vào việc hỗ trợ IDE thông qua rust-analyzer. Xem Phụ lục D để biết thêm chi tiết.

Tạo thư mục dự án

Bạn sẽ bắt đầu bằng việc tạo một thư mục để lưu trữ mã Rust của bạn. Đối với Rust, nơi lưu trữ mã của bạn không quan trọng, nhưng đối với các bài tập và dự án trong sách này, chúng tôi gợi ý tạo một thư mục projects trong thư mục home của bạn và lưu giữ tất cả các dự án của bạn ở đó.

Mở một terminal và nhập các lệnh sau để tạo một thư mục projects và một thư mục cho dự án “Hello, world!” trong thư mục projects.

Đối với Linux, macOS và PowerShell trên Windows, nhập:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

Đối với Windows CMD, nhập:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Viết và chạy một chương trình Rust

Tiếp theo, tạo một tệp nguồn mới và gọi nó là main.rs. Các tệp Rust luôn kết thúc bằng phần mở rộng .rs. Nếu bạn sử dụng nhiều hơn một từ trong tên tệp của mình, quy ước là sử dụng dấu gạch dưới để tách chúng. Ví dụ, sử dụng hello_world.rs thay vì helloworld.rs.

Bây giờ mở tệp main.rs mà bạn vừa tạo và nhập mã trong Listing 1-1.

fn main() {
    println!("Hello, world!");
}

Lưu tệp và quay lại cửa sổ terminal của bạn trong thư mục ~/projects/hello_world. Trên Linux hoặc macOS, nhập các lệnh sau để biên dịch và chạy tệp:

$ rustc main.rs
$ ./main
Hello, world!

Trên Windows, nhập lệnh .\main thay vì ./main:

> rustc main.rs
> .\main
Hello, world!

Bất kể hệ điều hành của bạn là gì, chuỗi Hello, world! sẽ được in ra terminal. Nếu bạn không thấy đầu ra này, hãy tham khảo lại phần “Khắc phục sự cố” của phần Cài đặt để biết cách nhận trợ giúp.

Nếu Hello, world! đã được in ra, chúc mừng bạn! Bạn đã chính thức viết một chương trình Rust. Điều đó làm cho bạn trở thành một lập trình viên Rust — chào mừng!

Cấu trúc của một chương trình Rust

Hãy xem xét chi tiết chương trình “Hello, world!” này. Đây là mảnh ghép đầu tiên:

fn main() {

}

Những dòng này định nghĩa một hàm tên là main. Hàm main là một hàm đặc biệt: nó luôn là mã đầu tiên chạy trong mọi chương trình Rust thực thi. Ở đây, dòng đầu tiên khai báo một hàm tên là main không có tham số và không trả về gì. Nếu có tham số, chúng sẽ nằm trong dấu ngoặc đơn ().

Thân hàm được bao bọc trong {}. Rust yêu cầu dấu ngoặc nhọn xung quanh tất cả thân hàm. Đó là phong cách tốt để đặt dấu ngoặc nhọn mở trên cùng một dòng với khai báo hàm, thêm một khoảng trắng ở giữa.

Lưu ý: Nếu bạn muốn tuân theo một phong cách chuẩn trong các dự án Rust, bạn có thể sử dụng công cụ định dạng tự động gọi là rustfmt để định dạng mã của bạn theo một phong cách cụ thể (đọc thêm về rustfmt trong Phụ lục D). Đội ngũ phát triển Rust đã bao gồm công cụ này trong bản phân phối Rust tiêu chuẩn, giống như rustc, vì vậy nó có thể đã được cài đặt trên máy tính của bạn!

Thân hàm main chứa mã sau:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

Dòng này thực hiện tất cả công việc trong chương trình nhỏ này: nó in văn bản lên màn hình. Có ba chi tiết quan trọng cần chú ý ở đây.

Đầu tiên, println! là một macro của Rust. Thay vì nếu nó được gọi là một hàm, nó sẽ được nhập là println (không có !). Các macro của Rust là một cách để viết mã tạo ra mã để mở rộng cú pháp Rust, và chúng ta sẽ thảo luận chi tiết hơn về chúng trong Chương 20. Hiện tại, bạn chỉ cần biết rằng việc sử dụng ! có nghĩa là bạn đang gọi một macro thay vì một hàm bình thường và các macro không luôn tuân theo các quy tắc giống như các hàm.

Thứ hai, bạn thấy chuỗi "Hello, world!". Chúng ta truyền chuỗi này làm đối số cho println!, và chuỗi được in ra màn hình.

Thứ ba, chúng ta kết thúc dòng bằng dấu chấm phẩy (;), điều này cho biết rằng biểu thức này đã kết thúc và biểu thức tiếp theo đã sẵn sàng để bắt đầu. Hầu hết các dòng mã Rust kết thúc bằng dấu chấm phẩy.

Biên dịch và chạy là các bước riêng biệt

Bạn vừa chạy một chương trình mới tạo, vì vậy hãy xem xét từng bước trong quá trình.

Trước khi chạy một chương trình Rust, bạn phải biên dịch nó bằng trình biên dịch Rust bằng cách nhập lệnh rustc và truyền cho nó tên tệp nguồn của bạn, như thế này:

$ rustc main.rs

Nếu bạn có nền tảng C hoặc C++, bạn sẽ nhận thấy điều này tương tự như gcc hoặc clang. Sau khi biên dịch thành công, Rust xuất ra một tệp thực thi nhị phân.

Trên Linux, macOS và PowerShell trên Windows, bạn có thể thấy tệp thực thi bằng cách nhập lệnh ls trong shell của bạn:

$ ls
main  main.rs

Trên Linux và macOS, bạn sẽ thấy hai tệp. Với PowerShell trên Windows, bạn sẽ thấy ba tệp giống như bạn sẽ thấy khi sử dụng CMD. Với CMD trên Windows, bạn sẽ nhập lệnh sau:

> dir /B %= tùy chọn /B chỉ hiển thị tên tệp =%
main.exe
main.pdb
main.rs

Điều này hiển thị tệp mã nguồn với phần mở rộng .rs, tệp thực thi (main.exe trên Windows, nhưng main trên tất cả các nền tảng khác), và, khi sử dụng Windows, một tệp chứa thông tin gỡ lỗi với phần mở rộng .pdb. Từ đây, bạn chạy tệp main hoặc main.exe, như sau:

$ ./main # hoặc .\main trên Windows

Nếu main.rs của bạn là chương trình “Hello, world!” của bạn, dòng này sẽ in Hello, world! lên terminal của bạn.

Nếu bạn quen thuộc hơn với một ngôn ngữ động, chẳng hạn như Ruby, Python hoặc JavaScript, bạn có thể không quen với việc biên dịch và chạy một chương trình như các bước riêng biệt. Rust là một ngôn ngữ biên dịch trước (ahead-of-time compiled), có nghĩa là bạn có thể biên dịch một chương trình và đưa tệp thực thi cho người khác, và họ có thể chạy nó ngay cả khi không cài đặt Rust. Nếu bạn đưa cho ai đó một tệp .rb, .py hoặc .js, họ cần phải có một triển khai Ruby, Python hoặc JavaScript tương ứng được cài đặt. Nhưng trong những ngôn ngữ đó, bạn chỉ cần một lệnh để biên dịch và chạy chương trình của bạn. Mọi thứ đều là sự đánh đổi trong thiết kế ngôn ngữ.

Chỉ biên dịch với rustc là đủ cho các chương trình đơn giản, nhưng khi dự án của bạn phát triển, bạn sẽ muốn quản lý tất cả các tùy chọn và làm cho việc chia sẻ mã của bạn trở nên dễ dàng. Tiếp theo, chúng tôi sẽ giới thiệu cho bạn công cụ Cargo, công cụ sẽ giúp bạn viết các chương trình Rust thực tế.

Xin chào, Cargo!

Cargo là hệ thống build và quản lý gói của Rust. Hầu hết các Rustacean (lập trình viên Rust) đều sử dụng công cụ này để quản lý các dự án Rust vì Cargo xử lý nhiều tác vụ cho bạn, chẳng hạn như xây dựng code, tải xuống các thư viện mà code của bạn phụ thuộc và build các thư viện đó. (Chúng tôi gọi các thư viện mà code của bạn cần là dependencies - các phụ thuộc.)

Các chương trình Rust đơn giản nhất, như cái chúng ta đã viết cho đến giờ, không có bất kỳ phụ thuộc nào. Nếu chúng ta xây dựng dự án "Hello, world!" với Cargo, nó sẽ chỉ sử dụng một phần của Cargo cho việc xử lý build code của bạn. Khi bạn viết các chương trình Rust phức tạp hơn, bạn sẽ thêm các phụ thuộc, và nếu bạn bắt đầu một dự án bằng Cargo, việc thêm phụ thuộc sẽ dễ dàng hơn nhiều.

Vì phần lớn các dự án Rust sử dụng Cargo, phần còn lại của cuốn sách này giả định rằng bạn cũng đang sử dụng Cargo. Cargo được cài đặt cùng với Rust nếu bạn đã sử dụng trình cài đặt chính thức được thảo luận trong phần "Cài đặt". Nếu bạn đã cài đặt Rust thông qua một số phương tiện khác, hãy kiểm tra xem Cargo đã được cài đặt chưa bằng cách nhập lệnh sau vào terminal của bạn:

$ cargo --version

Nếu bạn thấy một số phiên bản, bạn đã có nó! Nếu bạn thấy một lỗi, chẳng hạn như command not found, hãy xem tài liệu cho phương thức cài đặt của bạn để xác định cách cài đặt Cargo riêng biệt.

Tạo một dự án với Cargo

Hãy tạo một dự án mới sử dụng Cargo và xem nó khác với dự án "Hello, world!" ban đầu của chúng ta như thế nào. Quay lại thư mục projects của bạn (hoặc bất cứ nơi nào bạn đã quyết định lưu trữ code). Sau đó, trên bất kỳ hệ điều hành nào, chạy lệnh sau:

$ cargo new hello_cargo
$ cd hello_cargo

Lệnh đầu tiên tạo một thư mục và dự án mới có tên hello_cargo. Chúng ta đã đặt tên dự án là hello_cargo, và Cargo tạo các tệp của nó trong một thư mục cùng tên.

Đi vào thư mục hello_cargo và liệt kê các tệp. Bạn sẽ thấy rằng Cargo đã tạo hai tệp và một thư mục cho chúng ta: một tệp Cargo.toml và một thư mục src với một tệp main.rs bên trong.

Nó cũng đã khởi tạo một repository Git mới cùng với tệp .gitignore. Các tệp Git sẽ không được tạo nếu bạn chạy cargo new trong một Git repository đã tồn tại; bạn có thể ghi đè hành vi này bằng cách sử dụng cargo new --vcs=git.

Lưu ý: Git là một hệ thống quản lý phiên bản phổ biến. Bạn có thể thay đổi cargo new để sử dụng một hệ thống quản lý phiên bản khác hoặc không sử dụng hệ thống quản lý phiên bản nào bằng cách sử dụng cờ --vcs. Chạy cargo new --help để xem các tùy chọn có sẵn.

Mở Cargo.toml trong trình soạn thảo văn bản bạn chọn. Nó sẽ trông giống với mã trong Listing 1-2.

[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]

Tệp này có định dạng TOML (Tom's Obvious, Minimal Language), đây là định dạng cấu hình của Cargo.

Dòng đầu tiên, [package], là một tiêu đề phần cho biết rằng các câu lệnh tiếp theo đang cấu hình một gói. Khi chúng ta thêm thông tin vào tệp này, chúng ta sẽ thêm các phần khác.

Ba dòng tiếp theo thiết lập thông tin cấu hình mà Cargo cần để biên dịch chương trình của bạn: tên, phiên bản và phiên bản của Rust để sử dụng. Chúng ta sẽ nói về khóa edition trong Phụ lục E.

Dòng cuối cùng, [dependencies], là phần bắt đầu cho bạn liệt kê bất kỳ phụ thuộc nào của dự án. Trong Rust, các gói mã được gọi là crates. Chúng ta sẽ không cần bất kỳ crate nào khác cho dự án này, nhưng chúng ta sẽ cần trong dự án đầu tiên ở Chương 2, vì vậy chúng ta sẽ sử dụng phần phụ thuộc này sau.

Bây giờ mở src/main.rs và xem qua:

Tên tệp: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo đã tạo một chương trình "Hello, world!" cho bạn, giống như cái mà chúng ta đã viết trong Listing 1-1! Cho đến nay, sự khác biệt giữa dự án của chúng ta và dự án mà Cargo đã tạo là Cargo đặt mã trong thư mục src và chúng ta có một tệp cấu hình Cargo.toml ở thư mục cấp cao nhất.

Cargo mong muốn các tệp mã nguồn của bạn nằm trong thư mục src. Thư mục dự án cấp cao nhất chỉ dành cho các tệp README, thông tin giấy phép, các tệp cấu hình và bất kỳ thứ gì khác không liên quan đến mã của bạn. Sử dụng Cargo giúp bạn tổ chức các dự án. Có một vị trí cho mọi thứ, và mọi thứ đều ở đúng vị trí.

Nếu bạn đã bắt đầu một dự án không sử dụng Cargo, như chúng ta đã làm với dự án "Hello, world!", bạn có thể chuyển đổi nó thành một dự án sử dụng Cargo. Di chuyển mã dự án vào thư mục src và tạo một tệp Cargo.toml thích hợp. Một cách dễ dàng để có được tệp Cargo.toml đó là chạy cargo init, nó sẽ tạo tự động cho bạn.

Xây dựng và chạy một dự án Cargo

Bây giờ hãy xem có gì khác khi chúng ta xây dựng và chạy chương trình "Hello, world!" với Cargo! Từ thư mục hello_cargo của bạn, xây dựng dự án của bạn bằng cách nhập lệnh sau:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

Lệnh này tạo một tệp thực thi trong target/debug/hello_cargo (hoặc target\debug\hello_cargo.exe trên Windows) thay vì trong thư mục hiện tại của bạn. Vì bản dựng mặc định là bản dựng debug, Cargo đặt tệp nhị phân vào một thư mục có tên debug. Bạn có thể chạy tệp thực thi bằng lệnh này:

$ ./target/debug/hello_cargo # hoặc .\target\debug\hello_cargo.exe trên Windows
Hello, world!

Nếu mọi việc suôn sẻ, Hello, world! sẽ được in ra terminal. Chạy cargo build lần đầu tiên cũng khiến Cargo tạo một tệp mới ở cấp cao nhất: Cargo.lock. Tệp này theo dõi các phiên bản chính xác của các phụ thuộc trong dự án của bạn. Dự án này không có phụ thuộc, vì vậy tệp hơi thưa thớt. Bạn sẽ không bao giờ cần thay đổi tệp này theo cách thủ công; Cargo quản lý nội dung của nó cho bạn.

Chúng ta vừa xây dựng một dự án với cargo build và chạy nó với ./target/debug/hello_cargo, nhưng chúng ta cũng có thể sử dụng cargo run để biên dịch mã và sau đó chạy tệp thực thi kết quả tất cả trong một lệnh:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

Sử dụng cargo run thuận tiện hơn việc phải nhớ chạy cargo build và sau đó sử dụng toàn bộ đường dẫn đến tệp nhị phân, vì vậy hầu hết các nhà phát triển sử dụng cargo run.

Lưu ý rằng lần này chúng ta không thấy đầu ra cho biết rằng Cargo đang biên dịch hello_cargo. Cargo nhận ra rằng các tệp không thay đổi, vì vậy nó đã không xây dựng lại mà chỉ chạy tệp nhị phân. Nếu bạn đã sửa đổi mã nguồn, Cargo sẽ xây dựng lại dự án trước khi chạy nó, và bạn sẽ thấy đầu ra này:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo cũng cung cấp một lệnh có tên là cargo check. Lệnh này nhanh chóng kiểm tra mã của bạn để đảm bảo nó biên dịch được nhưng không tạo ra tệp thực thi:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

Tại sao bạn lại không muốn có một tệp thực thi? Thông thường, cargo check nhanh hơn nhiều so với cargo build vì nó bỏ qua bước tạo ra tệp thực thi. Nếu bạn đang liên tục kiểm tra công việc của mình trong khi viết mã, việc sử dụng cargo check sẽ tăng tốc quá trình cho bạn biết liệu dự án của bạn vẫn đang biên dịch hay không! Như vậy, nhiều Rustacean chạy cargo check định kỳ khi họ viết chương trình của họ để đảm bảo nó biên dịch. Sau đó, họ chạy cargo build khi họ sẵn sàng sử dụng tệp thực thi.

Hãy tổng kết những gì chúng ta đã học được cho đến nay về Cargo:

  • Chúng ta có thể tạo một dự án bằng cargo new.
  • Chúng ta có thể xây dựng một dự án bằng cargo build.
  • Chúng ta có thể xây dựng và chạy một dự án trong một bước bằng cargo run.
  • Chúng ta có thể xây dựng một dự án mà không tạo ra tệp nhị phân để kiểm tra lỗi bằng cargo check.
  • Thay vì lưu kết quả của bản dựng trong cùng thư mục với mã của chúng ta, Cargo lưu trữ nó trong thư mục target/debug.

Một lợi ích bổ sung của việc sử dụng Cargo là các lệnh giống nhau bất kể bạn đang làm việc trên hệ điều hành nào. Vì vậy, tại thời điểm này, chúng tôi sẽ không cung cấp hướng dẫn cụ thể cho Linux và macOS so với Windows nữa.

Build cho release (phát hành)

Khi dự án của bạn cuối cùng đã sẵn sàng để phát hành, bạn có thể sử dụng cargo build --release để biên dịch nó với các tối ưu hóa. Lệnh này sẽ tạo một tệp thực thi trong target/release thay vì target/debug. Các tối ưu hóa làm cho mã Rust của bạn chạy nhanh hơn, nhưng việc bật chúng làm kéo dài thời gian biên dịch chương trình. Đây là lý do tại sao có hai hồ sơ khác nhau: một cho phát triển, khi bạn muốn xây dựng lại nhanh chóng và thường xuyên, và một khác để xây dựng chương trình cuối cùng mà bạn sẽ giao cho người dùng, sẽ không được xây dựng lại nhiều lần và sẽ chạy nhanh nhất có thể. Nếu bạn đang đo hiệu suất thời gian chạy của mã, hãy chắc chắn chạy cargo build --release và đo hiệu suất với tệp thực thi trong target/release.

Cargo như là một quy chuẩn

Với các dự án đơn giản, Cargo không cung cấp nhiều giá trị hơn so với chỉ sử dụng rustc, nhưng nó sẽ chứng minh giá trị của nó khi chương trình của bạn trở nên phức tạp hơn. Khi chương trình phát triển thành nhiều tệp hoặc cần một phụ thuộc, thì sẽ dễ dàng hơn nhiều để Cargo phối hợp việc xây dựng.

Mặc dù dự án hello_cargo đơn giản, nhưng bây giờ nó sử dụng nhiều công cụ thực tế mà bạn sẽ sử dụng trong phần còn lại của sự nghiệp Rust của mình. Trên thực tế, để làm việc trên bất kỳ dự án hiện có nào, bạn có thể sử dụng các lệnh sau để kiểm tra mã sử dụng Git, thay đổi thành thư mục của dự án đó, và xây dựng:

$ git clone example.org/someproject
$ cd someproject
$ cargo build

Để biết thêm thông tin về Cargo, hãy xem tài liệu của nó.

Tóm tắt

Bạn đã có một khởi đầu tuyệt vời cho hành trình Rust của mình! Trong chương này, bạn đã học cách:

  • Cài đặt phiên bản Rust ổn định mới nhất sử dụng rustup
  • Cập nhật lên phiên bản Rust mới hơn
  • Mở tài liệu đã cài đặt cục bộ
  • Viết và chạy chương trình "Hello, world!" sử dụng rustc trực tiếp
  • Tạo và chạy một dự án mới sử dụng các quy ước của Cargo

Đây là thời điểm tuyệt vời để xây dựng một chương trình đáng kể hơn để làm quen với việc đọc và viết mã Rust. Vì vậy, trong Chương 2, chúng ta sẽ xây dựng một chương trình trò chơi đoán số. Nếu bạn thích bắt đầu bằng cách học cách các khái niệm lập trình phổ biến hoạt động trong Rust, hãy xem Chương 3 và sau đó quay lại Chương 2.

Lập Trình Một Trò Chơi Đoán Số

Hãy cùng bắt đầu làm quen với Rust thông qua một dự án thực tế! Chương này sẽ giới thiệu cho bạn một số khái niệm phổ biến trong Rust bằng cách hướng dẫn bạn sử dụng chúng trong một chương trình thực tế. Bạn sẽ học về let, match, phương thức, hàm liên kết, crate bên ngoài và nhiều hơn nữa! Trong các chương tiếp theo, chúng ta sẽ khám phá những ý tưởng này chi tiết hơn. Trong chương này, bạn chỉ cần thực hành những kiến thức cơ bản.

Chúng ta sẽ triển khai một bài tập lập trình cổ điển dành cho người mới bắt đầu: trò chơi đoán số. Trò chơi hoạt động như sau: chương trình sẽ tạo ra một số nguyên ngẫu nhiên từ 1 đến 100. Sau đó, chương trình sẽ yêu cầu người chơi nhập vào một số để đoán. Sau khi nhập số đoán, chương trình sẽ cho biết số đoán đó là quá nhỏ hay quá lớn. Nếu số đoán chính xác, trò chơi sẽ in ra thông báo chúc mừng và kết thúc.

Thiết Lập Một Dự Án Mới

Để thiết lập một dự án mới, hãy đi đến thư mục projects mà bạn đã tạo trong Chương 1 và tạo một dự án mới bằng Cargo, như sau:

$ cargo new guessing_game
$ cd guessing_game

Lệnh đầu tiên, cargo new, lấy tên của dự án (guessing_game) làm đối số đầu tiên. Lệnh thứ hai sẽ chuyển đến thư mục của dự án mới.

Hãy xem file Cargo.toml đã được tạo ra:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

Như bạn đã thấy trong Chương 1, cargo new tạo ra một chương trình “Hello, world!” cho bạn. Hãy xem file src/main.rs:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

Bây giờ hãy biên dịch chương trình “Hello, world!” này và chạy nó trong cùng một bước bằng cách sử dụng lệnh cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/guessing_game`
Hello, world!

Lệnh run rất hữu ích khi bạn cần lặp lại nhanh chóng trên một dự án, như chúng ta sẽ làm trong trò chơi này, nhanh chóng kiểm tra từng lần lặp trước khi chuyển sang lần tiếp theo.

Mở lại file src/main.rs. Bạn sẽ viết tất cả mã trong file này.

Xử Lý Một Số Đoán

Phần đầu tiên của chương trình trò chơi đoán số sẽ yêu cầu người dùng nhập vào, xử lý đầu vào đó và kiểm tra xem đầu vào có đúng dạng mong đợi hay không. Để bắt đầu, chúng ta sẽ cho phép người chơi nhập vào một số đoán. Nhập mã trong Listing 2-1 vào src/main.rs.

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Mã này chứa rất nhiều thông tin, vì vậy hãy đi qua từng dòng một. Để lấy đầu vào từ người dùng và sau đó in kết quả ra màn hình, chúng ta cần đưa thư viện đầu vào/đầu ra io vào phạm vi. Thư viện io đến từ thư viện tiêu chuẩn, được gọi là std:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Theo mặc định, Rust có một tập hợp các mục được định nghĩa trong thư viện tiêu chuẩn mà nó đưa vào phạm vi của mọi chương trình. Tập hợp này được gọi là prelude, và bạn có thể xem tất cả trong tài liệu thư viện tiêu chuẩn.

Nếu một kiểu bạn muốn sử dụng không có trong prelude, bạn phải đưa kiểu đó vào phạm vi rõ ràng bằng một câu lệnh use. Sử dụng thư viện std::io cung cấp cho bạn một số tính năng hữu ích, bao gồm khả năng chấp nhận đầu vào từ người dùng.

Như bạn đã thấy trong Chương 1, hàm main là điểm vào của chương trình:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Cú pháp fn khai báo một hàm mới; dấu ngoặc đơn, (), chỉ ra rằng không có tham số; và dấu ngoặc nhọn, {, bắt đầu thân của hàm.

Như bạn cũng đã học trong Chương 1, println! là một macro in một chuỗi ra màn hình:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Mã này đang in ra một lời nhắc cho biết trò chơi là gì và yêu cầu đầu vào từ người dùng.

Lưu Trữ Giá Trị Với Biến

Tiếp theo, chúng ta sẽ tạo một biến để lưu trữ đầu vào từ người dùng, như sau:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Bây giờ chương trình đang trở nên thú vị! Có rất nhiều điều đang diễn ra trong dòng nhỏ này. Chúng ta sử dụng câu lệnh let để tạo biến. Đây là một ví dụ khác:

let apples = 5;

Dòng này tạo ra một biến mới có tên là apples và gán nó với giá trị 5. Trong Rust, các biến mặc định là không thay đổi, có nghĩa là một khi chúng ta gán giá trị cho biến, giá trị đó sẽ không thay đổi. Chúng ta sẽ thảo luận chi tiết về khái niệm này trong phần “Biến và Tính Không Thay Đổi” trong Chương 3. Để làm cho một biến có thể thay đổi, chúng ta thêm mut trước tên biến:

let apples = 5; // không thay đổi
let mut bananas = 5; // có thể thay đổi

Lưu ý: Cú pháp // bắt đầu một bình luận kéo dài đến cuối dòng. Rust bỏ qua mọi thứ trong bình luận. Chúng ta sẽ thảo luận chi tiết về bình luận trong Chương 3.

Quay lại chương trình trò chơi đoán số, bây giờ bạn đã biết rằng let mut guess sẽ giới thiệu một biến có thể thay đổi có tên là guess. Dấu bằng (=) cho Rust biết chúng ta muốn gán một cái gì đó cho biến ngay bây giờ. Ở bên phải của dấu bằng là giá trị mà guess được gán, đó là kết quả của việc gọi String::new, một hàm trả về một phiên bản mới của một String. String là một kiểu chuỗi được cung cấp bởi thư viện tiêu chuẩn, là một đoạn văn bản có thể mở rộng, được mã hóa UTF-8.

Cú pháp :: trong dòng ::new chỉ ra rằng new là một hàm liên kết của kiểu String. Một hàm liên kết là một hàm được triển khai trên một kiểu, trong trường hợp này là String. Hàm new này tạo ra một chuỗi mới, trống. Bạn sẽ tìm thấy một hàm new trên nhiều kiểu vì nó là một tên phổ biến cho một hàm tạo ra một giá trị mới của một loại nào đó.

Tóm lại, dòng let mut guess = String::new(); đã tạo ra một biến có thể thay đổi hiện đang được gán với một phiên bản mới, trống của một String. Whew!

Nhận Đầu Vào Từ Người Dùng

Nhớ lại rằng chúng ta đã bao gồm chức năng đầu vào/đầu ra từ thư viện tiêu chuẩn với use std::io; ở dòng đầu tiên của chương trình. Bây giờ chúng ta sẽ gọi hàm stdin từ module io, điều này sẽ cho phép chúng ta xử lý đầu vào từ người dùng:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Nếu chúng ta không nhập module io với use std::io; ở đầu chương trình, chúng ta vẫn có thể sử dụng hàm này bằng cách viết lời gọi hàm này là std::io::stdin. Hàm stdin trả về một phiên bản của std::io::Stdin, là một kiểu đại diện cho một tay cầm đến đầu vào tiêu chuẩn cho terminal của bạn.

Tiếp theo, dòng .read_line(&mut guess) gọi phương thức read_line trên tay cầm đầu vào tiêu chuẩn để lấy đầu vào từ người dùng. Chúng ta cũng đang truyền &mut guess làm đối số cho read_line để cho nó biết chuỗi nào để lưu trữ đầu vào từ người dùng. Công việc đầy đủ của read_line là lấy bất cứ thứ gì người dùng nhập vào đầu vào tiêu chuẩn và thêm vào một chuỗi (mà không ghi đè nội dung của nó), vì vậy chúng ta truyền chuỗi đó làm đối số. Đối số chuỗi cần phải có thể thay đổi để phương thức có thể thay đổi nội dung của chuỗi.

Dấu & chỉ ra rằng đối số này là một tham chiếu, điều này cho bạn một cách để cho nhiều phần của mã truy cập vào một phần dữ liệu mà không cần phải sao chép dữ liệu đó vào bộ nhớ nhiều lần. Tham chiếu là một tính năng phức tạp, và một trong những lợi thế lớn của Rust là cách sử dụng tham chiếu an toàn và dễ dàng. Bạn không cần phải biết nhiều chi tiết đó để hoàn thành chương trình này. Hiện tại, tất cả những gì bạn cần biết là, giống như các biến, các tham chiếu mặc định là không thay đổi. Do đó, bạn cần viết &mut guess thay vì &guess để làm cho nó có thể thay đổi. (Chương 4 sẽ giải thích tham chiếu chi tiết hơn.)

Xử Lý Khả Năng Thất Bại Với Result

Chúng ta vẫn đang làm việc trên dòng mã này. Chúng ta hiện đang thảo luận về dòng văn bản thứ ba, nhưng lưu ý rằng nó vẫn là một phần của một dòng mã logic duy nhất. Phần tiếp theo là phương thức này:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Chúng ta có thể đã viết mã này như sau:

io::stdin().read_line(&mut guess).expect("Failed to read line");

Tuy nhiên, một dòng dài rất khó đọc, vì vậy tốt nhất là chia nó ra. Thường thì nên giới thiệu một dòng mới và các khoảng trắng khác để giúp chia nhỏ các dòng dài khi bạn gọi một phương thức với cú pháp .method_name(). Bây giờ hãy thảo luận về những gì dòng này làm.

Như đã đề cập trước đó, read_line đặt bất cứ thứ gì người dùng nhập vào chuỗi mà chúng ta truyền cho nó, nhưng nó cũng trả về một giá trị Result. Result là một liệt kê, thường được gọi là enum, là một kiểu có thể ở một trong nhiều trạng thái có thể. Chúng ta gọi mỗi trạng thái có thể là một biến thể.

Chương 6 sẽ bao gồm các enum chi tiết hơn. Mục đích của các kiểu Result này là để mã hóa thông tin xử lý lỗi.

Các biến thể của ResultOkErr. Biến thể Ok chỉ ra rằng hoạt động đã thành công, và nó chứa giá trị được tạo ra thành công. Biến thể Err có nghĩa là hoạt động đã thất bại, và nó chứa thông tin về cách hoặc lý do tại sao hoạt động thất bại.

Các giá trị của kiểu Result, giống như các giá trị của bất kỳ kiểu nào, có các phương thức được định nghĩa trên chúng. Một phiên bản của Result có một phương thức expect mà bạn có thể gọi. Nếu phiên bản này của Result là một giá trị Err, expect sẽ làm cho chương trình bị sập và hiển thị thông báo mà bạn đã truyền làm đối số cho expect. Nếu phương thức read_line trả về một Err, nó có thể là kết quả của một lỗi đến từ hệ điều hành cơ bản. Nếu phiên bản này của Result là một giá trị Ok, expect sẽ lấy giá trị trả về mà Ok đang giữ và trả về chỉ giá trị đó cho bạn để bạn có thể sử dụng nó. Trong trường hợp này, giá trị đó là số byte trong đầu vào của người dùng.

Nếu bạn không gọi expect, chương trình sẽ biên dịch, nhưng bạn sẽ nhận được một cảnh báo:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = 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
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust cảnh báo rằng bạn chưa sử dụng giá trị Result trả về từ read_line, chỉ ra rằng chương trình chưa xử lý một lỗi có thể xảy ra.

Cách đúng để loại bỏ cảnh báo là thực sự viết mã xử lý lỗi, nhưng trong trường hợp của chúng ta, chúng ta chỉ muốn làm cho chương trình này bị sập khi có vấn đề xảy ra, vì vậy chúng ta có thể sử dụng expect. Bạn sẽ học về cách khôi phục từ lỗi trong Chương 9.

In Giá Trị Với println! Placeholder

Ngoài dấu ngoặc nhọn đóng, chỉ còn một dòng nữa để thảo luận trong mã cho đến nay:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Dòng này in ra chuỗi hiện chứa đầu vào của người dùng. Bộ {} của dấu ngoặc nhọn là một placeholder: hãy nghĩ về {} như những cái kẹp nhỏ giữ một giá trị tại chỗ. Khi in giá trị của một biến, tên biến có thể được đặt bên trong dấu ngoặc nhọn. Khi in kết quả của việc đánh giá một biểu thức, đặt dấu ngoặc nhọn trống trong chuỗi định dạng, sau đó theo sau chuỗi định dạng với một danh sách các biểu thức được phân tách bằng dấu phẩy để in trong mỗi placeholder dấu ngoặc nhọn trống theo cùng thứ tự. In một biến và kết quả của một biểu thức trong một lần gọi println! sẽ trông như thế này:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);
}

Mã này sẽ in x = 5 and y + 2 = 12.

Kiểm Tra Phần Đầu Tiên

Hãy kiểm tra phần đầu tiên của trò chơi đoán số. Chạy nó bằng cách sử dụng cargo run:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

Tại thời điểm này, phần đầu tiên của trò chơi đã hoàn thành: chúng ta đang nhận đầu vào từ bàn phím và sau đó in nó ra.

Tạo Một Số Bí Mật

Tiếp theo, chúng ta cần tạo ra một số bí mật mà người dùng sẽ cố gắng đoán. Số bí mật nên khác nhau mỗi lần để trò chơi thú vị hơn khi chơi nhiều lần. Chúng ta sẽ sử dụng một số ngẫu nhiên từ 1 đến 100 để trò chơi không quá khó. Rust chưa bao gồm chức năng tạo số ngẫu nhiên trong thư viện tiêu chuẩn của nó. Tuy nhiên, nhóm Rust cung cấp một crate rand với chức năng này.

Sử Dụng Một Crate Để Có Thêm Chức Năng

Nhớ rằng một crate là một tập hợp các file mã nguồn Rust. Dự án mà chúng ta đã xây dựng là một binary crate, là một tệp thực thi. Crate rand là một library crate, chứa mã được sử dụng trong các chương trình khác và không thể thực thi độc lập.

Sự phối hợp của Cargo với các crate bên ngoài là nơi Cargo thực sự tỏa sáng. Trước khi chúng ta có thể viết mã sử dụng rand, chúng ta cần sửa đổi file Cargo.toml để bao gồm crate rand làm phụ thuộc. Mở file đó ngay bây giờ và thêm dòng sau vào cuối, bên dưới phần tiêu đề [dependencies] mà Cargo đã tạo cho bạn. Hãy chắc chắn chỉ định rand chính xác như chúng tôi đã làm ở đây, với số phiên bản này, hoặc các ví dụ mã trong hướng dẫn này có thể không hoạt động:

Filename: Cargo.toml

[dependencies]
rand = "0.8.5"

Trong file Cargo.toml, mọi thứ theo sau một tiêu đề là một phần của phần đó tiếp tục cho đến khi một phần khác bắt đầu. Trong [dependencies] bạn nói với Cargo những crate bên ngoài mà dự án của bạn phụ thuộc vào và các phiên bản của các crate đó mà bạn yêu cầu. Trong trường hợp này, chúng ta chỉ định crate rand với chỉ định phiên bản ngữ nghĩa 0.8.5. Cargo hiểu Semantic Versioning (đôi khi được gọi là SemVer), là một tiêu chuẩn để viết số phiên bản. Chỉ định 0.8.5 thực sự là viết tắt của ^0.8.5, có nghĩa là bất kỳ phiên bản nào ít nhất là 0.8.5 nhưng dưới 0.9.0.

Cargo coi các phiên bản này có API công khai tương thích với phiên bản 0.8.5, và chỉ định này đảm bảo bạn sẽ nhận được bản phát hành vá lỗi mới nhất mà vẫn biên dịch được với mã trong chương này. Bất kỳ phiên bản nào từ 0.9.0 trở lên không được đảm bảo có cùng API như những gì các ví dụ sau đây sử dụng.

Bây giờ, mà không thay đổi bất kỳ mã nào, hãy xây dựng dự án, như được hiển thị trong Listing 2-2.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s

Bạn có thể thấy các số phiên bản khác nhau (nhưng tất cả sẽ tương thích với mã, nhờ SemVer!) và các dòng khác nhau (tùy thuộc vào hệ điều hành), và các dòng có thể ở một thứ tự khác.

Khi chúng ta bao gồm một phụ thuộc bên ngoài, Cargo sẽ lấy các phiên bản mới nhất của mọi thứ mà phụ thuộc đó cần từ registry, là một bản sao của dữ liệu từ Crates.io. Crates.io là nơi mọi người trong hệ sinh thái Rust đăng các dự án Rust mã nguồn mở của họ để người khác sử dụng.

Sau khi cập nhật registry, Cargo kiểm tra phần [dependencies] và tải xuống bất kỳ crate nào được liệt kê mà chưa được tải xuống. Trong trường hợp này, mặc dù chúng ta chỉ liệt kê rand làm phụ thuộc, Cargo cũng đã lấy các crate khác mà rand phụ thuộc vào để hoạt động. Sau khi tải xuống các crate, Rust biên dịch chúng và sau đó biên dịch dự án với các phụ thuộc có sẵn.

Nếu bạn ngay lập tức chạy cargo build lại mà không thực hiện bất kỳ thay đổi nào, bạn sẽ không nhận được bất kỳ kết quả nào ngoài dòng Finished. Cargo biết rằng nó đã tải xuống và biên dịch các phụ thuộc, và bạn chưa thay đổi bất kỳ điều gì về chúng trong file Cargo.toml của bạn. Cargo cũng biết rằng bạn chưa thay đổi bất kỳ điều gì về mã của bạn, vì vậy nó không biên dịch lại mã đó. Không có gì để làm, nó chỉ đơn giản thoát ra.

Nếu bạn mở file src/main.rs, thực hiện một thay đổi nhỏ và sau đó lưu nó và xây dựng lại, bạn sẽ chỉ thấy hai dòng kết quả:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

Những dòng này cho thấy rằng Cargo chỉ cập nhật bản dựng với thay đổi nhỏ của bạn đối với file src/main.rs. Các phụ thuộc của bạn không thay đổi, vì vậy Cargo biết rằng nó có thể tái sử dụng những gì nó đã tải xuống và biên dịch cho những phụ thuộc đó.

Đảm Bảo Các Bản Dựng Có Thể Tái Tạo Với File Cargo.lock

Cargo có một cơ chế đảm bảo bạn có thể tái tạo cùng một artifact mỗi khi bạn hoặc bất kỳ ai khác xây dựng mã của bạn: Cargo sẽ chỉ sử dụng các phiên bản của các phụ thuộc mà bạn đã chỉ định cho đến khi bạn chỉ định khác. Ví dụ, giả sử rằng tuần tới phiên bản 0.8.6 của crate rand ra mắt, và phiên bản đó chứa một bản sửa lỗi quan trọng, nhưng nó cũng chứa một lỗi hồi quy sẽ làm hỏng mã của bạn. Để xử lý điều này, Rust tạo file Cargo.lock lần đầu tiên bạn chạy cargo build, vì vậy bây giờ chúng ta có file này trong thư mục guessing_game.

Khi bạn xây dựng một dự án lần đầu tiên, Cargo sẽ tìm ra tất cả các phiên bản của các phụ thuộc phù hợp với tiêu chí và sau đó ghi chúng vào file Cargo.lock. Khi bạn xây dựng dự án của mình trong tương lai, Cargo sẽ thấy rằng file Cargo.lock tồn tại và sẽ sử dụng các phiên bản được chỉ định ở đó thay vì làm tất cả công việc tìm ra các phiên bản lại. Điều này cho phép bạn có một bản dựng có thể tái tạo tự động. Nói cách khác, dự án của bạn sẽ vẫn ở phiên bản 0.8.5 cho đến khi bạn nâng cấp rõ ràng, nhờ vào file Cargo.lock. Vì file Cargo.lock quan trọng đối với các bản dựng có thể tái tạo, nó thường được kiểm tra vào kiểm soát nguồn cùng với phần còn lại của mã trong dự án của bạn.

Cập Nhật Một Crate Để Nhận Phiên Bản Mới

Khi bạn muốn cập nhật một crate, Cargo cung cấp lệnh update, lệnh này sẽ bỏ qua file Cargo.lock và tìm ra tất cả các phiên bản mới nhất phù hợp với các chỉ định của bạn trong Cargo.toml. Cargo sau đó sẽ ghi các phiên bản đó vào file Cargo.lock. Trong trường hợp này, Cargo sẽ chỉ tìm các phiên bản lớn hơn 0.8.5 và nhỏ hơn 0.9.0. Nếu crate rand đã phát hành hai phiên bản mới 0.8.6 và 0.9.0, bạn sẽ thấy điều sau nếu bạn chạy cargo update:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo bỏ qua phiên bản 0.9.0. Tại thời điểm này, bạn cũng sẽ nhận thấy một thay đổi trong file Cargo.lock ghi nhận rằng phiên bản của crate rand mà bạn đang sử dụng bây giờ là 0.8.6. Để sử dụng phiên bản 0.9.0 của rand hoặc bất kỳ phiên bản nào trong loạt 0.9.x, bạn sẽ phải cập nhật file Cargo.toml để trông như thế này:

[dependencies]
rand = "0.9.0"

Lần tiếp theo bạn chạy cargo build, Cargo sẽ cập nhật registry của các crate có sẵn và đánh giá lại các yêu cầu của bạn về rand theo phiên bản mới mà bạn đã chỉ định.

Có rất nhiều điều để nói về Cargohệ sinh thái của nó, mà chúng ta sẽ thảo luận trong Chương 14, nhưng hiện tại, đó là tất cả những gì bạn cần biết. Cargo làm cho việc tái sử dụng các thư viện rất dễ dàng, vì vậy các Rustaceans có thể viết các dự án nhỏ hơn được lắp ráp từ một số gói.

Tạo Một Số Ngẫu Nhiên

Hãy bắt đầu sử dụng rand để tạo ra một số để đoán. Bước tiếp theo là cập nhật src/main.rs, như được hiển thị trong Listing 2-3.

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}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Đầu tiên chúng ta thêm dòng use rand::Rng;. Trait Rng định nghĩa các phương thức mà các bộ tạo số ngẫu nhiên triển khai, và trait này phải nằm trong phạm vi để chúng ta sử dụng các phương thức đó. Chương 10 sẽ bao gồm các trait chi tiết.

Tiếp theo, chúng ta thêm hai dòng ở giữa. Trong dòng đầu tiên, chúng ta gọi hàm rand::thread_rng để lấy bộ tạo số ngẫu nhiên cụ thể mà chúng ta sẽ sử dụng: một bộ tạo số ngẫu nhiên cục bộ cho luồng thực thi hiện tại và được khởi tạo bởi hệ điều hành. Sau đó, chúng ta gọi phương thức gen_range trên bộ tạo số ngẫu nhiên. Phương thức này được định nghĩa bởi trait Rng mà chúng ta đã đưa vào phạm vi với câu lệnh use rand::Rng;. Phương thức gen_range lấy một biểu thức phạm vi làm đối số và tạo ra một số ngẫu nhiên trong phạm vi đó. Loại biểu thức phạm vi mà chúng ta đang sử dụng ở đây có dạng start..=end và bao gồm cả giới hạn dưới và giới hạn trên, vì vậy chúng ta cần chỉ định 1..=100 để yêu cầu một số từ 1 đến 100.

Lưu ý: Bạn sẽ không chỉ biết các trait nào để sử dụng và các phương thức và hàm nào để gọi từ một crate, vì vậy mỗi crate có tài liệu với hướng dẫn sử dụng nó. Một tính năng thú vị khác của Cargo là chạy lệnh cargo doc --open sẽ xây dựng tài liệu được cung cấp bởi tất cả các phụ thuộc của bạn cục bộ và mở nó trong trình duyệt của bạn. Nếu bạn quan tâm đến các chức năng khác trong crate rand, ví dụ, hãy chạy cargo doc --open và nhấp vào rand trong thanh bên trái.

Dòng mới thứ hai in ra số bí mật. Điều này hữu ích trong khi chúng ta đang phát triển chương trình để có thể kiểm tra nó, nhưng chúng ta sẽ xóa nó khỏi phiên bản cuối cùng. Nó không phải là một trò chơi nếu chương trình in ra câu trả lời ngay khi nó bắt đầu!

Hãy thử chạy chương trình một vài lần:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

Bạn nên nhận được các số ngẫu nhiên khác nhau, và tất cả chúng nên là các số từ 1 đến 100. Làm tốt lắm!

So Sánh Số Đoán Với Số Bí Mật

Bây giờ chúng ta đã có đầu vào từ người dùng và một số ngẫu nhiên, chúng ta có thể so sánh chúng. Bước đó được hiển thị trong Listing 2-4. Lưu ý rằng mã này sẽ không biên dịch ngay lập tức, như chúng ta sẽ giải thích.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Đầu tiên chúng ta thêm một câu lệnh use khác, đưa một kiểu gọi là std::cmp::Ordering vào phạm vi từ thư viện tiêu chuẩn. Kiểu Ordering là một enum khác và có các biến thể Less, GreaterEqual. Đây là ba kết quả có thể xảy ra khi bạn so sánh hai giá trị.

Sau đó, chúng ta thêm năm dòng mới ở cuối sử dụng kiểu Ordering. Phương thức cmp so sánh hai giá trị và có thể được gọi trên bất kỳ thứ gì có thể so sánh. Nó lấy một tham chiếu đến bất kỳ thứ gì bạn muốn so sánh: ở đây nó đang so sánh guess với secret_number. Sau đó, nó trả về một biến thể của enum Ordering mà chúng ta đã đưa vào phạm vi với câu lệnh use. Chúng ta sử dụng một biểu thức match để quyết định làm gì tiếp theo dựa trên biến thể nào của Ordering được trả về từ lời gọi đến cmp với các giá trị trong guesssecret_number.

Một biểu thức match được tạo thành từ các nhánh. Một nhánh bao gồm một mẫu để so khớp, và mã sẽ được chạy nếu giá trị được đưa vào match phù hợp với mẫu của nhánh đó. Rust lấy giá trị được đưa vào match và xem qua mẫu của từng nhánh lần lượt. Các mẫu và cấu trúc match là các tính năng mạnh mẽ của Rust: chúng cho phép bạn biểu đạt nhiều tình huống mà mã của bạn có thể gặp phải và chúng đảm bảo bạn xử lý tất cả chúng. Các tính năng này sẽ được bao gồm chi tiết trong Chương 6 và Chương 19, tương ứng.

Hãy đi qua một ví dụ với biểu thức match mà chúng ta sử dụng ở đây. Giả sử rằng người dùng đã đoán 50 và số bí mật được tạo ngẫu nhiên lần này là 38.

Khi mã so sánh 50 với 38, phương thức cmp sẽ trả về Ordering::Greater vì 50 lớn hơn 38. Biểu thức match nhận giá trị Ordering::Greater và bắt đầu kiểm tra mẫu của từng nhánh. Nó xem mẫu của nhánh đầu tiên, Ordering::Less, và thấy rằng giá trị Ordering::Greater không phù hợp với Ordering::Less, vì vậy nó bỏ qua mã trong nhánh đó và chuyển sang nhánh tiếp theo. Mẫu của nhánh tiếp theo là Ordering::Greater, điều này phù hợp với Ordering::Greater! Mã liên kết trong nhánh đó sẽ được thực thi và in ra Too big! trên màn hình. Biểu thức match kết thúc sau khi khớp thành công đầu tiên, vì vậy nó sẽ không xem nhánh cuối cùng trong tình huống này.

Tuy nhiên, mã trong Listing 2-4 sẽ không biên dịch ngay lập tức. Hãy thử nó:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/cmp.rs:964:8

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

Lỗi cốt lõi cho biết rằng có các kiểu không khớp. Rust có một hệ thống kiểu mạnh mẽ và tĩnh. Tuy nhiên, nó cũng có suy luận kiểu. Khi chúng ta viết let mut guess = String::new(), Rust có thể suy luận rằng guess nên là một String và không bắt chúng ta phải viết kiểu. Mặt khác, secret_number là một kiểu số. Một vài kiểu số của Rust có thể có giá trị từ 1 đến 100: i32, một số 32-bit; u32, một số 32-bit không dấu; i64, một số 64-bit; cũng như các kiểu khác. Trừ khi được chỉ định khác, Rust mặc định là một i32, đó là kiểu của secret_number trừ khi bạn thêm thông tin kiểu ở nơi khác để Rust suy luận một kiểu số khác. Lý do cho lỗi là Rust không thể so sánh một chuỗi và một kiểu số.

Cuối cùng, chúng ta muốn chuyển đổi String mà chương trình đọc làm đầu vào thành một kiểu số để chúng ta có thể so sánh nó về mặt số học với số bí mật. Chúng ta làm điều đó bằng cách thêm dòng này vào thân hàm main:

Filename: src/main.rs

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}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Dòng này là:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

Chúng ta tạo một biến có tên là guess. Nhưng chờ đã, chương trình đã có một biến có tên là guess chưa? Đúng vậy, nhưng may mắn thay Rust cho phép chúng ta che bóng giá trị trước đó của guess bằng một giá trị mới. Che bóng cho phép chúng ta tái sử dụng tên biến guess thay vì buộc chúng ta phải tạo hai biến duy nhất, chẳng hạn như guess_strguess, ví dụ. Chúng ta sẽ bao gồm điều này chi tiết hơn trong Chương 3, nhưng hiện tại, hãy biết rằng tính năng này thường được sử dụng khi bạn muốn chuyển đổi một giá trị từ một kiểu sang một kiểu khác.

Chúng ta gán biến mới này với biểu thức guess.trim().parse(). guess trong biểu thức đề cập đến biến guess ban đầu chứa đầu vào dưới dạng chuỗi. Phương thức trim trên một phiên bản String sẽ loại bỏ bất kỳ khoảng trắng nào ở đầu và cuối, điều mà chúng ta phải làm trước khi có thể chuyển đổi chuỗi thành một u32, chỉ có thể chứa dữ liệu số. Người dùng phải nhấn enter để thỏa mãn read_line và nhập số đoán của họ, điều này thêm một ký tự xuống dòng vào chuỗi. Ví dụ, nếu người dùng nhập 5 và nhấn enter, guess trông như thế này: 5\n. \n đại diện cho “xuống dòng.” (Trên Windows, nhấn enter sẽ dẫn đến một ký tự xuống dòng và một ký tự trở về, \r\n.) Phương thức trim loại bỏ \n hoặc \r\n, chỉ để lại 5.

Phương thức parse trên chuỗi chuyển đổi một chuỗi thành một kiểu khác. Ở đây, chúng ta sử dụng nó để chuyển đổi từ một chuỗi thành một số. Chúng ta cần nói với Rust kiểu số chính xác mà chúng ta muốn bằng cách sử dụng let guess: u32. Dấu hai chấm (:) sau guess cho Rust biết rằng chúng ta sẽ chú thích kiểu của biến. Rust có một vài kiểu số tích hợp; u32 được thấy ở đây là một số nguyên 32-bit không dấu. Đó là một lựa chọn mặc định tốt cho một số dương nhỏ. Bạn sẽ học về các kiểu số khác trong Chương 3.

Ngoài ra, chú thích u32 trong chương trình ví dụ này và so sánh với secret_number có nghĩa là Rust sẽ suy luận rằng secret_number cũng nên là một u32. Vì vậy, bây giờ so sánh sẽ là giữa hai giá trị cùng kiểu!

Phương thức parse sẽ chỉ hoạt động trên các ký tự có thể chuyển đổi hợp lý thành số và do đó có thể dễ dàng gây ra lỗi. Nếu, ví dụ, chuỗi chứa A👍%, sẽ không có cách nào để chuyển đổi điều đó thành một số. Vì nó có thể thất bại, phương thức parse trả về một kiểu Result, giống như phương thức read_line (được thảo luận trước đó trong “Xử Lý Khả Năng Thất Bại Với Result). Chúng ta sẽ xử lý Result này theo cùng cách bằng cách sử dụng phương thức expect một lần nữa. Nếu parse trả về một biến thể Err của Result vì nó không thể tạo ra một số từ chuỗi, lời gọi expect sẽ làm cho trò chơi bị sập và in ra thông báo mà chúng ta đưa ra. Nếu parse có thể chuyển đổi thành công chuỗi thành một số, nó sẽ trả về biến thể Ok của Result, và expect sẽ trả về số mà chúng ta muốn từ giá trị Ok.

Hãy chạy chương trình ngay bây giờ:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

Tuyệt vời! Mặc dù đã thêm các khoảng trắng trước số đoán, chương trình vẫn nhận ra rằng người dùng đã đoán 76. Chạy chương trình một vài lần để xác minh hành vi khác nhau với các loại đầu vào khác nhau: đoán số chính xác, đoán một số quá lớn và đoán một số quá nhỏ.

Chúng ta đã có hầu hết trò chơi hoạt động, nhưng người dùng chỉ có thể đoán một lần. Hãy thay đổi điều đó bằng cách thêm một vòng lặp!

Cho Phép Nhiều Lần Đoán Với Vòng Lặp

Từ khóa loop tạo ra một vòng lặp vô hạn. Chúng ta sẽ thêm một vòng lặp để cho phép người dùng có nhiều cơ hội đoán số hơn:

Filename: src/main.rs

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);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

Như bạn có thể thấy, chúng ta đã di chuyển mọi thứ từ lời nhắc nhập số đoán trở đi vào một vòng lặp. Hãy chắc chắn thụt lề các dòng bên trong vòng lặp thêm bốn khoảng trắng mỗi dòng và chạy chương trình lại. Chương trình bây giờ sẽ yêu cầu một số đoán khác mãi mãi, điều này thực sự giới thiệu một vấn đề mới. Dường như người dùng không thể thoát ra!

Người dùng luôn có thể ngắt chương trình bằng cách sử dụng phím tắt ctrl-c. Nhưng có một cách khác để thoát khỏi con quái vật không thể thỏa mãn này, như đã đề cập trong phần thảo luận về parse trong “So Sánh Số Đoán Với Số Bí Mật”: nếu người dùng nhập một câu trả lời không phải là số, chương trình sẽ bị sập. Chúng ta có thể tận dụng điều đó để cho phép người dùng thoát ra, như được hiển thị ở đây:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Nhập quit sẽ thoát khỏi trò chơi, nhưng như bạn sẽ nhận thấy, việc nhập bất kỳ đầu vào không phải là số nào khác cũng sẽ thoát ra. Điều này là không tối ưu, ít nhất là; chúng ta muốn trò chơi cũng dừng lại khi số đoán chính xác.

Thoát Sau Khi Đoán Đúng

Hãy lập trình trò chơi để thoát khi người dùng thắng bằng cách thêm một câu lệnh break:

Filename: src/main.rs

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();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        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;
            }
        }
    }
}

Thêm dòng break sau You win! làm cho chương trình thoát khỏi vòng lặp khi người dùng đoán đúng số bí mật. Thoát khỏi vòng lặp cũng có nghĩa là thoát khỏi chương trình, vì vòng lặp là phần cuối cùng của main.

Xử Lý Đầu Vào Không Hợp Lệ

Để tinh chỉnh thêm hành vi của trò chơi, thay vì làm cho chương trình bị sập khi người dùng nhập vào một số không phải là số, hãy làm cho trò chơi bỏ qua số không phải là số để người dùng có thể tiếp tục đoán. Chúng ta có thể làm điều đó bằng cách thay đổi dòng mà guess được chuyển đổi từ một String thành một u32, như được hiển thị trong Listing 2-5.

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;
            }
        }
    }
}

Chúng ta chuyển từ một lời gọi expect sang một biểu thức match để chuyển từ việc làm cho chương trình bị sập khi có lỗi sang xử lý lỗi. Nhớ rằng parse trả về một kiểu ResultResult là một enum có các biến thể OkErr. Chúng ta đang sử dụng một biểu thức match ở đây, như chúng ta đã làm với kết quả Ordering của phương thức cmp.

Nếu parse có thể chuyển đổi thành công chuỗi thành một số, nó sẽ trả về một giá trị Ok chứa số kết quả. Giá trị Ok đó sẽ khớp với mẫu của nhánh đầu tiên, và biểu thức match sẽ chỉ trả về giá trị numparse đã tạo ra và đặt bên trong giá trị Ok. Số đó sẽ kết thúc ngay tại nơi chúng ta muốn trong biến guess mới mà chúng ta đang tạo.

Nếu parse không thể chuyển đổi chuỗi thành một số, nó sẽ trả về một giá trị Err chứa thêm thông tin về lỗi. Giá trị Err không khớp với mẫu Ok(num) trong nhánh đầu tiên của match, nhưng nó khớp với mẫu Err(_) trong nhánh thứ hai. Dấu gạch dưới, _, là một giá trị bắt tất cả; trong ví dụ này, chúng ta đang nói rằng chúng ta muốn khớp với tất cả các giá trị Err, bất kể thông tin nào chúng có bên trong. Vì vậy, chương trình sẽ thực thi mã của nhánh thứ hai, continue, điều này cho chương trình biết đi đến lần lặp tiếp theo của loop và yêu cầu một số đoán khác. Vì vậy, về cơ bản, chương trình bỏ qua tất cả các lỗi mà parse có thể gặp phải!

Bây giờ mọi thứ trong chương trình nên hoạt động như mong đợi. Hãy thử nó:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

Tuyệt vời! Với một tinh chỉnh nhỏ cuối cùng, chúng ta sẽ hoàn thành trò chơi đoán số. Nhớ rằng chương trình vẫn đang in ra số bí mật. Điều đó hoạt động tốt cho việc kiểm tra, nhưng nó làm hỏng trò chơi. Hãy xóa println! in ra số bí mật. Listing 2-6 hiển thị mã hoàn chỉnh.

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);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        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}");

        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 này, bạn đã xây dựng thành công trò chơi đoán số. Chúc mừng!

Tóm Tắt

Dự án này là một cách thực hành để giới thiệu cho bạn nhiều khái niệm mới trong Rust: let, match, hàm, việc sử dụng các crate bên ngoài và nhiều hơn nữa. Trong các chương tiếp theo, bạn sẽ học về các khái niệm này chi tiết hơn. Chương 3 bao gồm các khái niệm mà hầu hết các ngôn ngữ lập trình đều có, chẳng hạn như biến, kiểu dữ liệu và hàm, và chỉ cho bạn cách sử dụng chúng trong Rust. Chương 4 khám phá quyền sở hữu, một tính năng làm cho Rust khác biệt so với các ngôn ngữ khác. Chương 5 thảo luận về cấu trúc và cú pháp phương thức, và Chương 6 giải thích cách hoạt động của các enum.

Các Khái Niệm Lập Trình Phổ Biến

Chương này bao gồm các khái niệm xuất hiện trong hầu hết mọi ngôn ngữ lập trình và cách chúng hoạt động trong Rust. Nhiều ngôn ngữ lập trình có nhiều điểm chung ở cốt lõi. Không có khái niệm nào được trình bày trong chương này là độc quyền của Rust, nhưng chúng ta sẽ thảo luận về chúng trong bối cảnh của Rust và giải thích các quy ước xung quanh việc sử dụng các khái niệm này.

Cụ thể, bạn sẽ học về biến, các kiểu dữ liệu cơ bản, hàm, bình luận, và luồng điều khiển. Những nền tảng này sẽ có trong mọi chương trình Rust, và việc học chúng sớm sẽ giúp bạn có một nền tảng vững chắc để bắt đầu.

Từ khóa

Ngôn ngữ Rust có một tập hợp các từ khóa được dành riêng để sử dụng bởi ngôn ngữ này, tương tự như trong các ngôn ngữ khác. Hãy lưu ý rằng bạn không thể sử dụng những từ này làm tên của biến hoặc hàm. Hầu hết các từ khóa đều có ý nghĩa đặc biệt, và bạn sẽ sử dụng chúng để thực hiện các nhiệm vụ khác nhau trong chương trình Rust của mình; một số ít không có chức năng cụ thể nào liên quan đến chúng nhưng đã được dành riêng cho các chức năng có thể được thêm vào Rust trong tương lai. Bạn có thể tìm thấy danh sách các từ khóa trong Phụ lục A.

Biến và Tính Khả Biến

Như đã đề cập trong phần "Lưu Trữ Giá Trị với Biến", theo mặc định, các biến là không thể thay đổi (immutable). Đây là một trong nhiều sự hướng dẫn mà Rust cung cấp cho bạn để viết code theo cách tận dụng lợi thế về tính an toàn và dễ dàng trong xử lý đồng thời mà Rust mang lại. Tuy nhiên, bạn vẫn có tùy chọn để làm cho các biến của mình có thể thay đổi (mutable). Hãy cùng khám phá cách thức và lý do tại sao Rust khuyến khích bạn ưu tiên tính bất biến và lý do tại sao đôi khi bạn có thể muốn lựa chọn khác.

Khi một biến là bất biến, sau khi một giá trị được gắn với một tên, bạn không thể thay đổi giá trị đó. Để minh họa điều này, hãy tạo một dự án mới có tên là variables trong thư mục projects của bạn bằng cách sử dụng cargo new variables.

Sau đó, trong thư mục variables mới của bạn, mở src/main.rs và thay thế mã của nó bằng đoạn mã sau, mã này sẽ chưa biên dịch được ngay:

Tên tệp: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Lưu và chạy chương trình bằng cách sử dụng cargo run. Bạn sẽ nhận được thông báo lỗi liên quan đến lỗi bất biến, như được hiển thị trong kết quả này:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` (bin "variables") due to 1 previous error

Ví dụ này cho thấy cách trình biên dịch giúp bạn tìm ra lỗi trong các chương trình của mình. Lỗi biên dịch có thể gây khó chịu, nhưng thực sự chúng chỉ có nghĩa là chương trình của bạn chưa an toàn để thực hiện những gì bạn muốn; chúng không có nghĩa là bạn không phải là một lập trình viên giỏi! Ngay cả những người dùng Rust có kinh nghiệm vẫn gặp lỗi biên dịch.

Bạn nhận được thông báo lỗi cannot assign twice to immutable variable `x` bởi vì bạn đã cố gắn một giá trị thứ hai cho biến bất biến x.

Điều quan trọng là chúng ta gặp lỗi khi biên dịch khi chúng ta cố gắng thay đổi một giá trị được chỉ định là bất biến bởi vì tình huống này có thể dẫn đến lỗi. Nếu một phần trong code của chúng ta hoạt động dựa trên giả định rằng một giá trị sẽ không bao giờ thay đổi và một phần khác của code thay đổi giá trị đó, thì có thể phần đầu tiên của code sẽ không làm những gì nó được thiết kế để làm. Nguyên nhân của loại lỗi này có thể khó theo dõi sau khi nó xảy ra, đặc biệt là khi phần code thứ hai chỉ thay đổi giá trị đôi khi. Trình biên dịch của Rust đảm bảo rằng khi bạn tuyên bố một giá trị sẽ không thay đổi, nó thực sự sẽ không thay đổi, vì vậy bạn không phải tự theo dõi nó. Do đó, code của bạn dễ dàng suy luận hơn.

Nhưng tính khả biến có thể rất hữu ích và có thể làm cho code trở nên thuận tiện hơn khi viết. Mặc dù các biến là bất biến theo mặc định, bạn có thể làm cho chúng trở nên khả biến bằng cách thêm mut trước tên biến như bạn đã làm trong Chương 2. Thêm mut cũng truyền đạt ý định cho những người đọc code trong tương lai bằng cách chỉ ra rằng các phần khác của code sẽ thay đổi giá trị của biến này.

Ví dụ, hãy thay đổi src/main.rs thành như sau:

Tên tệp: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

Khi chúng ta chạy chương trình bây giờ, chúng ta sẽ nhận được:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

Chúng ta được phép thay đổi giá trị gắn với x từ 5 thành 6 khi sử dụng mut. Cuối cùng, quyết định sử dụng tính khả biến hay không tùy thuộc vào bạn và phụ thuộc vào những gì bạn nghĩ là rõ ràng nhất trong tình huống cụ thể đó.

Hằng số

Giống như các biến bất biến, hằng số là các giá trị được gắn với một tên và không được phép thay đổi, nhưng có một vài sự khác biệt giữa hằng số và biến.

Đầu tiên, bạn không được phép sử dụng mut với hằng số. Hằng số không chỉ bất biến theo mặc định—chúng luôn bất biến. Bạn khai báo hằng số bằng từ khóa const thay vì từ khóa let, và kiểu của giá trị phải được chú thích. Chúng ta sẽ đề cập đến các kiểu và chú thích kiểu trong phần tiếp theo, "Kiểu Dữ Liệu", vì vậy đừng lo lắng về các chi tiết ngay bây giờ. Chỉ cần biết rằng bạn phải luôn chú thích kiểu.

Hằng số có thể được khai báo trong bất kỳ phạm vi nào, bao gồm cả phạm vi toàn cục, làm cho chúng hữu ích cho các giá trị mà nhiều phần của code cần biết.

Sự khác biệt cuối cùng là hằng số chỉ có thể được đặt thành một biểu thức hằng, không phải là kết quả của một giá trị chỉ có thể được tính toán trong thời gian chạy.

Đây là một ví dụ về khai báo hằng số:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

Tên của hằng số là THREE_HOURS_IN_SECONDS và giá trị của nó được đặt thành kết quả của việc nhân 60 (số giây trong một phút) với 60 (số phút trong một giờ) với 3 (số giờ mà chúng ta muốn đếm trong chương trình này). Quy ước đặt tên của Rust cho hằng số là sử dụng tất cả các chữ in hoa với dấu gạch dưới giữa các từ. Trình biên dịch có thể đánh giá một tập hợp giới hạn các hoạt động tại thời điểm biên dịch, cho phép chúng ta chọn cách viết giá trị này theo cách dễ hiểu và xác minh hơn, thay vì đặt hằng số này thành giá trị 10.800. Xem phần đánh giá hằng số trong Rust Reference để biết thêm thông tin về các hoạt động có thể được sử dụng khi khai báo hằng số.

Hằng số có hiệu lực trong suốt thời gian chương trình chạy, trong phạm vi mà chúng được khai báo. Thuộc tính này làm cho hằng số hữu ích cho các giá trị trong lĩnh vực ứng dụng của bạn mà nhiều phần của chương trình có thể cần biết, chẳng hạn như số điểm tối đa mà bất kỳ người chơi nào của trò chơi được phép kiếm được, hoặc tốc độ ánh sáng.

Việc đặt tên cho các giá trị cứng được sử dụng trong suốt chương trình của bạn dưới dạng hằng số là hữu ích trong việc truyền đạt ý nghĩa của giá trị đó cho những người duy trì code trong tương lai. Nó cũng giúp bạn chỉ cần có một nơi trong code mà bạn cần thay đổi nếu giá trị cứng cần được cập nhật trong tương lai.

Che Khuất (Shadowing)

Như bạn đã thấy trong hướng dẫn trò chơi đoán số trong Chương 2, bạn có thể khai báo một biến mới với cùng tên với một biến trước đó. Người dùng Rust nói rằng biến đầu tiên bị che khuất bởi biến thứ hai, có nghĩa là biến thứ hai là những gì trình biên dịch sẽ thấy khi bạn sử dụng tên của biến. Trên thực tế, biến thứ hai che khuất biến đầu tiên, lấy bất kỳ việc sử dụng nào của tên biến cho chính nó cho đến khi nó tự bị che khuất hoặc phạm vi kết thúc. Chúng ta có thể che khuất một biến bằng cách sử dụng cùng tên biến và lặp lại việc sử dụng từ khóa let như sau:

Tên tệp: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

Chương trình này đầu tiên gắn x với giá trị 5. Sau đó, nó tạo một biến mới x bằng cách lặp lại let x =, lấy giá trị ban đầu và cộng thêm 1 để giá trị của x sau đó là 6. Sau đó, trong phạm vi bên trong được tạo bằng dấu ngoặc nhọn, câu lệnh let thứ ba cũng che khuất x và tạo một biến mới, nhân giá trị trước đó với 2 để cho x giá trị là 12. Khi phạm vi đó kết thúc, việc che khuất bên trong kết thúc và x trở về giá trị 6. Khi chúng ta chạy chương trình này, nó sẽ xuất ra như sau:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

Che khuất khác với việc đánh dấu một biến là mut vì chúng ta sẽ gặp lỗi biên dịch nếu chúng ta vô tình cố gắng gán lại cho biến này mà không sử dụng từ khóa let. Bằng cách sử dụng let, chúng ta có thể thực hiện một vài biến đổi trên một giá trị nhưng làm cho biến đó bất biến sau khi các biến đổi đó đã hoàn thành.

Sự khác biệt khác giữa mut và che khuất là vì chúng ta đang tạo một biến mới khi chúng ta sử dụng từ khóa let lần nữa, chúng ta có thể thay đổi kiểu của giá trị nhưng vẫn tái sử dụng cùng một tên. Ví dụ, giả sử chương trình của chúng ta yêu cầu người dùng hiển thị số lượng khoảng trắng mà họ muốn giữa một số văn bản bằng cách nhập các ký tự khoảng trắng, và sau đó chúng ta muốn lưu trữ đầu vào đó dưới dạng số:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

Biến spaces đầu tiên là kiểu chuỗi và biến spaces thứ hai là kiểu số. Việc che khuất do đó giúp chúng ta không phải nghĩ ra các tên khác nhau, như spaces_strspaces_num; thay vào đó, chúng ta có thể tái sử dụng cái tên đơn giản hơn là spaces. Tuy nhiên, nếu chúng ta cố gắng sử dụng mut cho việc này, như được hiển thị ở đây, chúng ta sẽ gặp lỗi biên dịch:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

Lỗi nói rằng chúng ta không được phép thay đổi kiểu của một biến:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` (bin "variables") due to 1 previous error

Bây giờ chúng ta đã khám phá cách hoạt động của các biến, hãy xem xét nhiều loại dữ liệu khác mà biến có thể có.

Kiểu Dữ Liệu

Mỗi giá trị trong Rust thuộc về một kiểu dữ liệu nhất định, điều này cho Rust biết loại dữ liệu đang được chỉ định để nó biết cách làm việc với dữ liệu đó. Chúng ta sẽ xem xét hai tập con kiểu dữ liệu: kiểu vô hướng (scalar) và kiểu phức hợp (compound).

Hãy nhớ rằng Rust là một ngôn ngữ kiểu tĩnh, nghĩa là nó phải biết kiểu của tất cả các biến tại thời điểm biên dịch. Trình biên dịch thường có thể suy ra kiểu mà chúng ta muốn sử dụng dựa trên giá trị và cách chúng ta sử dụng nó. Trong các trường hợp có nhiều kiểu có thể xảy ra, chẳng hạn như khi chúng ta chuyển đổi một String thành một kiểu số bằng cách sử dụng parse trong phần "So sánh số đoán với số bí mật" ở Chương 2, chúng ta phải thêm chú thích kiểu, như sau:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

Nếu chúng ta không thêm chú thích kiểu : u32 như trong đoạn mã trên, Rust sẽ hiển thị lỗi sau, có nghĩa là trình biên dịch cần thêm thông tin từ chúng ta để biết kiểu nào chúng ta muốn sử dụng:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

Bạn sẽ thấy các chú thích kiểu khác nhau cho các kiểu dữ liệu khác.

Kiểu Vô Hướng

Một kiểu vô hướng biểu diễn một giá trị đơn lẻ. Rust có bốn kiểu vô hướng cơ bản: số nguyên, số thực dấu phẩy động, Boolean và ký tự. Có thể bạn đã quen thuộc với những kiểu này từ các ngôn ngữ lập trình khác. Chúng ta hãy xem cách chúng hoạt động trong Rust.

Kiểu Số Nguyên

Số nguyên là một số không có phần thập phân. Chúng ta đã sử dụng một kiểu số nguyên trong Chương 2, kiểu u32. Khai báo kiểu này cho biết rằng giá trị được liên kết với nó phải là một số nguyên không dấu (các kiểu số nguyên có dấu bắt đầu bằng i thay vì u) chiếm 32 bit không gian lưu trữ. Bảng 3-1 cho thấy các kiểu số nguyên tích hợp trong Rust. Chúng ta có thể sử dụng bất kỳ biến thể nào trong số này để khai báo kiểu của một giá trị số nguyên.

Bảng 3-1: Các Kiểu Số Nguyên trong Rust

Độ dàiCó dấuKhông dấu
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
phụ thuộc vào kiến trúcisizeusize

Mỗi biến thể có thể có dấu hoặc không dấu và có một kích thước rõ ràng. Có dấukhông dấu đề cập đến việc liệu số đó có thể âm hay không—nói cách khác, liệu số đó cần có dấu kèm theo (có dấu) hay chỉ bao giờ cũng dương và do đó có thể được biểu diễn mà không cần dấu (không dấu). Giống như viết số trên giấy: khi dấu quan trọng, số được hiển thị với dấu cộng hoặc dấu trừ; tuy nhiên, khi có thể giả định rằng số đó là dương, nó được hiển thị không có dấu. Số có dấu được lưu trữ bằng cách sử dụng biểu diễn bù hai.

Mỗi biến thể có dấu có thể lưu trữ số từ −(2n − 1) đến 2n − 1 − 1 bao gồm cả hai đầu, trong đó n là số bit mà biến thể đó sử dụng. Vì vậy, i8 có thể lưu trữ số từ −(27) đến 27 − 1, tương đương từ −128 đến 127. Các biến thể không dấu có thể lưu trữ số từ 0 đến 2n − 1, nên u8 có thể lưu trữ số từ 0 đến 28 − 1, tương đương từ 0 đến 255.

Ngoài ra, các kiểu isizeusize phụ thuộc vào kiến trúc của máy tính mà chương trình của bạn đang chạy: 64 bit nếu bạn đang sử dụng kiến trúc 64 bit và 32 bit nếu bạn đang sử dụng kiến trúc 32 bit.

Bạn có thể viết số nguyên theo bất kỳ dạng nào được hiển thị trong Bảng 3-2. Lưu ý rằng các số nguyên có thể thuộc nhiều kiểu số khác nhau cho phép hậu tố kiểu, chẳng hạn như 57u8, để chỉ định kiểu. Số nguyên cũng có thể sử dụng _ như một dấu phân cách trực quan để làm cho số dễ đọc hơn, chẳng hạn như 1_000, sẽ có giá trị giống như khi bạn chỉ định 1000.

Bảng 3-2: Các Số Nguyên trong Rust

Dạng sốVí dụ
Thập phân98_222
Thập lục phân0xff
Bát phân0o77
Nhị phân0b1111_0000
Byte (u8 chỉ)b'A'

Vậy làm thế nào để biết kiểu số nguyên nào để sử dụng? Nếu bạn không chắc chắn, mặc định của Rust thường là nơi tốt để bắt đầu: các kiểu số nguyên mặc định là i32. Trường hợp chính mà bạn sẽ sử dụng isize hoặc usize là khi đánh chỉ số cho một số loại bộ sưu tập.

Tràn số nguyên

Giả sử bạn có một biến kiểu u8 có thể lưu giá trị từ 0 đến 255. Nếu bạn cố gắng thay đổi biến thành một giá trị ngoài phạm vi đó, chẳng hạn như 256, tràn số nguyên sẽ xảy ra, có thể dẫn đến một trong hai hành vi. Khi bạn biên dịch ở chế độ debug, Rust bao gồm các kiểm tra tràn số nguyên khiến chương trình của bạn panic khi chạy nếu hành vi này xảy ra. Rust sử dụng thuật ngữ panicking khi một chương trình kết thúc với lỗi; chúng ta sẽ thảo luận sâu hơn về panic trong phần "Lỗi không thể khôi phục với panic!" trong Chương 9.

Khi bạn biên dịch ở chế độ phát hành với cờ --release, Rust không bao gồm các kiểm tra tràn số nguyên gây ra panic. Thay vào đó, nếu xảy ra tràn, Rust thực hiện bao quanh bù hai. Nói ngắn gọn, các giá trị lớn hơn giá trị tối đa mà kiểu có thể lưu trữ "bao quanh" về giá trị tối thiểu mà kiểu có thể lưu trữ. Trong trường hợp của u8, giá trị 256 trở thành 0, giá trị 257 trở thành 1, và cứ thế. Chương trình sẽ không panic, nhưng biến sẽ có giá trị có lẽ không phải là giá trị mà bạn mong đợi nó phải có. Việc dựa vào hành vi bao quanh của tràn số nguyên được coi là một lỗi.

Để xử lý rõ ràng khả năng tràn, bạn có thể sử dụng các họ phương thức sau được cung cấp bởi thư viện chuẩn cho các kiểu số nguyên nguyên thủy:

  • Bao quanh trong mọi chế độ với các phương thức wrapping_*, như wrapping_add.
  • Trả về giá trị None nếu có tràn với các phương thức checked_*.
  • Trả về giá trị và một Boolean cho biết liệu có tràn hay không với các phương thức overflowing_*.
  • Bão hòa ở giá trị tối thiểu hoặc tối đa của giá trị với các phương thức saturating_*.

Kiểu Số Thực Dấu Phẩy Động

Rust cũng có hai kiểu nguyên thủy cho số thực dấu phẩy động, là số có dấu thập phân. Kiểu số thực dấu phẩy động của Rust là f32f64, có kích thước lần lượt là 32 bit và 64 bit. Kiểu mặc định là f64 vì trên CPU hiện đại, nó có tốc độ gần tương đương với f32 nhưng có khả năng chính xác cao hơn. Tất cả các kiểu số thực dấu phẩy động đều có dấu.

Đây là một ví dụ thể hiện số thực dấu phẩy động trong hành động:

Tên tập tin: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

Số thực dấu phẩy động được biểu diễn theo tiêu chuẩn IEEE-754.

Phép Toán Số Học

Rust hỗ trợ các phép toán cơ bản mà bạn mong đợi cho tất cả các loại số: cộng, trừ, nhân, chia và lấy phần dư. Phép chia số nguyên cắt cụt về phía số không đến số nguyên gần nhất. Đoạn mã sau cho thấy cách bạn sử dụng mỗi phép toán trong một câu lệnh let:

Tên tập tin: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

Mỗi biểu thức trong các câu lệnh này sử dụng một toán tử toán học và đánh giá thành một giá trị duy nhất, sau đó được gắn với một biến. Phụ lục B chứa danh sách tất cả các toán tử mà Rust cung cấp.

Kiểu Boolean

Cũng giống như trong hầu hết các ngôn ngữ lập trình khác, kiểu Boolean trong Rust có hai giá trị có thể có: truefalse. Boolean có kích thước một byte. Kiểu Boolean trong Rust được chỉ định bằng bool. Ví dụ:

Tên tập tin: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

Cách chính để sử dụng giá trị Boolean là thông qua các điều kiện, chẳng hạn như biểu thức if. Chúng ta sẽ tìm hiểu cách biểu thức if hoạt động trong Rust trong phần "Luồng Điều Khiển".

Kiểu Ký Tự

Kiểu char của Rust là kiểu bảng chữ cái nguyên thủy nhất của ngôn ngữ. Dưới đây là một số ví dụ về khai báo giá trị char:

Tên tập tin: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

Lưu ý rằng chúng ta chỉ định các ký tự bằng dấu nháy đơn, khác với chuỗi, sử dụng dấu nháy kép. Kiểu char của Rust có kích thước bốn byte và đại diện cho một giá trị scalar của Unicode, nghĩa là nó có thể đại diện cho nhiều hơn chỉ là ASCII. Các chữ cái có dấu; ký tự tiếng Trung, tiếng Nhật và tiếng Hàn; biểu tượng cảm xúc; và khoảng trắng có độ rộng bằng không đều là giá trị char hợp lệ trong Rust. Giá trị scalar của Unicode nằm trong khoảng từ U+0000 đến U+D7FFU+E000 đến U+10FFFF bao gồm. Tuy nhiên, "ký tự" không thực sự là một khái niệm trong Unicode, vì vậy trực giác của con người về "ký tự" có thể không khớp với khái niệm char trong Rust. Chúng ta sẽ thảo luận chi tiết về chủ đề này trong "Lưu Trữ Văn Bản Mã Hóa UTF-8 với Chuỗi" ở Chương 8.

Kiểu Phức Hợp

Kiểu phức hợp có thể nhóm nhiều giá trị thành một kiểu. Rust có hai kiểu phức hợp nguyên thủy: bộ giá trị (tuple) và mảng.

Kiểu Bộ Giá Trị

Một bộ giá trị là một cách chung để nhóm một số giá trị có nhiều kiểu khác nhau vào một kiểu phức hợp. Bộ giá trị có độ dài cố định: một khi được khai báo, chúng không thể tăng hoặc giảm kích thước.

Chúng ta tạo một bộ giá trị bằng cách viết một danh sách các giá trị được phân tách bằng dấu phẩy bên trong dấu ngoặc đơn. Mỗi vị trí trong bộ giá trị có một kiểu, và các kiểu của các giá trị khác nhau trong bộ giá trị không nhất thiết phải giống nhau. Chúng tôi đã thêm chú thích kiểu tùy chọn trong ví dụ này:

Tên tập tin: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

Biến tup gắn với toàn bộ bộ giá trị vì bộ giá trị được coi là một phần tử phức hợp đơn lẻ. Để lấy các giá trị riêng lẻ từ một bộ giá trị, chúng ta có thể sử dụng pattern matching để phá hủy cấu trúc một giá trị bộ giá trị, như sau:

Tên tập tin: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

Chương trình này đầu tiên tạo ra một bộ giá trị và gắn nó với biến tup. Sau đó, nó sử dụng một pattern với let để lấy tup và biến nó thành ba biến riêng biệt, x, yz. Điều này được gọi là phá hủy cấu trúc vì nó chia một bộ giá trị thành ba phần. Cuối cùng, chương trình in giá trị của y, là 6.4.

Chúng ta cũng có thể truy cập trực tiếp một phần tử của bộ giá trị bằng cách sử dụng dấu chấm (.) theo sau là chỉ số của giá trị mà chúng ta muốn truy cập. Ví dụ:

Tên tập tin: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

Chương trình này tạo ra bộ giá trị x và sau đó truy cập vào mỗi phần tử của bộ giá trị bằng cách sử dụng các chỉ số tương ứng của chúng. Như với hầu hết các ngôn ngữ lập trình, chỉ số đầu tiên trong bộ giá trị là 0.

Bộ giá trị không có giá trị nào có một tên đặc biệt, unit. Giá trị này và kiểu tương ứng của nó đều được viết là () và đại diện cho một giá trị trống hoặc một kiểu trả về trống. Biểu thức ngầm định trả về giá trị unit nếu chúng không trả về bất kỳ giá trị nào khác.

Kiểu Mảng

Một cách khác để có một tập hợp gồm nhiều giá trị là với một mảng. Không giống như bộ giá trị, mọi phần tử của mảng phải có cùng kiểu. Không giống như mảng trong một số ngôn ngữ khác, mảng trong Rust có độ dài cố định.

Chúng ta viết các giá trị trong một mảng như một danh sách được phân tách bằng dấu phẩy bên trong dấu ngoặc vuông:

Tên tập tin: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Mảng hữu ích khi bạn muốn dữ liệu của mình được phân bổ trên stack, giống như các kiểu khác mà chúng ta đã thấy cho đến nay, thay vì trên heap (chúng ta sẽ thảo luận thêm về stack và heap trong Chương 4) hoặc khi bạn muốn đảm bảo rằng bạn luôn có một số lượng phần tử cố định. Mảng không linh hoạt như kiểu vector, tuy nhiên. Một vector là một kiểu tập hợp tương tự được cung cấp bởi thư viện chuẩn được phép tăng hoặc giảm kích thước vì nội dung của nó nằm trên heap. Nếu bạn không chắc liệu nên sử dụng một mảng hay một vector, khả năng là bạn nên sử dụng một vector. Chương 8 thảo luận chi tiết hơn về vector.

Tuy nhiên, mảng hữu ích hơn khi bạn biết số lượng phần tử sẽ không cần thay đổi. Ví dụ, nếu bạn đang sử dụng tên của các tháng trong một chương trình, bạn có lẽ sẽ sử dụng một mảng thay vì một vector vì bạn biết nó sẽ luôn chứa 12 phần tử:

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

Bạn viết kiểu của một mảng bằng cách sử dụng dấu ngoặc vuông với kiểu của mỗi phần tử, dấu chấm phẩy, và sau đó là số phần tử trong mảng, như sau:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

Ở đây, i32 là kiểu của mỗi phần tử. Sau dấu chấm phẩy, số 5 cho biết mảng chứa năm phần tử.

Bạn cũng có thể khởi tạo một mảng để chứa cùng một giá trị cho mỗi phần tử bằng cách chỉ định giá trị ban đầu, theo sau là dấu chấm phẩy, và sau đó là độ dài của mảng trong dấu ngoặc vuông, như được hiển thị ở đây:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

Mảng có tên a sẽ chứa 5 phần tử mà tất cả sẽ được thiết lập ban đầu với giá trị 3. Điều này tương đương với việc viết let a = [3, 3, 3, 3, 3]; nhưng theo cách ngắn gọn hơn.

Truy Cập Phần Tử Mảng

Một mảng là một khối bộ nhớ đơn với kích thước đã biết, cố định có thể được cấp phát trên stack. Bạn có thể truy cập các phần tử của mảng bằng cách sử dụng chỉ số, như sau:

Tên tập tin: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

Trong ví dụ này, biến có tên first sẽ nhận giá trị 1 vì đó là giá trị tại chỉ số [0] trong mảng. Biến có tên second sẽ nhận giá trị 2 từ chỉ số [1] trong mảng.

Truy Cập Phần Tử Mảng Không Hợp Lệ

Hãy xem điều gì xảy ra nếu bạn cố gắng truy cập một phần tử của mảng ở ngoài cuối mảng. Giả sử bạn chạy mã này, tương tự như trò chơi đoán số ở Chương 2, để nhận chỉ số mảng từ người dùng:

Tên tập tin: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

Mã này biên dịch thành công. Nếu bạn chạy mã này bằng cargo run và nhập 0, 1, 2, 3, hoặc 4, chương trình sẽ in ra giá trị tương ứng tại chỉ số đó trong mảng. Nếu thay vào đó bạn nhập một số ngoài cuối mảng, chẳng hạn như 10, bạn sẽ thấy đầu ra như sau:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Chương trình dẫn đến lỗi thời gian chạy ở điểm sử dụng một giá trị không hợp lệ trong phép toán đánh chỉ số. Chương trình kết thúc với một thông báo lỗi và không thực thi câu lệnh println! cuối cùng. Khi bạn cố gắng truy cập một phần tử bằng cách sử dụng chỉ số, Rust sẽ kiểm tra xem chỉ số mà bạn đã chỉ định có nhỏ hơn độ dài mảng hay không. Nếu chỉ số lớn hơn hoặc bằng độ dài, Rust sẽ panic. Kiểm tra này phải được thực hiện ở thời gian chạy, đặc biệt là trong trường hợp này, vì trình biên dịch không thể nào biết được người dùng sẽ nhập giá trị nào khi họ chạy mã sau này.

Đây là một ví dụ về nguyên tắc an toàn bộ nhớ của Rust trong hành động. Trong nhiều ngôn ngữ bậc thấp, loại kiểm tra này không được thực hiện, và khi bạn cung cấp một chỉ số không chính xác, bộ nhớ không hợp lệ có thể bị truy cập. Rust bảo vệ bạn khỏi loại lỗi này bằng cách thoát ngay lập tức thay vì cho phép truy cập bộ nhớ và tiếp tục. Chương 9 thảo luận thêm về xử lý lỗi của Rust và cách bạn có thể viết mã an toàn, dễ đọc mà không panic cũng không cho phép truy cập bộ nhớ không hợp lệ.

Các Hàm

Hàm xuất hiện phổ biến trong mã Rust. Bạn đã thấy một trong những hàm quan trọng nhất trong ngôn ngữ này: hàm main, đó là điểm khởi đầu của nhiều chương trình. Bạn cũng đã thấy từ khóa fn, cho phép bạn khai báo các hàm mới.

Mã Rust sử dụng snake case là quy ước phong cách cho tên hàm và tên biến, trong đó tất cả các chữ cái đều là chữ thường và dấu gạch dưới phân tách các từ. Đây là một chương trình chứa ví dụ về định nghĩa hàm:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Chúng ta định nghĩa một hàm trong Rust bằng cách nhập fn theo sau là tên hàm và một tập hợp dấu ngoặc đơn. Dấu ngoặc nhọn cho trình biên dịch biết nơi bắt đầu và kết thúc thân hàm.

Chúng ta có thể gọi bất kỳ hàm nào mà chúng ta đã định nghĩa bằng cách nhập tên hàm theo sau bởi tập hợp dấu ngoặc đơn. Vì another_function được định nghĩa trong chương trình, nó có thể được gọi từ bên trong hàm main. Lưu ý rằng chúng ta đã định nghĩa another_function sau hàm main trong mã nguồn; chúng ta cũng có thể đã định nghĩa nó trước. Rust không quan tâm bạn định nghĩa hàm của mình ở đâu, chỉ cần chúng được định nghĩa ở đâu đó trong một phạm vi mà người gọi có thể nhìn thấy.

Hãy bắt đầu một dự án nhị phân mới có tên là functions để khám phá hàm sâu hơn. Đặt ví dụ another_function trong src/main.rs và chạy nó. Bạn sẽ thấy kết quả sau:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

Các dòng thực thi theo thứ tự chúng xuất hiện trong hàm main. Đầu tiên, thông báo "Hello, world!" được in ra, và sau đó another_function được gọi và thông báo của nó được in ra.

Tham số

Chúng ta có thể định nghĩa hàm có tham số, đó là các biến đặc biệt là một phần của chữ ký hàm. Khi một hàm có tham số, bạn có thể cung cấp cho nó các giá trị cụ thể cho các tham số đó. Về mặt kỹ thuật, các giá trị cụ thể được gọi là đối số, nhưng trong cuộc trò chuyện thông thường, mọi người thường sử dụng từ tham sốđối số thay thế cho nhau cho cả biến trong định nghĩa một hàm hoặc các giá trị cụ thể được truyền vào khi bạn gọi một hàm.

Trong phiên bản này của another_function, chúng ta thêm một tham số:

Filename: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

Hãy thử chạy chương trình này; bạn sẽ nhận được kết quả sau:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

Khai báo của another_function có một tham số có tên là x. Kiểu của x được xác định là i32. Khi chúng ta truyền 5 vào another_function, macro println! đặt 5 vào nơi có cặp dấu ngoặc nhọn chứa x trong chuỗi định dạng.

Trong chữ ký hàm, bạn phải khai báo kiểu của mỗi tham số. Đây là một quyết định có chủ đích trong thiết kế của Rust: yêu cầu chú thích kiểu trong định nghĩa hàm có nghĩa là trình biên dịch hầu như không cần bạn sử dụng chúng ở nơi khác trong mã để xác định kiểu bạn muốn. Trình biên dịch cũng có thể đưa ra thông báo lỗi hữu ích hơn nếu nó biết kiểu nào mà hàm mong đợi.

Khi định nghĩa nhiều tham số, hãy phân tách khai báo tham số bằng dấu phẩy, như thế này:

Filename: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

Ví dụ này tạo ra một hàm có tên là print_labeled_measurement với hai tham số. Tham số đầu tiên có tên là value và là một i32. Tham số thứ hai có tên là unit_label và có kiểu char. Sau đó hàm in ra văn bản chứa cả valueunit_label.

Hãy thử chạy mã này. Thay thế chương trình hiện có trong tập tin src/main.rs của dự án functions bằng ví dụ trước đó và chạy nó bằng cargo run:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

Vì chúng ta đã gọi hàm với 5 là giá trị cho value'h' là giá trị cho unit_label, kết quả chương trình chứa những giá trị đó.

Câu lệnh và Biểu thức

Phần thân hàm được cấu thành từ một chuỗi các câu lệnh, tùy chọn kết thúc bằng một biểu thức. Cho đến nay, các hàm chúng ta đã đề cập chưa bao gồm một biểu thức kết thúc, nhưng bạn đã thấy một biểu thức như một phần của một câu lệnh. Bởi vì Rust là một ngôn ngữ dựa trên biểu thức, đây là một sự phân biệt quan trọng cần hiểu. Các ngôn ngữ khác không có sự phân biệt giống nhau, vì vậy hãy xem xét câu lệnh và biểu thức là gì và cách sự khác biệt của chúng ảnh hưởng đến phần thân của các hàm.

  • Câu lệnh là những chỉ thị thực hiện một số hành động và không trả về giá trị.
  • Biểu thức đánh giá thành một giá trị kết quả.

Hãy xem một số ví dụ.

Chúng ta thực sự đã sử dụng câu lệnh và biểu thức rồi. Việc tạo ra một biến và gán giá trị cho nó với từ khóa let là một câu lệnh. Trong Listing 3-1, let y = 6; là một câu lệnh.

fn main() {
    let y = 6;
}

Định nghĩa hàm cũng là câu lệnh; toàn bộ ví dụ trước đó là một câu lệnh tự nó. (Như chúng ta sẽ thấy dưới đây, gọi một hàm không phải là một câu lệnh, tuy nhiên.)

Câu lệnh không trả về giá trị. Do đó, bạn không thể gán một câu lệnh let cho một biến khác, như mã sau cố gắng làm; bạn sẽ gặp lỗi:

Filename: src/main.rs

fn main() {
    let x = (let y = 6);
}

Khi bạn chạy chương trình này, lỗi bạn sẽ nhận được trông như thế này:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

Câu lệnh let y = 6 không trả về giá trị, vì vậy không có gì để x ràng buộc. Điều này khác với những gì xảy ra trong các ngôn ngữ khác, chẳng hạn như C và Ruby, nơi phép gán trả về giá trị của phép gán. Trong các ngôn ngữ đó, bạn có thể viết x = y = 6 và cả xy đều có giá trị 6; điều đó không đúng trong Rust.

Biểu thức đánh giá thành một giá trị và tạo nên phần lớn phần còn lại của mã mà bạn sẽ viết trong Rust. Hãy xem xét một phép toán toán học, chẳng hạn như 5 + 6, đó là một biểu thức đánh giá thành giá trị 11. Biểu thức có thể là một phần của câu lệnh: trong Listing 3-1, 6 trong câu lệnh let y = 6; là một biểu thức đánh giá thành giá trị 6. Gọi một hàm là một biểu thức. Gọi một macro là một biểu thức. Một khối phạm vi mới được tạo ra với dấu ngoặc nhọn là một biểu thức, ví dụ:

Filename: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

Biểu thức này:

{
    let x = 3;
    x + 1
}

là một khối mà trong trường hợp này, đánh giá thành 4. Giá trị đó được ràng buộc với y như một phần của câu lệnh let. Lưu ý rằng dòng x + 1 không có dấu chấm phẩy ở cuối, khác với hầu hết các dòng mà bạn đã thấy cho đến nay. Biểu thức không bao gồm dấu chấm phẩy kết thúc. Nếu bạn thêm dấu chấm phẩy vào cuối một biểu thức, bạn biến nó thành một câu lệnh, và nó sẽ không trả về giá trị nữa. Hãy ghi nhớ điều này khi bạn khám phá giá trị trả về của hàm và biểu thức tiếp theo.

Hàm với Giá trị Trả về

Hàm có thể trả về giá trị cho mã gọi chúng. Chúng ta không đặt tên cho các giá trị trả về, nhưng chúng ta phải khai báo kiểu của chúng sau một mũi tên (->). Trong Rust, giá trị trả về của hàm đồng nghĩa với giá trị của biểu thức cuối cùng trong khối của phần thân hàm. Bạn có thể trả về sớm từ một hàm bằng cách sử dụng từ khóa return và chỉ định một giá trị, nhưng hầu hết các hàm đều trả về biểu thức cuối cùng một cách ngầm định. Đây là một ví dụ về một hàm trả về một giá trị:

Filename: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

Không có lời gọi hàm, macro, hay thậm chí câu lệnh let trong hàm five—chỉ có số 5 đứng một mình. Đó là một hàm hoàn toàn hợp lệ trong Rust. Lưu ý rằng kiểu trả về của hàm cũng được chỉ định, là -> i32. Hãy thử chạy mã này; kết quả sẽ trông như thế này:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

Số 5 trong five là giá trị trả về của hàm, đó là lý do tại sao kiểu trả về là i32. Hãy xem xét điều này chi tiết hơn. Có hai phần quan trọng: đầu tiên, dòng let x = five(); cho thấy rằng chúng ta đang sử dụng giá trị trả về của một hàm để khởi tạo một biến. Bởi vì hàm five trả về 5, dòng đó giống như sau:

#![allow(unused)]
fn main() {
let x = 5;
}

Thứ hai, hàm five không có tham số và định nghĩa kiểu của giá trị trả về, nhưng phần thân của hàm chỉ là một 5 cô đơn không có dấu chấm phẩy vì đó là một biểu thức mà chúng ta muốn trả về giá trị.

Hãy xem một ví dụ khác:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

Chạy mã này sẽ in ra The value of x is: 6. Nhưng nếu chúng ta đặt một dấu chấm phẩy ở cuối dòng chứa x + 1, biến nó từ một biểu thức thành một câu lệnh, chúng ta sẽ nhận được lỗi:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

Biên dịch mã này tạo ra lỗi như sau:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` (bin "functions") due to 1 previous error

Thông báo lỗi chính, mismatched types (không khớp kiểu), tiết lộ vấn đề cốt lõi với mã này. Định nghĩa của hàm plus_one nói rằng nó sẽ trả về một i32, nhưng câu lệnh không đánh giá thành một giá trị, được biểu thị bởi (), kiểu đơn vị. Do đó, không có gì được trả về, mâu thuẫn với định nghĩa hàm và dẫn đến lỗi. Trong kết quả này, Rust cung cấp một thông báo để có thể giúp khắc phục vấn đề này: nó đề xuất loại bỏ dấu chấm phẩy, điều đó sẽ khắc phục lỗi.

Chú thích

Tất cả các lập trình viên đều cố gắng làm cho mã của họ dễ hiểu, nhưng đôi khi cần có những giải thích thêm. Trong những trường hợp này, lập trình viên để lại chú thích (comments) trong mã nguồn mà trình biên dịch sẽ bỏ qua nhưng người đọc mã nguồn có thể thấy hữu ích.

Đây là một chú thích đơn giản:

#![allow(unused)]
fn main() {
// xin chào, thế giới
}

Trong Rust, phong cách chú thích thông dụng bắt đầu bằng hai dấu gạch chéo, và chú thích tiếp tục đến hết dòng. Đối với các chú thích kéo dài hơn một dòng, bạn cần phải thêm // ở mỗi dòng, như thế này:

#![allow(unused)]
fn main() {
// Chúng ta đang làm một điều gì đó phức tạp ở đây, đủ dài để chúng ta cần
// nhiều dòng chú thích để làm điều đó! Ồ! Hy vọng rằng, chú thích này sẽ
// giải thích những gì đang diễn ra.
}

Chú thích cũng có thể được đặt ở cuối dòng chứa mã:

Tên tệp: src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

Nhưng bạn sẽ thường thấy chúng được sử dụng trong định dạng này, với chú thích trên một dòng riêng phía trên mã mà nó đang chú thích:

Tên tệp: src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

Rust cũng có một loại chú thích khác, chú thích tài liệu, mà chúng ta sẽ thảo luận trong phần "Xuất bản một Crate lên Crates.io" của Chương 14.

Luồng Điều Khiển

Khả năng chạy một số mã tùy thuộc vào việc một điều kiện là true và chạy một số mã lặp đi lặp lại trong khi một điều kiện là true là những khối xây dựng cơ bản trong hầu hết các ngôn ngữ lập trình. Các cấu trúc phổ biến nhất cho phép bạn kiểm soát luồng thực thi của mã Rust là biểu thức if và vòng lặp.

Biểu Thức if

Biểu thức if cho phép bạn phân nhánh mã của mình dựa trên các điều kiện. Bạn cung cấp một điều kiện và sau đó nói, "Nếu điều kiện này được đáp ứng, hãy chạy khối mã này. Nếu điều kiện không được đáp ứng, đừng chạy khối mã này."

Tạo một dự án mới có tên branches trong thư mục projects của bạn để khám phá biểu thức if. Trong tệp src/main.rs, nhập nội dung sau:

Tên tệp: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Tất cả các biểu thức if bắt đầu bằng từ khóa if, theo sau là một điều kiện. Trong trường hợp này, điều kiện kiểm tra xem biến number có giá trị nhỏ hơn 5 hay không. Chúng ta đặt khối mã để thực thi nếu điều kiện là true ngay sau điều kiện bên trong dấu ngoặc nhọn. Các khối mã liên kết với các điều kiện trong biểu thức if đôi khi được gọi là nhánh, giống như các nhánh trong biểu thức match mà chúng ta đã thảo luận trong phần "So Sánh Dự Đoán với Số Bí Mật" của Chương 2.

Tùy chọn, chúng ta cũng có thể bao gồm một biểu thức else, mà chúng ta đã chọn ở đây, để cung cấp cho chương trình một khối mã thay thế để thực thi nếu điều kiện đánh giá thành false. Nếu bạn không cung cấp một biểu thức else và điều kiện là false, chương trình sẽ bỏ qua khối if và chuyển sang đoạn mã tiếp theo.

Hãy thử chạy mã này; bạn sẽ thấy đầu ra sau:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

Hãy thử thay đổi giá trị của number thành một giá trị khiến điều kiện thành false để xem điều gì xảy ra:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

Chạy chương trình lần nữa và xem đầu ra:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

Cũng cần lưu ý rằng điều kiện trong mã này phải là một bool. Nếu điều kiện không phải là bool, chúng ta sẽ gặp lỗi. Ví dụ, hãy thử chạy mã sau:

Tên tệp: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

Điều kiện if đánh giá thành một giá trị 3 lần này, và Rust báo lỗi:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

Lỗi chỉ ra rằng Rust mong đợi một bool nhưng nhận được một số nguyên. Không giống như các ngôn ngữ như Ruby và JavaScript, Rust sẽ không tự động chuyển đổi các kiểu không phải Boolean thành Boolean. Bạn phải rõ ràng và luôn cung cấp cho if một Boolean làm điều kiện của nó. Nếu chúng ta muốn khối mã if chạy chỉ khi một số không bằng 0, ví dụ, chúng ta có thể thay đổi biểu thức if thành như sau:

Tên tệp: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

Chạy mã này sẽ in ra number was something other than zero.

Xử Lý Nhiều Điều Kiện với else if

Bạn có thể sử dụng nhiều điều kiện bằng cách kết hợp ifelse trong một biểu thức else if. Ví dụ:

Tên tệp: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

Chương trình này có bốn đường dẫn có thể thực hiện. Sau khi chạy nó, bạn sẽ thấy đầu ra sau:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

Khi chương trình này thực thi, nó kiểm tra từng biểu thức if lần lượt và thực thi thân đầu tiên mà điều kiện đánh giá thành true. Lưu ý rằng mặc dù 6 chia hết cho 2, chúng ta không thấy đầu ra number is divisible by 2, cũng không thấy văn bản number is not divisible by 4, 3, or 2 từ khối else. Đó là vì Rust chỉ thực thi khối cho điều kiện true đầu tiên, và khi tìm thấy một điều kiện, nó thậm chí không kiểm tra phần còn lại.

Sử dụng quá nhiều biểu thức else if có thể làm rối mã của bạn, vì vậy nếu bạn có nhiều hơn một, bạn có thể muốn cấu trúc lại mã của mình. Chương 6 mô tả một cấu trúc phân nhánh mạnh mẽ của Rust gọi là match cho những trường hợp này.

Sử dụng if trong một câu lệnh let

if là một biểu thức, chúng ta có thể sử dụng nó ở phía bên phải của câu lệnh let để gán kết quả cho một biến, như trong Listing 3-2.

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}

Biến number sẽ được gắn với một giá trị dựa trên kết quả của biểu thức if. Chạy mã này để xem điều gì xảy ra:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

Hãy nhớ rằng các khối mã đánh giá thành biểu thức cuối cùng trong chúng, và các số tự chúng cũng là biểu thức. Trong trường hợp này, giá trị của toàn bộ biểu thức if phụ thuộc vào khối mã nào được thực thi. Điều này có nghĩa là các giá trị có khả năng là kết quả từ mỗi nhánh của if phải cùng một loại; trong Listing 3-2, kết quả của cả nhánh if và nhánh else đều là số nguyên i32. Nếu các loại không khớp, như trong ví dụ sau, chúng ta sẽ gặp lỗi:

Tên tệp: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

Khi cố gắng biên dịch mã này, chúng ta sẽ gặp lỗi. Các nhánh ifelse có kiểu giá trị không tương thích, và Rust chỉ ra chính xác nơi tìm thấy vấn đề trong chương trình:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

Biểu thức trong khối if đánh giá thành một số nguyên, và biểu thức trong khối else đánh giá thành một chuỗi. Điều này sẽ không hoạt động vì các biến phải có một kiểu duy nhất, và Rust cần biết tại thời điểm biên dịch kiểu biến number là gì, một cách chắc chắn. Biết kiểu của number cho phép trình biên dịch xác minh kiểu là hợp lệ ở mọi nơi chúng ta sử dụng number. Rust sẽ không thể làm điều đó nếu kiểu của number chỉ được xác định trong thời gian chạy; trình biên dịch sẽ phức tạp hơn và sẽ cung cấp ít đảm bảo hơn về mã nếu phải theo dõi nhiều kiểu giả định cho bất kỳ biến nào.

Lặp Lại với Vòng Lặp

Thường rất hữu ích để thực thi một khối mã nhiều lần. Cho nhiệm vụ này, Rust cung cấp một số vòng lặp, sẽ chạy qua mã bên trong thân vòng lặp đến cuối và sau đó bắt đầu lại từ đầu ngay lập tức. Để thử nghiệm với vòng lặp, hãy tạo một dự án mới có tên loops.

Rust có ba loại vòng lặp: loop, while, và for. Hãy thử từng loại.

Lặp Lại Mã với loop

Từ khóa loop yêu cầu Rust thực thi một khối mã lặp đi lặp lại mãi mãi hoặc cho đến khi bạn yêu cầu nó dừng lại.

Ví dụ, hãy thay đổi tệp src/main.rs trong thư mục loops của bạn để trông như thế này:

Tên tệp: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

Khi chúng ta chạy chương trình này, chúng ta sẽ thấy again! được in liên tục cho đến khi chúng ta dừng chương trình bằng tay. Hầu hết các terminal hỗ trợ phím tắt ctrl-c để ngắt một chương trình bị kẹt trong một vòng lặp liên tục. Hãy thử:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

Ký hiệu ^C biểu thị nơi bạn đã nhấn ctrl-c.

Bạn có thể sẽ thấy hoặc không thấy từ again! được in sau ^C, tùy thuộc vào vị trí mã trong vòng lặp khi nó nhận được tín hiệu ngắt.

May mắn thay, Rust cũng cung cấp một cách để thoát khỏi vòng lặp bằng mã. Bạn có thể đặt từ khóa break trong vòng lặp để yêu cầu chương trình dừng thực thi vòng lặp. Hãy nhớ rằng chúng ta đã làm điều này trong trò chơi đoán số ở phần "Thoát Sau Khi Đoán Đúng" của Chương 2 để thoát chương trình khi người dùng thắng trò chơi bằng cách đoán đúng số.

Chúng ta cũng đã sử dụng continue trong trò chơi đoán số, trong một vòng lặp nó bảo chương trình bỏ qua bất kỳ mã còn lại nào trong lần lặp này của vòng lặp và chuyển sang lần lặp tiếp theo.

Trả Về Giá Trị từ Vòng Lặp

Một trong những cách sử dụng của loop là thử lại một hoạt động mà bạn biết có thể thất bại, chẳng hạn như kiểm tra xem một luồng đã hoàn thành công việc của nó hay chưa. Bạn cũng có thể cần truyền kết quả của hoạt động đó ra khỏi vòng lặp đến phần còn lại của mã của bạn. Để làm điều này, bạn có thể thêm giá trị bạn muốn trả về sau biểu thức break bạn sử dụng để dừng vòng lặp; giá trị đó sẽ được trả về từ vòng lặp để bạn có thể sử dụng nó, như được hiển thị ở đây:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

Trước vòng lặp, chúng ta khai báo một biến có tên counter và khởi tạo nó thành 0. Sau đó, chúng ta khai báo một biến có tên result để giữ giá trị trả về từ vòng lặp. Trong mỗi lần lặp của vòng lặp, chúng ta thêm 1 vào biến counter, và sau đó kiểm tra xem counter có bằng 10 không. Khi nó bằng 10, chúng ta sử dụng từ khóa break với giá trị counter * 2. Sau vòng lặp, chúng ta sử dụng dấu chấm phẩy để kết thúc câu lệnh gán giá trị cho result. Cuối cùng, chúng ta in giá trị trong result, trong trường hợp này là 20.

Bạn cũng có thể return từ bên trong một vòng lặp. Trong khi break chỉ thoát khỏi vòng lặp hiện tại, return luôn thoát khỏi hàm hiện tại.

Nhãn Vòng Lặp để Phân Biệt Giữa Nhiều Vòng Lặp

Nếu bạn có vòng lặp trong vòng lặp, breakcontinue áp dụng cho vòng lặp bên trong nhất tại thời điểm đó. Bạn có thể tùy chọn chỉ định một nhãn vòng lặp trên một vòng lặp mà bạn có thể sử dụng với break hoặc continue để chỉ định rằng những từ khóa đó áp dụng cho vòng lặp được gắn nhãn thay vì vòng lặp bên trong nhất. Nhãn vòng lặp phải bắt đầu bằng một dấu nháy đơn. Đây là một ví dụ với hai vòng lặp lồng nhau:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

Vòng lặp bên ngoài có nhãn 'counting_up, và nó sẽ đếm lên từ 0 đến 2. Vòng lặp bên trong không có nhãn đếm ngược từ 10 đến 9. break đầu tiên không chỉ định nhãn sẽ chỉ thoát khỏi vòng lặp bên trong. Câu lệnh break 'counting_up; sẽ thoát khỏi vòng lặp bên ngoài. Mã này in:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

Vòng Lặp Có Điều Kiện với while

Một chương trình thường cần đánh giá một điều kiện trong một vòng lặp. Trong khi điều kiện là true, vòng lặp chạy. Khi điều kiện không còn là true, chương trình gọi break, dừng vòng lặp. Có thể thực hiện hành vi như thế này bằng cách kết hợp loop, if, else, và break; bạn có thể thử điều đó bây giờ trong một chương trình, nếu bạn muốn. Tuy nhiên, mẫu này rất phổ biến nên Rust có một cấu trúc ngôn ngữ tích hợp cho nó, gọi là vòng lặp while. Trong Listing 3-3, chúng ta sử dụng while để chạy chương trình ba lần, đếm ngược mỗi lần, và sau đó, sau vòng lặp, in một thông báo và thoát.

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

Cấu trúc này loại bỏ rất nhiều việc lồng nhau sẽ cần thiết nếu bạn sử dụng loop, if, else, và break, và nó rõ ràng hơn. Trong khi một điều kiện đánh giá thành true, mã chạy; nếu không, nó thoát khỏi vòng lặp.

Lặp Qua một Tập Hợp với for

Bạn có thể chọn sử dụng cấu trúc while để lặp qua các phần tử của một tập hợp, chẳng hạn như một mảng. Ví dụ, vòng lặp trong Listing 3-4 in ra từng phần tử trong mảng a.

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}

Ở đây, mã đếm qua các phần tử trong mảng. Nó bắt đầu từ chỉ mục 0, và sau đó lặp cho đến khi đạt đến chỉ mục cuối cùng trong mảng (đó là khi index < 5 không còn true nữa). Chạy mã này sẽ in mọi phần tử trong mảng:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

Tất cả năm giá trị mảng xuất hiện trong terminal, như mong đợi. Mặc dù index sẽ đạt đến giá trị 5 tại một thời điểm nào đó, vòng lặp dừng thực thi trước khi cố gắng lấy giá trị thứ sáu từ mảng.

Tuy nhiên, cách tiếp cận này dễ xảy ra lỗi; chúng ta có thể khiến chương trình hoảng sợ nếu giá trị chỉ mục hoặc điều kiện kiểm tra không chính xác. Ví dụ, nếu bạn thay đổi định nghĩa của mảng a để có bốn phần tử nhưng quên cập nhật điều kiện thành while index < 4, mã sẽ hoảng sợ. Nó cũng chậm, vì trình biên dịch thêm mã thời gian chạy để thực hiện kiểm tra điều kiện xem chỉ mục có nằm trong giới hạn của mảng trong mỗi lần lặp qua vòng lặp không.

Là một lựa chọn thay thế ngắn gọn hơn, bạn có thể sử dụng vòng lặp for và thực thi một số mã cho mỗi phần tử trong một tập hợp. Một vòng lặp for trông giống như mã trong Listing 3-5.

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}

Khi chúng ta chạy mã này, chúng ta sẽ thấy đầu ra giống như trong Listing 3-4. Quan trọng hơn, bây giờ chúng ta đã tăng độ an toàn của mã và loại bỏ khả năng xảy ra lỗi có thể dẫn đến vượt quá cuối mảng hoặc không đi đủ xa và bỏ qua một số phần tử. Mã máy được tạo từ vòng lặp for cũng có thể hiệu quả hơn, vì chỉ mục không cần phải so sánh với độ dài của mảng ở mỗi lần lặp.

Sử dụng vòng lặp for, bạn sẽ không cần phải nhớ thay đổi bất kỳ mã nào khác nếu bạn thay đổi số lượng giá trị trong mảng, như bạn sẽ làm với phương pháp được sử dụng trong Listing 3-4.

Sự an toàn và ngắn gọn của vòng lặp for làm cho chúng trở thành cấu trúc vòng lặp được sử dụng phổ biến nhất trong Rust. Ngay cả trong những tình huống mà bạn muốn chạy một số mã một số lần nhất định, như trong ví dụ đếm ngược sử dụng vòng lặp while trong Listing 3-3, hầu hết người dùng Rust sẽ sử dụng vòng lặp for. Cách để làm điều đó sẽ là sử dụng một Range, được cung cấp bởi thư viện tiêu chuẩn, tạo ra tất cả các số theo thứ tự bắt đầu từ một số và kết thúc trước một số khác.

Đây là cách đếm ngược sẽ trông như thế nào khi sử dụng vòng lặp for và một phương pháp khác mà chúng ta chưa nói đến, rev, để đảo ngược phạm vi:

Tên tệp: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

Mã này tốt hơn một chút, phải không?

Tóm Tắt

Bạn đã làm được! Đây là một chương đáng kể: bạn đã học về biến, kiểu dữ liệu vô hướng và phức hợp, hàm, nhận xét, biểu thức if, và vòng lặp! Để thực hành với các khái niệm được thảo luận trong chương này, hãy thử xây dựng các chương trình để thực hiện các việc sau:

  • Chuyển đổi nhiệt độ giữa Fahrenheit và Celsius.
  • Tạo số Fibonacci thứ n.
  • In lời bài hát Giáng sinh "The Twelve Days of Christmas", tận dụng sự lặp lại trong bài hát.

Khi bạn sẵn sàng tiếp tục, chúng ta sẽ nói về một khái niệm trong Rust mà không thường tồn tại trong các ngôn ngữ lập trình khác: quyền sở hữu.

Hiểu về Ownership

Ownership (quyền sở hữu) là tính năng độc đáo nhất của Rust và có ảnh hưởng sâu sắc đến phần còn lại của ngôn ngữ. Nó cho phép Rust đảm bảo an toàn bộ nhớ mà không cần bộ thu gom rác (garbage collector), vì vậy việc hiểu cách ownership hoạt động là rất quan trọng. Trong chương này, chúng ta sẽ nói về ownership cũng như một số tính năng liên quan: borrowing (mượn), slices (lát cắt), và cách Rust bố trí dữ liệu trong bộ nhớ.

Quyền Sở Hữu Là Gì?

Quyền sở hữu (Ownership) là một tập hợp các quy tắc chi phối cách chương trình Rust quản lý bộ nhớ. Tất cả các chương trình đều phải quản lý cách sử dụng bộ nhớ máy tính khi chạy. Một số ngôn ngữ có cơ chế thu gom rác (garbage collection) thường xuyên tìm kiếm bộ nhớ không còn được sử dụng khi chương trình đang chạy; trong các ngôn ngữ khác, lập trình viên phải cấp phát và giải phóng bộ nhớ một cách rõ ràng. Rust sử dụng cách tiếp cận thứ ba: bộ nhớ được quản lý thông qua hệ thống quyền sở hữu với một tập hợp các quy tắc mà trình biên dịch kiểm tra. Nếu bất kỳ quy tắc nào bị vi phạm, chương trình sẽ không biên dịch được. Không có tính năng nào của quyền sở hữu sẽ làm chậm chương trình khi nó đang chạy.

Vì quyền sở hữu là một khái niệm mới đối với nhiều lập trình viên, nên cần một khoảng thời gian để làm quen. Tin tốt là càng có nhiều kinh nghiệm với Rust và các quy tắc của hệ thống quyền sở hữu, bạn sẽ càng dễ dàng phát triển một cách tự nhiên các đoạn mã an toàn và hiệu quả. Hãy cứ tiếp tục!

Khi bạn hiểu quyền sở hữu, bạn sẽ có một nền tảng vững chắc để hiểu các tính năng khiến Rust trở nên độc đáo. Trong chương này, bạn sẽ học về quyền sở hữu thông qua làm việc với một số ví dụ tập trung vào một cấu trúc dữ liệu rất phổ biến: chuỗi (strings).

Stack và Heap

Nhiều ngôn ngữ lập trình không yêu cầu bạn phải suy nghĩ về stack và heap thường xuyên. Nhưng trong một ngôn ngữ lập trình hệ thống như Rust, việc một giá trị nằm trên stack hay heap ảnh hưởng đến cách ngôn ngữ hoạt động và lý do bạn phải đưa ra những quyết định nhất định. Các phần của quyền sở hữu sẽ được mô tả liên quan đến stack và heap sau trong chương này, vì vậy đây là một giải thích ngắn gọn để chuẩn bị.

Cả stack và heap đều là những phần của bộ nhớ có sẵn để mã của bạn sử dụng trong thời gian chạy, nhưng chúng được cấu trúc theo những cách khác nhau. Stack lưu trữ các giá trị theo thứ tự nhận được và xóa các giá trị theo thứ tự ngược lại. Điều này được gọi là vào sau, ra trước (last in, first out). Hãy nghĩ về một chồng đĩa: khi bạn thêm đĩa, bạn đặt chúng lên trên cùng của chồng, và khi bạn cần một cái đĩa, bạn lấy một cái từ trên cùng. Việc thêm hoặc xóa đĩa từ giữa hoặc đáy sẽ không hiệu quả! Việc thêm dữ liệu được gọi là đẩy vào stack, và việc xóa dữ liệu được gọi là lấy ra khỏi stack. Tất cả dữ liệu được lưu trữ trên stack phải có kích thước đã biết và cố định. Dữ liệu có kích thước không xác định tại thời điểm biên dịch hoặc kích thước có thể thay đổi phải được lưu trữ trên heap thay thế.

Heap ít có tổ chức hơn: khi bạn đặt dữ liệu trên heap, bạn yêu cầu một lượng không gian nhất định. Bộ phân bổ bộ nhớ tìm một chỗ trống trong heap đủ lớn, đánh dấu nó là đang sử dụng, và trả về một con trỏ, đó là địa chỉ của vị trí đó. Quá trình này được gọi là cấp phát trên heap và đôi khi được viết tắt là chỉ cấp phát (việc đẩy các giá trị vào stack không được coi là cấp phát). Vì con trỏ đến heap có kích thước đã biết và cố định, bạn có thể lưu trữ con trỏ trên stack, nhưng khi bạn muốn dữ liệu thực tế, bạn phải đi theo con trỏ. Hãy nghĩ về việc được sắp xếp chỗ ngồi tại một nhà hàng. Khi bạn vào, bạn nói số người trong nhóm của bạn, và người phục vụ tìm một bàn trống vừa đủ cho mọi người và dẫn bạn đến đó. Nếu ai đó trong nhóm của bạn đến muộn, họ có thể hỏi bạn đã được xếp chỗ ở đâu để tìm bạn.

Đẩy vào stack nhanh hơn cấp phát trên heap vì bộ cấp phát không bao giờ phải tìm kiếm vị trí để lưu trữ dữ liệu mới; vị trí đó luôn ở đỉnh của stack. So sánh, việc cấp phát không gian trên heap đòi hỏi nhiều công việc hơn vì bộ cấp phát phải trước tiên tìm một không gian đủ lớn để chứa dữ liệu và sau đó thực hiện các công việc quản lý để chuẩn bị cho lần cấp phát tiếp theo.

Truy cập dữ liệu trong heap thường chậm hơn truy cập dữ liệu trên stack vì bạn phải đi theo con trỏ để đến đó. Các bộ xử lý hiện đại nhanh hơn nếu chúng nhảy ít hơn trong bộ nhớ. Tiếp tục ví dụ, hãy xem xét một người phục vụ tại nhà hàng đang nhận đơn đặt hàng từ nhiều bàn. Hiệu quả nhất là lấy tất cả đơn đặt hàng tại một bàn trước khi chuyển sang bàn tiếp theo. Việc nhận đơn đặt hàng từ bàn A, sau đó nhận đơn đặt hàng từ bàn B, sau đó quay lại A, và sau đó lại đến bàn B sẽ là một quá trình chậm hơn nhiều. Tương tự, bộ xử lý thường có thể làm công việc của mình tốt hơn nếu nó làm việc với dữ liệu gần với dữ liệu khác (như trên stack) thay vì xa hơn (như có thể trên heap).

Khi mã của bạn gọi một hàm, các giá trị được truyền vào hàm (bao gồm cả các con trỏ đến dữ liệu trên heap) và các biến cục bộ của hàm được đẩy vào stack. Khi hàm kết thúc, những giá trị này được lấy ra khỏi stack.

Theo dõi những phần mã nào đang sử dụng dữ liệu nào trên heap, giảm thiểu lượng dữ liệu trùng lặp trên heap, và dọn sạch dữ liệu không sử dụng trên heap để bạn không bị hết không gian, tất cả đều là những vấn đề mà quyền sở hữu giải quyết. Một khi bạn hiểu quyền sở hữu, bạn sẽ không cần phải nghĩ về stack và heap thường xuyên nữa, nhưng biết rằng mục đích chính của quyền sở hữu là quản lý dữ liệu heap có thể giúp giải thích lý do tại sao nó hoạt động như vậy.

Các Quy Tắc Quyền Sở Hữu

Đầu tiên, hãy xem xét các quy tắc quyền sở hữu. Ghi nhớ những quy tắc này khi chúng ta làm việc với các ví dụ minh họa:

  • Mỗi giá trị trong Rust có một chủ sở hữu.
  • Tại một thời điểm chỉ có thể có một chủ sở hữu.
  • Khi chủ sở hữu ra khỏi phạm vi, giá trị sẽ bị hủy.

Phạm Vi Biến

Bây giờ chúng ta đã vượt qua cú pháp Rust cơ bản, chúng ta sẽ không bao gồm tất cả mã fn main() { trong các ví dụ, vì vậy nếu bạn đang làm theo, hãy đảm bảo đặt các ví dụ sau vào hàm main một cách thủ công. Do đó, các ví dụ của chúng ta sẽ ngắn gọn hơn một chút, cho phép chúng ta tập trung vào các chi tiết thực tế hơn là mã boilerplate.

Như một ví dụ đầu tiên về quyền sở hữu, chúng ta sẽ xem xét phạm vi của một số biến. Một phạm vi là phạm vi trong một chương trình mà một mục có giá trị. Xét biến sau:

#![allow(unused)]
fn main() {
let s = "hello";
}

Biến s tham chiếu đến một chuỗi chữ, trong đó giá trị của chuỗi được mã hóa cứng vào văn bản của chương trình của chúng ta. Biến có giá trị từ điểm mà nó được khai báo cho đến khi kết thúc phạm vi hiện tại. Listing 4-1 cho thấy một chương trình với các chú thích cho biết nơi biến s sẽ có giá trị.

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

Nói cách khác, có hai điểm quan trọng về thời gian ở đây:

  • Khi s vào phạm vi, nó có giá trị.
  • Nó vẫn có giá trị cho đến khi nó ra khỏi phạm vi.

Ở điểm này, mối quan hệ giữa phạm vi và thời điểm các biến có giá trị tương tự như trong các ngôn ngữ lập trình khác. Bây giờ chúng ta sẽ xây dựng dựa trên hiểu biết này bằng cách giới thiệu kiểu dữ liệu String.

Kiểu String

Để minh họa các quy tắc của quyền sở hữu, chúng ta cần một kiểu dữ liệu phức tạp hơn những gì chúng ta đã đề cập trong phần "Kiểu Dữ Liệu" của Chương 3. Các kiểu được đề cập trước đó có kích thước đã biết, có thể được lưu trữ trên stack và lấy ra khỏi stack khi phạm vi của chúng kết thúc, và có thể được sao chép nhanh chóng và dễ dàng để tạo một phiên bản độc lập mới nếu một phần khác của mã cần sử dụng cùng giá trị trong phạm vi khác. Nhưng chúng ta muốn xem xét dữ liệu được lưu trữ trên heap và khám phá cách Rust biết khi nào dọn dẹp dữ liệu đó, và kiểu String là một ví dụ tuyệt vời.

Chúng ta sẽ tập trung vào các phần của String liên quan đến quyền sở hữu. Những khía cạnh này cũng áp dụng cho các kiểu dữ liệu phức tạp khác, dù chúng được cung cấp bởi thư viện chuẩn hay được tạo bởi bạn. Chúng ta sẽ thảo luận về String sâu hơn trong Chương 8.

Chúng ta đã thấy các chuỗi chữ, trong đó giá trị chuỗi được mã hóa cứng vào chương trình của chúng ta. Chuỗi chữ rất tiện lợi, nhưng chúng không phù hợp cho mọi tình huống mà chúng ta muốn sử dụng văn bản. Một lý do là chúng không thay đổi được. Một lý do khác là không phải mọi giá trị chuỗi đều có thể biết khi chúng ta viết mã của mình: ví dụ, nếu chúng ta muốn lấy đầu vào từ người dùng và lưu trữ nó? Đối với những tình huống này, Rust có một kiểu chuỗi thứ hai, String. Kiểu này quản lý dữ liệu được cấp phát trên heap và do đó có thể lưu trữ một lượng văn bản không xác định đối với chúng ta tại thời điểm biên dịch. Bạn có thể tạo một String từ một chuỗi chữ bằng cách sử dụng hàm from, như sau:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

Toán tử hai dấu hai chấm :: cho phép chúng ta đặt tên hàm from cụ thể này dưới kiểu String thay vì sử dụng một loại tên như string_from. Chúng ta sẽ thảo luận về cú pháp này nhiều hơn trong phần "Cú pháp Phương thức" của Chương 5, và khi chúng ta nói về không gian tên với các mô-đun trong "Đường dẫn để Tham chiếu đến một Mục trong Cây Mô-đun" trong Chương 7.

Loại chuỗi này có thể thay đổi:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

Vậy, sự khác biệt ở đây là gì? Tại sao String có thể thay đổi được nhưng các chuỗi chữ thì không? Sự khác biệt nằm ở cách hai loại này xử lý bộ nhớ.

Bộ Nhớ và Cấp Phát

Trong trường hợp của chuỗi chữ, chúng ta biết nội dung tại thời điểm biên dịch, vì vậy văn bản được mã hóa cứng trực tiếp vào tệp thực thi cuối cùng. Đây là lý do tại sao chuỗi chữ nhanh chóng và hiệu quả. Nhưng những thuộc tính này chỉ có từ tính không thay đổi của chuỗi chữ. Tiếc thay, chúng ta không thể đặt một khối bộ nhớ vào tệp nhị phân cho mỗi đoạn văn bản có kích thước không xác định tại thời điểm biên dịch và có kích thước có thể thay đổi trong khi chạy chương trình.

Với kiểu String, để hỗ trợ một đoạn văn bản có thể thay đổi và phát triển, chúng ta cần phân bổ một lượng bộ nhớ trên heap, không xác định tại thời điểm biên dịch, để chứa nội dung. Điều này có nghĩa:

  • Bộ nhớ phải được yêu cầu từ bộ phân bổ bộ nhớ tại thời điểm chạy.
  • Chúng ta cần một cách để trả lại bộ nhớ này cho bộ cấp phát khi chúng ta đã xong với String của mình.

Phần đầu tiên được thực hiện bởi chúng ta: khi chúng ta gọi String::from, việc triển khai của nó yêu cầu bộ nhớ mà nó cần. Điều này khá phổ biến trong lập trình ngôn ngữ.

Tuy nhiên, phần thứ hai là khác nhau. Trong các ngôn ngữ có bộ thu gom rác (GC), GC theo dõi và dọn dẹp bộ nhớ không còn được sử dụng nữa, và chúng ta không cần phải nghĩ về nó. Trong hầu hết các ngôn ngữ không có GC, trách nhiệm của chúng ta là xác định khi nào bộ nhớ không còn được sử dụng và gọi mã để giải phóng nó một cách rõ ràng, giống như chúng ta đã yêu cầu nó. Làm điều này một cách chính xác về mặt lịch sử là một vấn đề lập trình khó khăn. Nếu chúng ta quên, chúng ta sẽ lãng phí bộ nhớ. Nếu chúng ta làm quá sớm, chúng ta sẽ có một biến không hợp lệ. Nếu chúng ta làm hai lần, đó cũng là một lỗi. Chúng ta cần ghép chính xác một allocate với chính xác một free.

Rust đi theo một con đường khác: bộ nhớ được trả lại tự động một khi biến sở hữu nó ra khỏi phạm vi. Dưới đây là một phiên bản của ví dụ phạm vi từ Listing 4-1 sử dụng một String thay vì một chuỗi chữ:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

Có một điểm tự nhiên mà chúng ta có thể trả lại bộ nhớ mà String của chúng ta cần cho bộ cấp phát: khi s ra khỏi phạm vi. Khi một biến ra khỏi phạm vi, Rust gọi một hàm đặc biệt cho chúng ta. Hàm này được gọi là drop, và đó là nơi tác giả của String có thể đặt mã để trả lại bộ nhớ. Rust gọi drop tự động tại dấu ngoặc nhọn đóng.

Lưu ý: Trong C++, mô hình này của việc phân bổ tài nguyên tại cuối của vòng đời của một mục đôi khi được gọi là Resource Acquisition Is Initialization (RAII). Chức năng drop trong Rust sẽ quen thuộc với bạn nếu bạn đã sử dụng các mẫu RAII.

Mô hình này có một tác động sâu sắc đến cách mã Rust được viết. Nó có vẻ đơn giản ngay bây giờ, nhưng hành vi của mã có thể bất ngờ trong các tình huống phức tạp hơn khi chúng ta muốn có nhiều biến sử dụng dữ liệu chúng ta đã cấp phát trên heap. Hãy khám phá một số tình huống đó ngay bây giờ.

Các Biến và Dữ Liệu Tương Tác với Move

Nhiều biến có thể tương tác với cùng một dữ liệu theo những cách khác nhau trong Rust. Hãy xem một ví dụ sử dụng một số nguyên trong Listing 4-2.

fn main() {
    let x = 5;
    let y = x;
}

Chúng ta có thể đoán được đoạn mã này đang làm gì: "gán giá trị 5 cho x; sau đó tạo một bản sao của giá trị trong x và gán nó cho y." Bây giờ chúng ta có hai biến, xy, và cả hai đều bằng 5. Đây thực sự là những gì đang xảy ra, bởi vì số nguyên là các giá trị đơn giản với kích thước đã biết, cố định, và hai giá trị 5 này được đẩy vào stack.

Bây giờ hãy xem phiên bản String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

Điều này trông rất giống, vì vậy chúng ta có thể giả định rằng cách nó hoạt động sẽ giống nhau: nghĩa là, dòng thứ hai sẽ tạo một bản sao của giá trị trong s1 và gán nó cho s2. Nhưng điều này không hoàn toàn là những gì xảy ra.

Hãy xem Hình 4-1 để xem điều gì đang xảy ra với String bên dưới bề mặt. Một String bao gồm ba phần, được hiển thị ở bên trái: một con trỏ đến bộ nhớ chứa nội dung của chuỗi, một độ dài, và một dung lượng. Nhóm dữ liệu này được lưu trữ trên stack. Ở bên phải là bộ nhớ trên heap chứa nội dung.

Hai bảng: bảng đầu tiên chứa biểu diễn của s1 trên
stack, bao gồm độ dài (5), dung lượng (5), và một con trỏ đến giá trị đầu tiên
trong bảng thứ hai. Bảng thứ hai chứa biểu diễn của
dữ liệu chuỗi trên heap, byte theo byte.

Hình 4-1: Biểu diễn trong bộ nhớ của một String chứa giá trị "hello" được gắn với s1

Độ dài là lượng bộ nhớ, tính bằng byte, mà nội dung của String đang sử dụng hiện tại. Dung lượng là tổng lượng bộ nhớ, tính bằng byte, mà String đã nhận từ bộ cấp phát. Sự khác biệt giữa độ dài và dung lượng là quan trọng, nhưng không phải trong ngữ cảnh này, vì vậy hiện tại, việc bỏ qua dung lượng là ổn.

Khi chúng ta gán s1 cho s2, dữ liệu String được sao chép, nghĩa là chúng ta sao chép con trỏ, độ dài và dung lượng nằm trên stack. Chúng ta không sao chép dữ liệu trên heap mà con trỏ trỏ tới. Nói cách khác, biểu diễn dữ liệu trong bộ nhớ trông giống như Hình 4-2.

Ba bảng: bảng s1 và s2 đại diện cho các chuỗi đó trên
stack, tương ứng, và cả hai đều trỏ đến cùng một dữ liệu chuỗi trên heap.

Hình 4-2: Biểu diễn trong bộ nhớ của biến s2 có một bản sao của con trỏ, độ dài và dung lượng của s1

Biểu diễn không trông giống như Hình 4-3, đây là cách bộ nhớ sẽ trông như thế nào nếu Rust cũng sao chép dữ liệu heap. Nếu Rust làm điều này, thao tác s2 = s1 có thể rất tốn kém về hiệu suất thời gian chạy nếu dữ liệu trên heap lớn.

Bốn bảng: hai bảng đại diện cho dữ liệu stack cho s1 và s2,
và mỗi bảng trỏ đến bản sao riêng của dữ liệu chuỗi trên heap.

Hình 4-3: Một khả năng khác cho những gì s2 = s1 có thể làm nếu Rust cũng sao chép dữ liệu heap

Trước đó, chúng ta đã nói rằng khi một biến ra khỏi phạm vi, Rust tự động gọi hàm drop và dọn sạch bộ nhớ heap cho biến đó. Nhưng Hình 4-2 cho thấy cả hai con trỏ dữ liệu đều trỏ đến cùng một vị trí. Đây là một vấn đề: khi s2s1 ra khỏi phạm vi, cả hai sẽ cố gắng giải phóng cùng một bộ nhớ. Điều này được gọi là lỗi giải phóng bộ nhớ hai lần và là một trong những lỗi an toàn bộ nhớ mà chúng ta đã đề cập trước đó. Giải phóng bộ nhớ hai lần có thể dẫn đến hư hỏng bộ nhớ, điều này có thể dẫn đến lỗ hổng bảo mật.

Để đảm bảo an toàn bộ nhớ, sau dòng let s2 = s1;, Rust coi s1 như không còn hợp lệ. Do đó, Rust không cần phải giải phóng bất cứ thứ gì khi s1 ra khỏi phạm vi. Kiểm tra những gì xảy ra khi bạn cố gắng sử dụng s1 sau khi s2 được tạo; nó sẽ không hoạt động:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Bạn sẽ nhận được lỗi như thế này vì Rust ngăn bạn sử dụng tham chiếu không hợp lệ:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Nếu bạn đã nghe về các thuật ngữ sao chép nông (shallow copy)sao chép sâu (deep copy) trong khi làm việc với các ngôn ngữ khác, khái niệm sao chép con trỏ, độ dài và dung lượng mà không sao chép dữ liệu có thể nghe giống như đang thực hiện sao chép nông. Nhưng vì Rust cũng làm cho biến đầu tiên không hợp lệ, nên thay vì được gọi là sao chép nông, nó được gọi là di chuyển (move). Trong ví dụ này, chúng ta sẽ nói rằng s1 đã được di chuyển vào s2. Vì vậy, những gì thực sự xảy ra được hiển thị trong Hình 4-4.

Ba bảng: bảng s1 và s2 đại diện cho các chuỗi đó trên
stack, tương ứng, và cả hai đều trỏ đến cùng một dữ liệu chuỗi trên heap.
Bảng s1 được tô màu xám vì s1 không còn hợp lệ; chỉ s2 có thể được sử dụng để
truy cập dữ liệu heap.

Hình 4-4: Biểu diễn trong bộ nhớ sau khi s1 đã bị vô hiệu

Điều đó giải quyết vấn đề của chúng ta! Với chỉ s2 hợp lệ, khi nó ra khỏi phạm vi, nó một mình sẽ giải phóng bộ nhớ, và chúng ta đã hoàn thành.

Ngoài ra, có một lựa chọn thiết kế được ngụ ý bởi điều này: Rust sẽ không bao giờ tự động tạo các bản sao "sâu" của dữ liệu của bạn. Do đó, bất kỳ tự động sao chép nào cũng có thể được giả định là có chi phí thấp về hiệu suất thời gian chạy.

Phạm Vi và Gán

Điều ngược lại cũng đúng cho mối quan hệ giữa phạm vi, quyền sở hữu và bộ nhớ được giải phóng thông qua hàm drop. Khi bạn gán một giá trị hoàn toàn mới cho một biến hiện có, Rust sẽ gọi drop và giải phóng bộ nhớ của giá trị ban đầu ngay lập tức. Xem xét mã này, ví dụ:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

Ban đầu chúng ta khai báo một biến s và gắn nó với một String có giá trị "hello". Sau đó chúng ta ngay lập tức tạo một String mới với giá trị "ahoy" và gán nó cho s. Tại thời điểm này, không có gì tham chiếu đến giá trị ban đầu trên heap một chút nào.

Một bảng s đại diện cho giá trị chuỗi trên stack, trỏ đến
phần dữ liệu chuỗi thứ hai (ahoy) trên heap, với dữ liệu chuỗi ban đầu
(hello) được tô xám vì không thể truy cập nữa.

Hình 4-5: Biểu diễn trong bộ nhớ sau khi giá trị ban đầu đã bị thay thế hoàn toàn.

Vì vậy, chuỗi ban đầu ngay lập tức ra khỏi phạm vi. Rust sẽ chạy hàm drop trên nó và bộ nhớ của nó sẽ được giải phóng ngay lập tức. Khi chúng ta in giá trị vào cuối, nó sẽ là "ahoy, world!".

Các Biến và Dữ Liệu Tương Tác với Clone

Nếu chúng ta muốn sao chép sâu dữ liệu heap của String, không chỉ dữ liệu stack, chúng ta có thể sử dụng một phương thức phổ biến gọi là clone. Chúng ta sẽ thảo luận về cú pháp phương thức trong Chương 5, nhưng vì các phương thức là một tính năng phổ biến trong nhiều ngôn ngữ lập trình, bạn có thể đã thấy chúng trước đây.

Đây là một ví dụ về phương thức clone trong hành động:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

Điều này hoạt động tốt và rõ ràng tạo ra hành vi được hiển thị trong Hình 4-3, nơi dữ liệu heap thực sự được sao chép.

Khi bạn thấy một lệnh gọi đến clone, bạn biết rằng một số mã tùy ý đang được thực thi và mã đó có thể tốn kém. Đó là một dấu hiệu trực quan rằng điều gì đó khác biệt đang xảy ra.

Dữ Liệu Chỉ Trên Stack: Copy

Còn một chi tiết khác mà chúng ta chưa đề cập. Mã này sử dụng số nguyên—một phần đã được hiển thị trong Listing 4-2—hoạt động và hợp lệ:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

Nhưng mã này dường như trái ngược với những gì chúng ta vừa học: chúng ta không có lệnh gọi clone, nhưng x vẫn hợp lệ và không bị di chuyển vào y.

Lý do là các kiểu như số nguyên có kích thước đã biết tại thời điểm biên dịch được lưu trữ hoàn toàn trên stack, nên các bản sao của giá trị thực tế được tạo nhanh chóng. Điều đó có nghĩa là không có lý do tại sao chúng ta muốn ngăn x không còn hợp lệ sau khi chúng ta tạo biến y. Nói cách khác, không có sự khác biệt giữa sao chép sâu và nông ở đây, vì vậy việc gọi clone sẽ không làm gì khác so với sao chép nông thông thường, và chúng ta có thể bỏ qua nó.

Rust có một chú thích đặc biệt gọi là trait Copy mà chúng ta có thể đặt trên các kiểu được lưu trữ trên stack, như số nguyên (chúng ta sẽ nói nhiều hơn về trait trong Chương 10). Nếu một kiểu triển khai trait Copy, các biến sử dụng nó không bị di chuyển, mà là được sao chép một cách tầm thường, khiến chúng vẫn hợp lệ sau khi gán cho một biến khác.

Rust sẽ không cho phép chúng ta chú thích một kiểu với Copy nếu kiểu đó, hoặc bất kỳ phần nào của nó, đã triển khai trait Drop. Nếu kiểu cần điều gì đó đặc biệt xảy ra khi giá trị ra khỏi phạm vi và chúng ta thêm chú thích Copy vào kiểu đó, chúng ta sẽ nhận được lỗi biên dịch. Để tìm hiểu về cách thêm chú thích Copy vào kiểu của bạn để triển khai trait, xem "Các Trait Có thể Dẫn xuất" trong Phụ lục C.

Vậy, các kiểu nào triển khai trait Copy? Bạn có thể kiểm tra tài liệu cho kiểu đã cho để chắc chắn, nhưng theo quy tắc chung, bất kỳ nhóm giá trị vô hướng đơn giản nào cũng có thể triển khai Copy, và không có gì yêu cầu cấp phát hoặc là một dạng tài nguyên có thể triển khai Copy. Đây là một số kiểu triển khai Copy:

  • Tất cả các kiểu số nguyên, chẳng hạn như u32.
  • Kiểu Boolean, bool, với giá trị truefalse.
  • Tất cả các kiểu số thực, chẳng hạn như f64.
  • Kiểu ký tự, char.
  • Tuple, nếu chúng chỉ chứa các kiểu cũng triển khai Copy. Ví dụ, (i32, i32) triển khai Copy, nhưng (i32, String) thì không.

Quyền Sở Hữu và Hàm

Cơ chế của việc truyền một giá trị cho một hàm tương tự như khi gán một giá trị cho một biến. Truyền một biến cho một hàm sẽ di chuyển hoặc sao chép, giống như gán. Listing 4-3 có một ví dụ với một số chú thích cho thấy các biến đi vào và ra khỏi phạm vi ở đâu.

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

Nếu chúng ta cố gắng sử dụng s sau cuộc gọi đến takes_ownership, Rust sẽ đưa ra một lỗi biên dịch. Những kiểm tra tĩnh này bảo vệ chúng ta khỏi các sai lầm. Hãy thử thêm mã vào main sử dụng sx để xem bạn có thể sử dụng chúng ở đâu và nơi quy tắc sở hữu ngăn bạn làm như vậy.

Giá Trị Trả Về và Phạm Vi

Việc trả về giá trị cũng có thể chuyển quyền sở hữu. Listing 4-4 hiển thị một ví dụ về một hàm trả về một số giá trị, với các chú thích tương tự như trong Listing 4-3.

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

Quyền sở hữu của một biến tuân theo cùng một mô hình mọi lúc: gán một giá trị cho một biến khác sẽ di chuyển nó. Khi một biến bao gồm dữ liệu trên heap ra khỏi phạm vi, giá trị sẽ được dọn dẹp bởi drop trừ khi quyền sở hữu của dữ liệu đã được chuyển sang một biến khác.

Mặc dù điều này hoạt động, việc lấy quyền sở hữu và sau đó trả lại quyền sở hữu với mọi hàm hơi tẻ nhạt. Nếu chúng ta muốn cho một hàm sử dụng một giá trị nhưng không lấy quyền sở hữu? Khá là phiền toái khi bất cứ thứ gì chúng ta truyền vào cũng cần được truyền lại nếu chúng ta muốn sử dụng lại, cùng với bất kỳ dữ liệu nào có từ thân hàm mà chúng ta cũng có thể muốn trả về.

Rust cho phép chúng ta trả về nhiều giá trị bằng cách sử dụng một tuple, như được hiển thị trong Listing 4-5.

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

Nhưng đây là quá nhiều nghi thức và rất nhiều công việc cho một khái niệm nên phổ biến. May mắn thay, Rust có một tính năng để sử dụng một giá trị mà không chuyển quyền sở hữu, được gọi là tham chiếu (references).

Tham Chiếu và Mượn

Vấn đề với đoạn mã tuple trong Listing 4-5 là chúng ta phải trả lại String cho hàm gọi để chúng ta vẫn có thể sử dụng String sau cuộc gọi đến calculate_length, bởi vì String đã được chuyển vào calculate_length. Thay vào đó, chúng ta có thể cung cấp một tham chiếu đến giá trị String. Tham chiếu (reference) giống như một con trỏ ở chỗ nó là một địa chỉ mà chúng ta có thể theo dõi để truy cập dữ liệu được lưu trữ tại địa chỉ đó; dữ liệu đó thuộc sở hữu của một biến khác. Không giống như con trỏ, một tham chiếu được đảm bảo trỏ đến một giá trị hợp lệ của một kiểu dữ liệu cụ thể trong suốt thời gian tồn tại của tham chiếu đó.

Đây là cách bạn sẽ định nghĩa và sử dụng một hàm calculate_length có một tham chiếu đến một đối tượng làm tham số thay vì lấy quyền sở hữu của giá trị:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Đầu tiên, lưu ý rằng tất cả các mã tuple trong khai báo biến và giá trị trả về của hàm đã biến mất. Thứ hai, lưu ý rằng chúng ta truyền &s1 vào calculate_length và trong định nghĩa của nó, chúng ta lấy &String thay vì String. Các dấu và (&) này đại diện cho tham chiếu, và chúng cho phép bạn tham chiếu đến một số giá trị mà không lấy quyền sở hữu của nó. Hình 4-6 minh họa khái niệm này.

Ba bảng: bảng cho s chỉ chứa một con trỏ đến bảng
cho s1. Bảng cho s1 chứa dữ liệu stack cho s1 và trỏ đến
dữ liệu chuỗi trên heap.

Hình 4-6: Một sơ đồ của &String s trỏ đến String s1

Lưu ý: Ngược lại với việc tham chiếu bằng cách sử dụng &giải tham chiếu (dereferencing), được thực hiện với toán tử giải tham chiếu, *. Chúng ta sẽ thấy một số cách sử dụng toán tử giải tham chiếu trong Chương 8 và thảo luận chi tiết về giải tham chiếu trong Chương 15.

Hãy xem xét kỹ hơn cuộc gọi hàm ở đây:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Cú pháp &s1 cho phép chúng ta tạo một tham chiếu trỏ đến giá trị của s1 nhưng không sở hữu nó. Vì tham chiếu không sở hữu nó, giá trị mà nó trỏ đến sẽ không bị hủy khi tham chiếu không còn được sử dụng nữa.

Tương tự, chữ ký của hàm sử dụng & để chỉ ra rằng kiểu của tham số s là một tham chiếu. Hãy thêm một số chú thích giải thích:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the String is not dropped.

Phạm vi mà biến s có giá trị giống như phạm vi của bất kỳ tham số hàm nào, nhưng giá trị được trỏ đến bởi tham chiếu không bị hủy khi s không còn được sử dụng nữa, vì s không có quyền sở hữu. Khi các hàm có tham chiếu làm tham số thay vì các giá trị thực tế, chúng ta sẽ không cần trả lại các giá trị để trả lại quyền sở hữu, vì chúng ta chưa bao giờ có quyền sở hữu.

Chúng ta gọi hành động tạo một tham chiếu là mượn (borrowing). Như trong cuộc sống thực, nếu một người sở hữu một thứ gì đó, bạn có thể mượn nó từ họ. Khi bạn xong việc, bạn phải trả lại nó. Bạn không sở hữu nó.

Vậy, điều gì sẽ xảy ra nếu chúng ta cố gắng sửa đổi thứ gì đó mà chúng ta đang mượn? Hãy thử mã trong Listing 4-6. Cảnh báo trước: nó không hoạt động!

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

Đây là lỗi:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Giống như các biến là không thay đổi theo mặc định, các tham chiếu cũng vậy. Chúng ta không được phép sửa đổi một thứ mà chúng ta có tham chiếu đến.

Tham Chiếu Có Thể Thay Đổi

Chúng ta có thể sửa mã từ Listing 4-6 để cho phép chúng ta sửa đổi một giá trị đã mượn chỉ với một vài điều chỉnh nhỏ sử dụng, thay vào đó, một tham chiếu có thể thay đổi (mutable reference):

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

Đầu tiên, chúng ta thay đổi s thành mut. Sau đó, chúng ta tạo một tham chiếu có thể thay đổi với &mut s khi chúng ta gọi hàm change, và cập nhật chữ ký hàm để chấp nhận một tham chiếu có thể thay đổi với some_string: &mut String. Điều này làm cho nó rất rõ ràng rằng hàm change sẽ thay đổi giá trị mà nó mượn.

Tham chiếu có thể thay đổi có một hạn chế lớn: nếu bạn có một tham chiếu có thể thay đổi đến một giá trị, bạn không thể có tham chiếu nào khác đến giá trị đó. Mã này cố gắng tạo hai tham chiếu có thể thay đổi đến s sẽ thất bại:

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{r1}, {r2}");
}

Đây là lỗi:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{r1}, {r2}");
  |               ---- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Lỗi này nói rằng mã này không hợp lệ vì chúng ta không thể mượn s như một biến có thể thay đổi nhiều hơn một lần tại một thời điểm. Lần mượn có thể thay đổi đầu tiên là trong r1 và phải kéo dài cho đến khi nó được sử dụng trong println!, nhưng giữa việc tạo tham chiếu có thể thay đổi đó và việc sử dụng nó, chúng ta đã cố gắng tạo một tham chiếu có thể thay đổi khác trong r2 mà mượn cùng dữ liệu như r1.

Hạn chế ngăn nhiều tham chiếu có thể thay đổi đến cùng một dữ liệu tại cùng một thời điểm cho phép thay đổi nhưng theo một cách rất có kiểm soát. Đây là điều mà các lập trình viên Rust mới gặp khó khăn vì hầu hết các ngôn ngữ cho phép bạn thay đổi bất cứ khi nào bạn muốn. Lợi ích của việc có hạn chế này là Rust có thể ngăn chặn đua dữ liệu tại thời điểm biên dịch. Đua dữ liệu (data race) tương tự như một điều kiện đua và xảy ra khi có ba hành vi sau:

  • Hai hoặc nhiều con trỏ truy cập cùng một dữ liệu tại cùng một thời điểm.
  • Ít nhất một trong các con trỏ đang được sử dụng để ghi vào dữ liệu.
  • Không có cơ chế nào đang được sử dụng để đồng bộ hóa việc truy cập vào dữ liệu.

Đua dữ liệu gây ra hành vi không xác định và có thể khó chẩn đoán và sửa chữa khi bạn đang cố gắng theo dõi chúng trong thời gian chạy; Rust ngăn chặn vấn đề này bằng cách từ chối biên dịch mã có đua dữ liệu!

Như mọi khi, chúng ta có thể sử dụng dấu ngoặc nhọn để tạo một phạm vi mới, cho phép nhiều tham chiếu có thể thay đổi, miễn là chúng không đồng thời xuất hiện:

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust áp dụng một quy tắc tương tự cho việc kết hợp tham chiếu có thể thay đổi và không thể thay đổi. Mã này dẫn đến lỗi:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{r1}, {r2}, and {r3}");
}

Đây là lỗi:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}, and {r3}");
  |               ---- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Ôi! Chúng ta cũng không thể có một tham chiếu có thể thay đổi trong khi chúng ta có một tham chiếu không thể thay đổi đến cùng một giá trị.

Người dùng của một tham chiếu không thể thay đổi không mong đợi giá trị đột nhiên thay đổi dưới chân họ! Tuy nhiên, nhiều tham chiếu không thể thay đổi được cho phép vì không ai chỉ đọc dữ liệu có khả năng ảnh hưởng đến việc đọc dữ liệu của người khác.

Lưu ý rằng phạm vi của một tham chiếu bắt đầu từ nơi nó được giới thiệu và tiếp tục thông qua lần cuối cùng tham chiếu đó được sử dụng. Ví dụ, mã này sẽ biên dịch vì lần sử dụng cuối cùng của các tham chiếu không thể thay đổi là trong println!, trước khi tham chiếu có thể thay đổi được giới thiệu:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

Phạm vi của các tham chiếu không thể thay đổi r1r2 kết thúc sau println! nơi chúng được sử dụng lần cuối, đó là trước khi tham chiếu có thể thay đổi r3 được tạo. Các phạm vi này không chồng lấn, vì vậy mã này được cho phép: trình biên dịch có thể biết rằng tham chiếu không còn được sử dụng tại một điểm trước khi kết thúc phạm vi.

Mặc dù các lỗi mượn có thể gây bực bội đôi khi, hãy nhớ rằng đó là trình biên dịch Rust chỉ ra một lỗi tiềm ẩn sớm (tại thời điểm biên dịch thay vì tại thời điểm chạy) và chỉ cho bạn chính xác nơi vấn đề nằm ở. Sau đó, bạn không phải theo dõi lý do tại sao dữ liệu của bạn không phải là những gì bạn nghĩ nó là.

Tham Chiếu Treo

Trong các ngôn ngữ có con trỏ, rất dễ vô tình tạo ra một con trỏ treo (dangling pointer)—một con trỏ tham chiếu đến một vị trí trong bộ nhớ có thể đã được cấp cho ai đó khác—bằng cách giải phóng một số bộ nhớ trong khi vẫn giữ một con trỏ đến bộ nhớ đó. Ngược lại, trong Rust, trình biên dịch đảm bảo rằng các tham chiếu sẽ không bao giờ là tham chiếu treo: nếu bạn có một tham chiếu đến một số dữ liệu, trình biên dịch sẽ đảm bảo rằng dữ liệu sẽ không ra khỏi phạm vi trước tham chiếu đến dữ liệu đó.

Hãy thử tạo một tham chiếu treo để xem cách Rust ngăn chúng với một lỗi thời điểm biên dịch:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Đây là lỗi:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

error[E0515]: cannot return reference to local variable `s`
 --> src/main.rs:8:5
  |
8 |     &s
  |     ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

Thông báo lỗi này đề cập đến một tính năng mà chúng ta chưa đề cập: thời gian tồn tại (lifetimes). Chúng ta sẽ thảo luận về thời gian tồn tại chi tiết trong Chương 10. Nhưng, nếu bạn bỏ qua các phần về thời gian tồn tại, thông báo có chứa chìa khóa cho lý do tại sao mã này là một vấn đề:

kiểu trả về của hàm này chứa một giá trị đã mượn, nhưng không có giá trị
nào để mượn từ đó

Hãy xem xét kỹ hơn chính xác những gì đang xảy ra ở mỗi giai đoạn của mã dangle của chúng ta:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope and is dropped, so its memory goes away.
  // Danger!

s được tạo bên trong dangle, khi mã của dangle hoàn thành, s sẽ bị giải phóng. Nhưng chúng ta đã cố gắng trả về một tham chiếu đến nó. Điều đó có nghĩa là tham chiếu này sẽ trỏ đến một String không hợp lệ. Điều đó không tốt! Rust sẽ không cho phép chúng ta làm điều này.

Giải pháp ở đây là trả về String trực tiếp:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

Điều này hoạt động mà không có vấn đề gì. Quyền sở hữu được chuyển ra ngoài và không có gì bị giải phóng.

Các Quy Tắc của Tham Chiếu

Hãy tổng kết những gì chúng ta đã thảo luận về tham chiếu:

  • Tại bất kỳ thời điểm nào, bạn có thể có hoặc là một tham chiếu có thể thay đổi hoặc là bất kỳ số lượng tham chiếu không thể thay đổi nào.
  • Tham chiếu phải luôn hợp lệ.

Tiếp theo, chúng ta sẽ xem xét một loại tham chiếu khác: slice.

Kiểu Slice

Slices cho phép bạn tham chiếu đến một chuỗi liên tiếp các phần tử trong một bộ sưu tập. Slice là một loại tham chiếu, nên nó không có quyền sở hữu.

Đây là một bài toán nhỏ: viết một hàm nhận vào một chuỗi các từ được phân tách bởi khoảng trắng và trả về từ đầu tiên mà nó tìm thấy trong chuỗi đó. Nếu hàm không tìm thấy khoảng trắng trong chuỗi, toàn bộ chuỗi phải là một từ, do đó toàn bộ chuỗi sẽ được trả về.

Lưu ý: Để giới thiệu về string slice, chúng ta giả định chỉ xử lý ASCII trong phần này; một thảo luận chi tiết hơn về xử lý UTF-8 nằm trong phần "Lưu trữ văn bản được mã hóa UTF-8 với Strings" ở Chương 8.

Hãy cùng xem xét cách viết chữ ký của hàm này mà không sử dụng slices, để hiểu vấn đề mà slices sẽ giải quyết:

fn first_word(s: &String) -> ?

Hàm first_word có một tham số kiểu &String. Chúng ta không cần quyền sở hữu, nên điều này là hợp lý. (Theo cách viết thông thường của Rust, hàm không lấy quyền sở hữu của các đối số của chúng trừ khi cần thiết, và lý do sẽ trở nên rõ ràng hơn khi chúng ta tiếp tục.) Nhưng chúng ta nên trả về gì? Chúng ta không thực sự có cách để nói về một phần của một chuỗi. Tuy nhiên, chúng ta có thể trả về chỉ số của vị trí kết thúc từ, được chỉ định bằng một khoảng trắng. Hãy thử điều đó, như trong Listing 4-7.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Vì chúng ta cần đi qua từng phần tử của String và kiểm tra xem một giá trị có phải là khoảng trắng không, chúng ta sẽ chuyển đổi String thành một mảng byte bằng phương thức as_bytes.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Tiếp theo, chúng ta tạo một iterator qua mảng byte bằng phương thức iter:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Chúng ta sẽ thảo luận về iterators chi tiết hơn trong Chương 13. Hiện tại, hãy biết rằng iter là một phương thức trả về mỗi phần tử trong một bộ sưu tập và enumerate bao bọc kết quả của iter và trả về mỗi phần tử dưới dạng một tuple. Phần tử đầu tiên của tuple trả về từ enumerate là chỉ số, và phần tử thứ hai là tham chiếu đến phần tử. Điều này thuận tiện hơn một chút so với việc tự tính toán chỉ số.

Vì phương thức enumerate trả về một tuple, chúng ta có thể sử dụng các mẫu để phân rã tuple đó. Chúng ta sẽ thảo luận về các mẫu chi tiết hơn trong Chương 6. Trong vòng lặp for, chúng ta chỉ định một mẫu có i cho chỉ số trong tuple và &item cho byte đơn lẻ trong tuple. Bởi vì chúng ta có được tham chiếu đến phần tử từ .iter().enumerate(), chúng ta sử dụng & trong mẫu.

Bên trong vòng lặp for, chúng ta tìm kiếm byte đại diện cho khoảng trắng bằng cách sử dụng cú pháp byte literal. Nếu chúng ta tìm thấy một khoảng trắng, chúng ta trả về vị trí. Nếu không, chúng ta trả về độ dài của chuỗi bằng cách sử dụng s.len().

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Bây giờ chúng ta đã có cách để tìm ra chỉ số của vị trí kết thúc từ đầu tiên trong chuỗi, nhưng có một vấn đề. Chúng ta đang trả về một usize riêng lẻ, nhưng đó chỉ là một số có ý nghĩa trong ngữ cảnh của &String. Nói cách khác, vì nó là một giá trị tách biệt khỏi String, không có gì đảm bảo rằng nó sẽ vẫn hợp lệ trong tương lai. Xem xét chương trình trong Listing 4-8 sử dụng hàm first_word từ Listing 4-7.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // word still has the value 5 here, but s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}

Chương trình này biên dịch mà không có lỗi nào và cũng sẽ như vậy nếu chúng ta sử dụng word sau khi gọi s.clear(). Bởi vì word không liên kết với trạng thái của s chút nào, word vẫn chứa giá trị 5. Chúng ta có thể sử dụng giá trị 5 đó với biến s để cố gắng trích xuất từ đầu tiên, nhưng điều này sẽ là một lỗi vì nội dung của s đã thay đổi kể từ khi chúng ta lưu 5 vào word.

Việc phải lo lắng về chỉ số trong word không đồng bộ với dữ liệu trong s là tẻ nhạt và dễ gây lỗi! Việc quản lý các chỉ số này thậm chí còn mong manh hơn nếu chúng ta viết một hàm second_word. Chữ ký của nó sẽ phải trông như thế này:

fn second_word(s: &String) -> (usize, usize) {

Bây giờ chúng ta đang theo dõi một chỉ số bắt đầu một chỉ số kết thúc, và chúng ta có nhiều giá trị hơn được tính toán từ dữ liệu trong một trạng thái cụ thể nhưng không gắn liền với trạng thái đó chút nào. Chúng ta có ba biến không liên quan đến nhau nổi xung quanh cần phải được giữ đồng bộ.

May mắn thay, Rust có một giải pháp cho vấn đề này: string slices.

String Slices

Một string slice là một tham chiếu đến một chuỗi liên tiếp các phần tử của một String, và nó trông như thế này:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

Thay vì một tham chiếu đến toàn bộ String, hello là một tham chiếu đến một phần của String, được chỉ định trong phần [0..5]. Chúng ta tạo slices bằng cách sử dụng một phạm vi trong ngoặc vuông bằng cách chỉ định [starting_index..ending_index], trong đó starting_index là vị trí đầu tiên trong slice và ending_index là vị trí sau vị trí cuối cùng trong slice. Về mặt nội bộ, cấu trúc dữ liệu slice lưu trữ vị trí bắt đầu và độ dài của slice, mà tương ứng với ending_index trừ đi starting_index. Vì vậy, trong trường hợp let world = &s[6..11];, world sẽ là một slice chứa một con trỏ đến byte tại chỉ số 6 của s với giá trị độ dài là 5.

Hình 4-7 minh họa điều này trong một sơ đồ.

Ba bảng: một bảng đại diện cho dữ liệu stack của s, nó trỏ đến
byte tại chỉ số 0 trong một bảng dữ liệu chuỗi "hello world" trên
heap. Bảng thứ ba đại diện cho dữ liệu stack của slice world, nó
có giá trị độ dài là 5 và trỏ đến byte thứ 6 của bảng dữ liệu heap.

Hình 4-7: String slice tham chiếu đến một phần của một String

Với cú pháp phạm vi .. của Rust, nếu bạn muốn bắt đầu từ chỉ số 0, bạn có thể bỏ giá trị trước hai dấu chấm. Nói cách khác, hai cách viết sau là tương đương:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

Tương tự, nếu slice của bạn bao gồm byte cuối cùng của String, bạn có thể bỏ số cuối. Điều đó có nghĩa là các cách viết sau là tương đương:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

Bạn cũng có thể bỏ cả hai giá trị để lấy một slice của toàn bộ chuỗi. Vì vậy, hai cách viết sau là tương đương:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Lưu ý: Các chỉ số phạm vi của string slice phải nằm tại các ranh giới ký tự UTF-8 hợp lệ. Nếu bạn cố gắng tạo một string slice ở giữa một ký tự đa byte, chương trình của bạn sẽ kết thúc với một lỗi.

Với tất cả thông tin này, hãy viết lại first_word để trả về một slice. Kiểu dùng để biểu thị "string slice" được viết là &str:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

Chúng ta lấy chỉ số cho vị trí kết thúc của từ theo cách tương tự như trong Listing 4-7, bằng cách tìm kiếm lần xuất hiện đầu tiên của một khoảng trắng. Khi chúng ta tìm thấy một khoảng trắng, chúng ta trả về một string slice sử dụng phần đầu của chuỗi và chỉ số của khoảng trắng làm chỉ số bắt đầu và kết thúc.

Bây giờ khi chúng ta gọi first_word, chúng ta nhận được một giá trị duy nhất gắn liền với dữ liệu cơ bản. Giá trị bao gồm một tham chiếu đến điểm bắt đầu của slice và số phần tử trong slice.

Việc trả về một slice cũng sẽ hoạt động cho một hàm second_word:

fn second_word(s: &String) -> &str {

Bây giờ chúng ta có một API đơn giản hơn nhiều mà khó bị sai sót hơn bởi vì trình biên dịch sẽ đảm bảo các tham chiếu vào String vẫn hợp lệ. Hãy nhớ lại lỗi trong chương trình ở Listing 4-8, khi chúng ta nhận được chỉ số đến vị trí kết thúc từ đầu tiên nhưng sau đó xóa chuỗi khiến cho chỉ số của chúng ta không còn hợp lệ? Đoạn mã đó về mặt logic là không đúng nhưng không hiển thị lỗi ngay lập tức. Các vấn đề sẽ xuất hiện sau nếu chúng ta tiếp tục cố gắng sử dụng chỉ số từ đầu tiên với một chuỗi đã bị làm rỗng. Slices làm cho lỗi này là không thể và cho chúng ta biết rằng chúng ta có vấn đề với mã của mình sớm hơn nhiều. Sử dụng phiên bản slice của first_word sẽ báo lỗi khi biên dịch:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

Đây là lỗi biên dịch:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                  ------ immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Hãy nhớ lại từ quy tắc mượn rằng nếu chúng ta có một tham chiếu bất biến đến một thứ gì đó, chúng ta không thể đồng thời lấy một tham chiếu có thể thay đổi. Bởi vì clear cần cắt ngắn String, nó cần lấy một tham chiếu có thể thay đổi. println! sau lời gọi đến clear sử dụng tham chiếu trong word, do đó tham chiếu bất biến phải vẫn còn hoạt động tại thời điểm đó. Rust không cho phép tham chiếu có thể thay đổi trong clear và tham chiếu bất biến trong word tồn tại cùng một lúc, và việc biên dịch thất bại. Rust không chỉ làm cho API của chúng ta dễ sử dụng hơn, mà còn loại bỏ toàn bộ một lớp lỗi tại thời điểm biên dịch!

String Literals dưới dạng Slices

Nhớ lại rằng chúng ta đã nói về việc string literals được lưu trữ bên trong mã nhị phân. Bây giờ khi chúng ta đã biết về slices, chúng ta có thể hiểu đúng về string literals:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

Kiểu của s ở đây là &str: đó là một slice trỏ đến một điểm cụ thể của mã nhị phân. Đây cũng là lý do tại sao string literals là bất biến; &str là một tham chiếu bất biến.

String Slices dưới dạng Tham số

Khi biết rằng bạn có thể lấy slices của literals và giá trị String dẫn chúng ta đến một cải tiến nữa cho first_word, và đó là chữ ký của nó:

fn first_word(s: &String) -> &str {

Một Rustacean có kinh nghiệm hơn sẽ viết chữ ký như trong Listing 4-9 thay vì như trên vì nó cho phép chúng ta sử dụng cùng một hàm cho cả giá trị &String và giá trị &str.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Nếu chúng ta có một string slice, chúng ta có thể truyền nó trực tiếp. Nếu chúng ta có một String, chúng ta có thể truyền một slice của String hoặc một tham chiếu đến String. Tính linh hoạt này tận dụng deref coercions, một tính năng mà chúng ta sẽ đề cập trong phần "Implicit Deref Coercions with Functions and Methods" của Chương 15.

Việc định nghĩa một hàm nhận một string slice thay vì một tham chiếu đến một String làm cho API của chúng ta trở nên tổng quát và hữu ích hơn mà không mất bất kỳ chức năng nào:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Các Slices Khác

String slices, như bạn có thể tưởng tượng, là đặc biệt dành cho chuỗi. Nhưng có một kiểu slice chung hơn. Xét mảng này:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

Cũng như chúng ta có thể muốn tham chiếu đến một phần của một chuỗi, chúng ta có thể muốn tham chiếu đến một phần của một mảng. Chúng ta sẽ thực hiện như sau:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Slice này có kiểu &[i32]. Nó hoạt động giống như string slices, bằng cách lưu trữ một tham chiếu đến phần tử đầu tiên và một độ dài. Bạn sẽ sử dụng kiểu slice này cho tất cả các loại bộ sưu tập khác. Chúng ta sẽ thảo luận về các bộ sưu tập này chi tiết khi chúng ta nói về vectors trong Chương 8.

Tóm tắt

Các khái niệm về quyền sở hữu, mượn và slices đảm bảo an toàn bộ nhớ trong các chương trình Rust tại thời điểm biên dịch. Ngôn ngữ Rust cho bạn quyền kiểm soát đối với việc sử dụng bộ nhớ của bạn theo cách giống như các ngôn ngữ lập trình hệ thống khác, nhưng việc có chủ sở hữu của dữ liệu tự động dọn dẹp dữ liệu đó khi chủ sở hữu ra khỏi phạm vi có nghĩa là bạn không cần phải viết và gỡ lỗi mã bổ sung để có được sự kiểm soát này.

Quyền sở hữu ảnh hưởng đến cách thức hoạt động của nhiều phần khác của Rust, vì vậy chúng ta sẽ nói về các khái niệm này thêm trong suốt phần còn lại của cuốn sách. Hãy chuyển sang Chương 5 và xem xét việc nhóm các phần dữ liệu lại với nhau trong một struct.

Sử dụng Structs để Cấu trúc Dữ liệu Liên quan

Một struct, hay structure, là một kiểu dữ liệu tùy chỉnh cho phép bạn đóng gói và đặt tên cho nhiều giá trị liên quan tạo thành một nhóm có ý nghĩa. Nếu bạn đã quen với ngôn ngữ hướng đối tượng, struct giống như các thuộc tính dữ liệu của một đối tượng. Trong chương này, chúng ta sẽ so sánh và đối chiếu tuple với struct để xây dựng trên những gì bạn đã biết và chứng minh khi nào struct là cách tốt hơn để nhóm dữ liệu.

Chúng ta sẽ trình bày cách định nghĩa và khởi tạo struct. Chúng ta sẽ thảo luận về cách định nghĩa các hàm liên kết, đặc biệt là loại hàm liên kết được gọi là methods, để chỉ định hành vi liên quan đến một kiểu struct. Struct và enums (được thảo luận trong Chương 6) là các khối xây dựng để tạo ra các kiểu mới trong miền chương trình của bạn để tận dụng tối đa khả năng kiểm tra kiểu tại thời điểm biên dịch của Rust.

Định nghĩa và Khởi tạo Struct

Struct tương tự như tuple, đã được thảo luận trong phần "Kiểu Tuple", ở chỗ cả hai đều chứa nhiều giá trị liên quan. Giống như tuple, các thành phần của một struct có thể có kiểu khác nhau. Khác với tuple, trong một struct, bạn sẽ đặt tên cho từng phần dữ liệu để làm rõ ý nghĩa của các giá trị. Việc thêm các tên này có nghĩa là struct linh hoạt hơn tuple: bạn không phải dựa vào thứ tự của dữ liệu để xác định hoặc truy cập các giá trị của một thực thể.

Để định nghĩa một struct, chúng ta nhập từ khóa struct và đặt tên cho toàn bộ struct. Tên của struct nên mô tả ý nghĩa của các phần dữ liệu được nhóm lại với nhau. Sau đó, bên trong dấu ngoặc nhọn, chúng ta định nghĩa tên và kiểu của các phần dữ liệu, mà chúng ta gọi là trường (fields). Ví dụ, Listing 5-1 hiển thị một struct lưu trữ thông tin về tài khoản người dùng.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}

Để sử dụng struct sau khi đã định nghĩa nó, chúng ta tạo ra một instance (thực thể) của struct đó bằng cách xác định giá trị cụ thể cho mỗi trường. Chúng ta tạo một thực thể bằng cách nêu tên của struct và sau đó thêm dấu ngoặc nhọn chứa các cặp key: value, trong đó key là tên của các trường và value là dữ liệu mà chúng ta muốn lưu trữ trong các trường đó. Chúng ta không cần phải chỉ định các trường theo cùng thứ tự mà chúng ta đã khai báo trong struct. Nói cách khác, định nghĩa struct giống như một khuôn mẫu chung cho kiểu dữ liệu, và các thực thể điền vào khuôn mẫu đó với dữ liệu cụ thể để tạo ra các giá trị của kiểu đó. Ví dụ, chúng ta có thể khai báo một người dùng cụ thể như trong Listing 5-2.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}

Để lấy một giá trị cụ thể từ struct, chúng ta sử dụng ký hiệu dấu chấm. Ví dụ, để truy cập địa chỉ email của người dùng này, chúng ta sử dụng user1.email. Nếu thực thể là có thể thay đổi, chúng ta có thể thay đổi giá trị bằng cách sử dụng ký hiệu dấu chấm và gán vào một trường cụ thể. Listing 5-3 cho thấy cách thay đổi giá trị trong trường email của một thực thể User có thể thay đổi.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

Lưu ý rằng toàn bộ thực thể phải là có thể thay đổi; Rust không cho phép chúng ta đánh dấu chỉ một số trường nhất định là có thể thay đổi. Giống như với bất kỳ biểu thức nào, chúng ta có thể tạo một thực thể mới của struct làm biểu thức cuối cùng trong thân hàm để ngầm định trả về thực thể mới đó.

Listing 5-4 hiển thị một hàm build_user trả về một thực thể User với email và tên người dùng đã cho. Trường active nhận giá trị là true, và sign_in_count nhận giá trị là 1.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

Việc đặt tên các tham số của hàm giống với tên trường của struct là hợp lý, nhưng việc phải lặp lại tên trường emailusername cùng với biến là hơi tẻ nhạt. Nếu struct có nhiều trường hơn, việc lặp lại mỗi tên sẽ càng phiền phức hơn. May mắn thay, có một cách viết tắt thuận tiện!

Sử dụng Field Init Shorthand

Vì tên tham số và tên trường struct là hoàn toàn giống nhau trong Listing 5-4, chúng ta có thể sử dụng cú pháp field init shorthand để viết lại build_user để nó hoạt động chính xác như cũ nhưng không có sự lặp lại của usernameemail, như trong Listing 5-5.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

Ở đây, chúng ta đang tạo một thực thể mới của struct User, nó có một trường tên là email. Chúng ta muốn đặt giá trị của trường email thành giá trị trong tham số email của hàm build_user. Vì trường email và tham số email có cùng tên, chúng ta chỉ cần viết email thay vì email: email.

Tạo Thực thể từ Các Thực thể Khác với Cú pháp Cập nhật Struct

Thường rất hữu ích khi tạo một thực thể mới của một struct bao gồm hầu hết các giá trị từ một thực thể khác của cùng kiểu, nhưng thay đổi một số. Bạn có thể làm điều này bằng cách sử dụng cú pháp cập nhật struct (struct update syntax).

Đầu tiên, trong Listing 5-6, chúng ta thấy cách tạo một thực thể User mới trong user2 theo cách thông thường, không sử dụng cú pháp cập nhật. Chúng ta đặt một giá trị mới cho email nhưng sử dụng các giá trị giống nhau từ user1 mà chúng ta đã tạo ra trong Listing 5-2.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
}

Sử dụng cú pháp cập nhật struct, chúng ta có thể đạt được cùng kết quả với ít mã hơn, như trong Listing 5-7. Cú pháp .. chỉ định rằng các trường còn lại không được đặt rõ ràng sẽ có cùng giá trị với các trường trong thực thể đã cho.

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}

Mã trong Listing 5-7 cũng tạo ra một thực thể trong user2 có giá trị khác cho email nhưng có cùng giá trị cho các trường username, active, và sign_in_count từ user1. ..user1 phải đặt cuối cùng để chỉ định rằng bất kỳ trường còn lại nào nên lấy giá trị của chúng từ các trường tương ứng trong user1, nhưng chúng ta có thể chọn chỉ định giá trị cho nhiều trường tùy ý theo bất kỳ thứ tự nào, bất kể thứ tự của các trường trong định nghĩa struct.

Lưu ý rằng cú pháp cập nhật struct sử dụng = như một phép gán; điều này là vì nó di chuyển dữ liệu, giống như chúng ta đã thấy trong phần "Variables and Data Interacting with Move". Trong ví dụ này, chúng ta không còn có thể sử dụng user1 sau khi tạo user2String trong trường username của user1 đã được di chuyển vào user2. Nếu chúng ta đã cung cấp cho user2 giá trị String mới cho cả emailusername, và do đó chỉ sử dụng các giá trị activesign_in_count từ user1, thì user1 vẫn sẽ hợp lệ sau khi tạo ra user2. Cả activesign_in_count đều là các kiểu thực hiện trait Copy, do đó hành vi mà chúng ta đã thảo luận trong phần "Stack-Only Data: Copy" sẽ được áp dụng. Chúng ta cũng vẫn có thể sử dụng user1.email trong ví dụ này, vì giá trị của nó không bị di chuyển ra khỏi user1.

Sử dụng Tuple Struct Không Có Trường Được Đặt Tên để Tạo Các Kiểu Khác Nhau

Rust cũng hỗ trợ struct trông giống như tuple, được gọi là tuple struct. Tuple struct có thêm ý nghĩa mà tên struct cung cấp nhưng không có tên được liên kết với các trường của chúng; thay vào đó, chúng chỉ có các kiểu của các trường. Tuple struct hữu ích khi bạn muốn đặt tên cho toàn bộ tuple và làm cho tuple đó thành một kiểu khác với các tuple khác, và khi đặt tên cho mỗi trường như trong một struct thông thường sẽ dài dòng hoặc thừa thãi.

Để định nghĩa một tuple struct, bắt đầu với từ khóa struct và tên struct theo sau là các kiểu trong tuple. Ví dụ, ở đây chúng ta định nghĩa và sử dụng hai tuple struct có tên là ColorPoint:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

Lưu ý rằng giá trị blackorigin là các kiểu khác nhau vì chúng là các thực thể của các tuple struct khác nhau. Mỗi struct bạn định nghĩa là kiểu riêng của nó, ngay cả khi các trường trong struct có thể có cùng kiểu. Ví dụ, một hàm nhận một tham số kiểu Color không thể nhận một Point làm đối số, mặc dù cả hai kiểu đều được tạo thành từ ba giá trị i32. Nếu không, các thực thể tuple struct tương tự như tuple ở chỗ bạn có thể phân rã chúng thành các phần riêng lẻ, và bạn có thể sử dụng . theo sau bởi chỉ số để truy cập một giá trị riêng lẻ. Khác với tuple, tuple struct yêu cầu bạn đặt tên kiểu của struct khi bạn phân rã chúng. Ví dụ, chúng ta sẽ viết let Point(x, y, z) = origin; để phân rã các giá trị trong điểm origin thành các biến có tên là x, y, và z.

Unit-Like Struct Không Có Trường Nào

Bạn cũng có thể định nghĩa struct không có trường nào! Những thứ này được gọi là unit-like struct vì chúng hoạt động tương tự như (), kiểu đơn vị mà chúng ta đã đề cập trong phần "The Tuple Type". Unit-like struct có thể hữu ích khi bạn cần thực hiện một trait trên một kiểu nhưng không có bất kỳ dữ liệu nào mà bạn muốn lưu trữ trong chính kiểu đó. Chúng ta sẽ thảo luận về trait trong Chương 10. Đây là một ví dụ về việc khai báo và khởi tạo một unit struct có tên là AlwaysEqual:

struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

Để định nghĩa AlwaysEqual, chúng ta sử dụng từ khóa struct, tên mà chúng ta muốn, và sau đó là dấu chấm phẩy. Không cần dấu ngoặc nhọn hoặc dấu ngoặc tròn! Sau đó chúng ta có thể lấy một thực thể của AlwaysEqual trong biến subject theo cách tương tự: sử dụng tên mà chúng ta đã định nghĩa, không có dấu ngoặc nhọn hoặc dấu ngoặc tròn nào. Hãy tưởng tượng rằng sau này chúng ta sẽ thực hiện hành vi cho kiểu này sao cho mọi thực thể của AlwaysEqual luôn bằng với mọi thực thể của bất kỳ kiểu nào khác, có lẽ để có một kết quả đã biết cho mục đích kiểm tra. Chúng ta sẽ không cần bất kỳ dữ liệu nào để thực hiện hành vi đó! Bạn sẽ thấy trong Chương 10 cách định nghĩa trait và thực hiện chúng trên bất kỳ kiểu nào, bao gồm cả unit-like struct.

Quyền sở hữu Dữ liệu trong Struct

Trong định nghĩa struct User ở Listing 5-1, chúng ta đã sử dụng kiểu String có quyền sở hữu thay vì kiểu string slice &str. Đây là một lựa chọn có chủ đích bởi vì chúng ta muốn mỗi thực thể của struct này sở hữu tất cả dữ liệu của nó và cho dữ liệu đó hợp lệ miễn là toàn bộ struct còn hợp lệ.

Cũng có thể để struct lưu trữ các tham chiếu đến dữ liệu thuộc sở hữu của một thứ khác, nhưng để làm điều đó đòi hỏi phải sử dụng lifetimes, một tính năng của Rust mà chúng ta sẽ thảo luận trong Chương 10. Lifetimes đảm bảo rằng dữ liệu được tham chiếu bởi một struct là hợp lệ miễn là struct còn tồn tại. Hãy giả sử bạn cố gắng lưu trữ một tham chiếu trong một struct mà không chỉ định lifetimes, như sau; điều này sẽ không hoạt động:

struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "someone@example.com",
        sign_in_count: 1,
    };
}

Trình biên dịch sẽ phàn nàn rằng nó cần bộ chỉ định lifetime:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors

Trong Chương 10, chúng ta sẽ thảo luận về cách sửa những lỗi này để bạn có thể lưu trữ tham chiếu trong struct, nhưng hiện tại, chúng ta sẽ sửa các lỗi như thế này bằng cách sử dụng các kiểu có quyền sở hữu như String thay vì các tham chiếu như &str.

Chương trình Ví dụ Sử dụng Structs

Để hiểu khi nào chúng ta nên sử dụng structs, hãy viết một chương trình tính diện tích của hình chữ nhật. Chúng ta sẽ bắt đầu bằng việc sử dụng các biến đơn lẻ, và sau đó cải tiến chương trình cho đến khi chúng ta sử dụng structs.

Hãy tạo một dự án binary mới với Cargo có tên rectangles để tính diện tích hình chữ nhật dựa trên chiều rộng và chiều cao được chỉ định bằng pixel. Listing 5-8 hiển thị một chương trình ngắn với một cách thực hiện chính xác điều đó trong tệp src/main.rs của dự án chúng ta.

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Bây giờ, chạy chương trình này bằng cargo run:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

Đoạn mã này thành công trong việc tính diện tích của hình chữ nhật bằng cách gọi hàm area với mỗi kích thước, nhưng chúng ta có thể làm nhiều hơn nữa để làm cho mã này rõ ràng và dễ đọc hơn.

Vấn đề với đoạn mã này thể hiện rõ trong chữ ký của hàm area:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

Hàm area được cho là để tính diện tích của một hình chữ nhật, nhưng hàm mà chúng ta đã viết có hai tham số, và không có chỗ nào trong chương trình của chúng ta làm rõ rằng các tham số này có liên quan đến nhau. Sẽ dễ đọc và dễ quản lý hơn nếu nhóm chiều rộng và chiều cao lại với nhau. Chúng ta đã thảo luận về một cách chúng ta có thể làm điều đó trong phần "Kiểu Tuple" của Chương 3: bằng cách sử dụng các tuple.

Cải tiến với Tuples

Listing 5-9 hiển thị một phiên bản khác của chương trình chúng ta sử dụng tuples.

fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

Ở một khía cạnh, chương trình này tốt hơn. Tuples cho phép chúng ta thêm một chút cấu trúc và giờ đây chúng ta chỉ truyền một đối số. Nhưng ở một khía cạnh khác, phiên bản này ít rõ ràng hơn: tuples không đặt tên cho các phần tử của chúng, vì vậy chúng ta phải lập chỉ mục vào các phần của tuple, làm cho phép tính của chúng ta kém rõ ràng hơn.

Việc nhầm lẫn chiều rộng và chiều cao sẽ không ảnh hưởng đến phép tính diện tích, nhưng nếu chúng ta muốn vẽ hình chữ nhật trên màn hình, điều đó sẽ quan trọng! Chúng ta sẽ phải nhớ rằng width là chỉ mục tuple 0height là chỉ mục tuple 1. Điều này sẽ càng khó hơn cho người khác để hiểu và ghi nhớ nếu họ sử dụng mã của chúng ta. Bởi vì chúng ta chưa truyền đạt ý nghĩa của dữ liệu trong mã của mình, việc dễ gây ra lỗi hơn.

Cải tiến với Structs: Thêm Ý nghĩa

Chúng ta sử dụng structs để thêm ý nghĩa bằng cách gắn nhãn cho dữ liệu. Chúng ta có thể chuyển đổi tuple chúng ta đang sử dụng thành một struct với một tên cho toàn bộ cũng như các tên cho các phần, như được hiển thị trong Listing 5-10.

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

Ở đây, chúng ta đã định nghĩa một struct và đặt tên nó là Rectangle. Bên trong dấu ngoặc nhọn, chúng ta định nghĩa các trường là widthheight, cả hai đều có kiểu u32. Sau đó, trong main, chúng ta tạo một instance cụ thể của Rectangle có chiều rộng 30 và chiều cao 50.

Hàm area của chúng ta bây giờ được định nghĩa với một tham số, mà chúng ta đã đặt tên là rectangle, có kiểu là một tham chiếu không thể thay đổi đến một instance struct Rectangle. Như đã đề cập trong Chương 4, chúng ta muốn mượn struct thay vì lấy quyền sở hữu của nó. Bằng cách này, main giữ quyền sở hữu và có thể tiếp tục sử dụng rect1, đó là lý do tại sao chúng ta sử dụng & trong chữ ký hàm và nơi chúng ta gọi hàm.

Hàm area truy cập vào các trường widthheight của instance Rectangle (lưu ý rằng việc truy cập các trường của một instance struct đã được mượn không di chuyển các giá trị trường, đó là lý do tại sao bạn thường thấy việc mượn các struct). Chữ ký hàm cho area bây giờ nói chính xác những gì chúng ta muốn: tính diện tích của Rectangle, sử dụng các trường widthheight. Điều này truyền đạt rằng chiều rộng và chiều cao có liên quan đến nhau, và nó đưa ra các tên mô tả cho các giá trị thay vì sử dụng các giá trị chỉ mục tuple 01. Đây là một thắng lợi cho sự rõ ràng.

Thêm Chức năng Hữu ích với Derived Traits

Sẽ rất hữu ích nếu có thể in ra một instance của Rectangle trong khi chúng ta đang gỡ lỗi chương trình và xem các giá trị cho tất cả các trường của nó. Listing 5-11 thử sử dụng macro println! như chúng ta đã sử dụng trong các chương trước. Tuy nhiên, điều này sẽ không hoạt động.

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1}");
}

Khi chúng ta biên dịch đoạn mã này, chúng ta nhận được một lỗi với thông báo cốt lõi này:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

Macro println! có thể thực hiện nhiều loại định dạng, và theo mặc định, dấu ngoặc nhọn báo cho println! sử dụng định dạng được gọi là Display: đầu ra dành cho người dùng cuối trực tiếp. Các kiểu nguyên thủy mà chúng ta đã thấy cho đến nay triển khai Display theo mặc định vì chỉ có một cách bạn muốn hiển thị 1 hoặc bất kỳ kiểu nguyên thủy nào khác cho người dùng. Nhưng với các struct, cách println! nên định dạng đầu ra ít rõ ràng hơn vì có nhiều khả năng hiển thị hơn: Bạn có muốn dấu phẩy hay không? Bạn có muốn in dấu ngoặc nhọn? Tất cả các trường có nên được hiển thị không? Do tính mơ hồ này, Rust không cố đoán những gì chúng ta muốn, và các struct không có sẵn thực thi của Display để sử dụng với println! và placeholder {}.

Nếu chúng ta tiếp tục đọc các lỗi, chúng ta sẽ tìm thấy ghi chú hữu ích này:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Hãy thử nó! Lệnh gọi macro println! bây giờ sẽ trông như println!("rect1 is {rect1:?}");. Đặt đặc tả :? bên trong dấu ngoặc nhọn báo cho println! rằng chúng ta muốn sử dụng một định dạng đầu ra được gọi là Debug. Trait Debug cho phép chúng ta in struct theo cách hữu ích cho các nhà phát triển để chúng ta có thể thấy giá trị của nó trong khi chúng ta đang gỡ lỗi mã của mình.

Biên dịch mã với thay đổi này. Chết tiệt! Chúng ta vẫn nhận được một lỗi:

error[E0277]: `Rectangle` doesn't implement `Debug`

Nhưng một lần nữa, trình biên dịch cung cấp cho chúng ta một ghi chú hữu ích:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust bao gồm chức năng để in ra thông tin gỡ lỗi, nhưng chúng ta phải chọn tham gia một cách rõ ràng để làm cho chức năng đó có sẵn cho struct của chúng ta. Để làm điều đó, chúng ta thêm thuộc tính ngoài #[derive(Debug)] ngay trước định nghĩa struct, như được hiển thị trong Listing 5-12.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}

Bây giờ khi chúng ta chạy chương trình, chúng ta sẽ không gặp bất kỳ lỗi nào, và chúng ta sẽ thấy đầu ra sau:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

Tuyệt! Đây không phải là đầu ra đẹp nhất, nhưng nó hiển thị các giá trị của tất cả các trường cho instance này, điều này chắc chắn sẽ giúp ích trong quá trình gỡ lỗi. Khi chúng ta có các struct lớn hơn, sẽ hữu ích khi có đầu ra dễ đọc hơn một chút; trong những trường hợp đó, chúng ta có thể sử dụng {:#?} thay vì {:?} trong chuỗi println!. Trong ví dụ này, sử dụng kiểu {:#?} sẽ xuất ra như sau:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Một cách khác để in ra một giá trị sử dụng định dạng Debug là sử dụng macro dbg!, lấy quyền sở hữu của một biểu thức (trái ngược với println!, lấy một tham chiếu), in ra tệp và số dòng nơi lệnh gọi macro dbg! đó xuất hiện trong mã của bạn cùng với giá trị kết quả của biểu thức đó, và trả lại quyền sở hữu của giá trị.

Lưu ý: Gọi macro dbg! in ra luồng bảng điều khiển lỗi tiêu chuẩn (stderr), trái ngược với println!, in ra luồng bảng điều khiển đầu ra tiêu chuẩn (stdout). Chúng ta sẽ nói thêm về stderrstdout trong phần "Viết Thông báo Lỗi cho Standard Error Thay vì Standard Output" trong Chương 12.

Đây là một ví dụ trong đó chúng ta quan tâm đến giá trị được gán cho trường width, cũng như giá trị của toàn bộ struct trong rect1:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

Chúng ta có thể đặt dbg! xung quanh biểu thức 30 * scale và, bởi vì dbg! trả lại quyền sở hữu của giá trị biểu thức, trường width sẽ nhận được cùng một giá trị như thể chúng ta không có lệnh gọi dbg! ở đó. Chúng ta không muốn dbg! lấy quyền sở hữu của rect1, vì vậy chúng ta sử dụng một tham chiếu đến rect1 trong lệnh gọi tiếp theo. Đây là đầu ra của ví dụ này:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

Chúng ta có thể thấy phần đầu tiên của đầu ra đến từ dòng 10 của src/main.rs nơi chúng ta đang gỡ lỗi biểu thức 30 * scale, và giá trị kết quả của nó là 60 (định dạng Debug được thực hiện cho các số nguyên là chỉ in giá trị của chúng). Lệnh gọi dbg! trên dòng 14 của src/main.rs xuất ra giá trị của &rect1, đó là struct Rectangle. Đầu ra này sử dụng định dạng Debug đẹp của kiểu Rectangle. Macro dbg! có thể thực sự hữu ích khi bạn đang cố gắng tìm hiểu xem mã của bạn đang làm gì!

Ngoài trait Debug, Rust đã cung cấp một số trait cho chúng ta sử dụng với thuộc tính derive có thể thêm hành vi hữu ích cho các kiểu tùy chỉnh của chúng ta. Những trait đó và hành vi của chúng được liệt kê trong Phụ lục C. Chúng ta sẽ đề cập đến cách triển khai các trait này với hành vi tùy chỉnh cũng như cách tạo trait của riêng mình trong Chương 10. Ngoài ra còn có nhiều thuộc tính khác ngoài derive; để biết thêm thông tin, xem phần "Thuộc tính" của Tham chiếu Rust.

Hàm area của chúng ta rất cụ thể: nó chỉ tính diện tích của hình chữ nhật. Sẽ hữu ích hơn nếu gắn hành vi này chặt chẽ hơn với struct Rectangle của chúng ta vì nó sẽ không hoạt động với bất kỳ kiểu nào khác. Hãy xem xét làm thế nào chúng ta có thể tiếp tục cải tiến mã này bằng cách chuyển hàm area thành phương thức area được định nghĩa trên kiểu Rectangle của chúng ta.

Cú pháp Method

Method tương tự như các hàm: chúng ta khai báo chúng với từ khóa fn và một tên, chúng có thể có các tham số và giá trị trả về, và chúng chứa một số mã được chạy khi method được gọi từ nơi khác. Không giống như các hàm, method được định nghĩa trong ngữ cảnh của một struct (hoặc một enum hoặc một trait object, mà chúng ta sẽ đề cập trong Chương 6Chương 18, tương ứng), và tham số đầu tiên của chúng luôn là self, đại diện cho instance của struct mà method đang được gọi.

Định nghĩa Method

Hãy thay đổi hàm area có một instance Rectangle làm tham số và thay vào đó, tạo một method area được định nghĩa trên struct Rectangle, như thể hiện trong Listing 5-13.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

Để định nghĩa hàm trong ngữ cảnh của Rectangle, chúng ta bắt đầu một khối impl (implementation) cho Rectangle. Mọi thứ trong khối impl này sẽ được liên kết với kiểu Rectangle. Sau đó, chúng ta di chuyển hàm area vào trong dấu ngoặc nhọn impl và thay đổi tham số đầu tiên (và trong trường hợp này, duy nhất) thành self trong chữ ký và mọi nơi trong nội dung hàm. Trong main, nơi chúng ta đã gọi hàm area và truyền rect1 như một đối số, chúng ta có thể sử dụng cú pháp method để gọi method area trên instance Rectangle của chúng ta. Cú pháp method được đặt sau một instance: chúng ta thêm một dấu chấm theo sau bởi tên method, dấu ngoặc đơn, và bất kỳ đối số nào.

Trong chữ ký cho area, chúng ta sử dụng &self thay vì rectangle: &Rectangle. &self thực tế là viết tắt của self: &Self. Trong một khối impl, kiểu Self là bí danh cho kiểu mà khối impl đang áp dụng. Method phải có một tham số tên là self kiểu Self làm tham số đầu tiên, vì vậy Rust cho phép bạn viết tắt điều này với chỉ tên self ở vị trí tham số đầu tiên. Lưu ý rằng chúng ta vẫn cần sử dụng & trước viết tắt self để chỉ ra rằng method này mượn instance Self, giống như chúng ta đã làm trong rectangle: &Rectangle. Method có thể lấy quyền sở hữu của self, mượn self không thay đổi như chúng ta đã làm ở đây, hoặc mượn self có thể thay đổi, giống như chúng có thể với bất kỳ tham số nào khác.

Chúng ta chọn &self ở đây vì cùng lý do chúng ta đã sử dụng &Rectangle trong phiên bản hàm: chúng ta không muốn lấy quyền sở hữu, và chúng ta chỉ muốn đọc dữ liệu trong struct, không viết vào nó. Nếu chúng ta muốn thay đổi instance mà chúng ta đã gọi method như một phần của những gì method làm, chúng ta sẽ sử dụng &mut self làm tham số đầu tiên. Có một method lấy quyền sở hữu của instance bằng cách chỉ sử dụng self làm tham số đầu tiên là hiếm; kỹ thuật này thường được sử dụng khi method chuyển đổi self thành một cái gì đó khác và bạn muốn ngăn người gọi sử dụng instance ban đầu sau khi chuyển đổi.

Lý do chính để sử dụng method thay vì hàm, ngoài việc cung cấp cú pháp method và không phải lặp lại kiểu của self trong mọi chữ ký method, là để tổ chức. Chúng ta đã đặt tất cả những thứ có thể làm với một instance của một kiểu trong một khối impl thay vì làm cho người dùng tương lai của mã chúng ta phải tìm kiếm khả năng của Rectangle ở nhiều nơi khác nhau trong thư viện mà chúng ta cung cấp.

Lưu ý rằng chúng ta có thể chọn đặt tên cho một method giống với tên của một trong các trường của struct. Ví dụ, chúng ta có thể định nghĩa một method trên Rectangle cũng được đặt tên là width:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

Ở đây, chúng ta đang chọn cách làm cho method width trả về true nếu giá trị trong trường width của instance lớn hơn 0false nếu giá trị là 0: chúng ta có thể sử dụng một trường trong một method cùng tên cho bất kỳ mục đích nào. Trong main, khi chúng ta theo sau rect1.width với dấu ngoặc đơn, Rust biết chúng ta đang đề cập đến method width. Khi chúng ta không sử dụng dấu ngoặc đơn, Rust biết chúng ta đang đề cập đến trường width.

Thường xuyên, nhưng không phải lúc nào cũng vậy, khi chúng ta đặt cho một method cùng tên với một trường, chúng ta muốn nó chỉ trả về giá trị trong trường đó và không làm gì khác. Các method như thế này được gọi là getter, và Rust không tự động triển khai chúng cho các trường của struct như một số ngôn ngữ khác. Getter rất hữu ích vì bạn có thể làm cho trường này riêng tư nhưng method là công khai, và do đó cho phép truy cập chỉ đọc vào trường đó như một phần của API công khai của kiểu. Chúng ta sẽ thảo luận về các khái niệm công khai và riêng tư là gì và cách chỉ định một trường hoặc method là công khai hay riêng tư trong Chương 7.

Toán tử -> Ở Đâu?

Trong C và C++, hai toán tử khác nhau được sử dụng để gọi method: bạn sử dụng . nếu bạn đang gọi một method trực tiếp trên đối tượng và -> nếu bạn đang gọi method trên một con trỏ đến đối tượng và cần giải tham chiếu con trỏ trước. Nói cách khác, nếu object là một con trỏ, object->something() tương tự như (*object).something().

Rust không có một toán tử tương đương với ->; thay vào đó, Rust có một tính năng gọi là tham chiếu và giải tham chiếu tự động. Gọi method là một trong số ít nơi trong Rust có hành vi này.

Đây là cách nó hoạt động: khi bạn gọi một method bằng object.something(), Rust tự động thêm &, &mut, hoặc * để object khớp với chữ ký của method. Nói cách khác, những điều sau đây là giống nhau:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

Cái đầu tiên trông gọn gàng hơn nhiều. Hành vi tham chiếu tự động này hoạt động bởi vì method có một người nhận rõ ràng—kiểu của self. Với người nhận và tên của một method, Rust có thể xác định rõ ràng liệu method đang đọc (&self), biến đổi (&mut self), hay tiêu thụ (self). Thực tế là Rust làm cho việc mượn ngầm định cho người nhận method là một phần lớn của việc làm cho quyền sở hữu trở nên tiện dụng trong thực tế.

Method với Nhiều Tham số

Hãy thực hành sử dụng method bằng cách triển khai một method thứ hai trên struct Rectangle. Lần này chúng ta muốn một instance của Rectangle lấy một instance khác của Rectangle và trả về true nếu Rectangle thứ hai có thể nằm hoàn toàn bên trong self (tức là Rectangle đầu tiên); nếu không, nó sẽ trả về false. Nghĩa là, một khi chúng ta đã định nghĩa method can_hold, chúng ta muốn có thể viết chương trình được hiển thị trong Listing 5-14.

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Đầu ra dự kiến sẽ trông giống như sau bởi vì cả hai kích thước của rect2 đều nhỏ hơn kích thước của rect1, nhưng rect3 rộng hơn rect1:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

Chúng ta biết mình muốn định nghĩa một method, vì vậy nó sẽ nằm trong khối impl Rectangle. Tên method sẽ là can_hold, và nó sẽ lấy một bản mượn không thay đổi của một Rectangle khác làm tham số. Chúng ta có thể biết kiểu của tham số sẽ là gì bằng cách nhìn vào mã gọi method: rect1.can_hold(&rect2) truyền vào &rect2, đó là một bản mượn không thay đổi cho rect2, một instance của Rectangle. Điều này có ý nghĩa vì chúng ta chỉ cần đọc rect2 (thay vì viết, có nghĩa là chúng ta cần một bản mượn có thể thay đổi), và chúng ta muốn main giữ quyền sở hữu của rect2 để chúng ta có thể sử dụng nó lại sau khi gọi method can_hold. Giá trị trả về của can_hold sẽ là một giá trị Boolean, và việc triển khai sẽ kiểm tra xem chiều rộng và chiều cao của self có lớn hơn chiều rộng và chiều cao của Rectangle khác hay không, tương ứng. Hãy thêm method can_hold mới vào khối impl từ Listing 5-13, như được hiển thị trong Listing 5-15.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Khi chúng ta chạy mã này với hàm main trong Listing 5-14, chúng ta sẽ nhận được đầu ra mong muốn. Method có thể lấy nhiều tham số mà chúng ta thêm vào chữ ký sau tham số self, và những tham số đó hoạt động giống như tham số trong hàm.

Hàm Liên kết

Tất cả các hàm được định nghĩa trong một khối impl được gọi là hàm liên kết bởi vì chúng được liên kết với kiểu được đặt tên sau impl. Chúng ta có thể định nghĩa các hàm liên kết mà không có self là tham số đầu tiên của chúng (và do đó không phải là method) bởi vì chúng không cần một instance của kiểu để làm việc với. Chúng ta đã sử dụng một hàm như thế này: hàm String::from mà được định nghĩa trên kiểu String.

Các hàm liên kết không phải là method thường được sử dụng cho các constructor sẽ trả về một instance mới của struct. Những hàm này thường được gọi là new, nhưng new không phải là một tên đặc biệt và không được tích hợp sẵn trong ngôn ngữ. Ví dụ, chúng ta có thể chọn cung cấp một hàm liên kết có tên square sẽ có một tham số kích thước và sử dụng nó làm cả chiều rộng và chiều cao, từ đó giúp tạo một Rectangle vuông dễ dàng hơn thay vì phải chỉ định cùng một giá trị hai lần:

Tên tệp: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

Từ khóa Self trong kiểu trả về và trong nội dung của hàm là bí danh cho kiểu xuất hiện sau từ khóa impl, trong trường hợp này là Rectangle.

Để gọi hàm liên kết này, chúng ta sử dụng cú pháp :: với tên struct; let sq = Rectangle::square(3); là một ví dụ. Hàm này được đặt tên trong không gian tên bởi struct: cú pháp :: được sử dụng cho cả hàm liên kết và không gian tên được tạo bởi các module. Chúng ta sẽ thảo luận về các module trong Chương 7.

Nhiều Khối impl

Mỗi struct được phép có nhiều khối impl. Ví dụ, Listing 5-15 tương đương với mã được hiển thị trong Listing 5-16, trong đó mỗi method nằm trong khối impl riêng của nó.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Không có lý do gì để tách các method này thành nhiều khối impl ở đây, nhưng đây là cú pháp hợp lệ. Chúng ta sẽ thấy một trường hợp trong đó nhiều khối impl là hữu ích trong Chương 10, nơi chúng ta thảo luận về các kiểu và trait generic.

Tóm tắt

Struct cho phép bạn tạo các kiểu tùy chỉnh có ý nghĩa cho miền của bạn. Bằng cách sử dụng struct, bạn có thể giữ các phần dữ liệu liên quan với nhau và đặt tên cho mỗi phần để làm mã của bạn rõ ràng. Trong các khối impl, bạn có thể định nghĩa các hàm được liên kết với kiểu của bạn, và method là một loại hàm liên kết cho phép bạn chỉ định hành vi mà các instance của struct của bạn có.

Nhưng struct không phải là cách duy nhất để bạn tạo kiểu tùy chỉnh: hãy chuyển sang tính năng enum của Rust để thêm một công cụ khác vào hộp công cụ của bạn.

Enums và Pattern Matching

Trong chương này, chúng ta sẽ tìm hiểu về enumerations, hay còn được gọi là enums. Enums cho phép bạn định nghĩa một kiểu bằng cách liệt kê các biến thể (variants) có thể có của nó. Đầu tiên chúng ta sẽ định nghĩa và sử dụng một enum để chỉ ra cách một enum có thể mã hóa ý nghĩa cùng với dữ liệu. Tiếp theo, chúng ta sẽ khám phá một enum đặc biệt hữu ích là Option, nó biểu thị rằng một giá trị có thể là một cái gì đó hoặc không là gì cả. Sau đó, chúng ta sẽ xem xét cách mà pattern matching trong biểu thức match giúp dễ dàng chạy các đoạn mã khác nhau cho các giá trị khác nhau của một enum. Cuối cùng, chúng ta sẽ tìm hiểu cách cấu trúc if let là một thành ngữ khác tiện lợi và ngắn gọn có sẵn để xử lý enums trong mã của bạn.

Định nghĩa một Enum

Trong khi struct cung cấp cho bạn cách nhóm các trường và dữ liệu liên quan với nhau, như một Rectangle với widthheight của nó, enum cho bạn cách để nói rằng một giá trị là một trong một tập hợp các giá trị có thể có. Ví dụ, chúng ta có thể muốn nói rằng Rectangle là một trong một tập hợp các hình dạng có thể có bao gồm cả CircleTriangle. Để làm được điều này, Rust cho phép chúng ta mã hóa các khả năng này như một enum.

Hãy xem xét một tình huống mà chúng ta có thể muốn biểu đạt trong mã và tìm hiểu tại sao enum hữu ích và phù hợp hơn struct trong trường hợp này. Giả sử chúng ta cần làm việc với địa chỉ IP. Hiện tại, hai chuẩn chính được sử dụng cho địa chỉ IP: phiên bản bốn và phiên bản sáu. Vì đây là những khả năng duy nhất cho một địa chỉ IP mà chương trình của chúng ta sẽ gặp phải, chúng ta có thể liệt kê tất cả các biến thể có thể có, đó là nơi mà enumeration có được tên gọi của nó.

Bất kỳ địa chỉ IP nào cũng có thể là địa chỉ phiên bản bốn hoặc phiên bản sáu, nhưng không thể là cả hai cùng một lúc. Đặc tính đó của địa chỉ IP làm cho cấu trúc dữ liệu enum trở nên thích hợp bởi vì một giá trị enum chỉ có thể là một trong các biến thể của nó. Cả hai địa chỉ phiên bản bốn và phiên bản sáu đều về cơ bản vẫn là địa chỉ IP, vì vậy chúng nên được xử lý như cùng một kiểu khi mã đang xử lý các tình huống áp dụng cho bất kỳ loại địa chỉ IP nào.

Chúng ta có thể biểu đạt khái niệm này trong mã bằng cách định nghĩa một enum IpAddrKind và liệt kê các loại có thể có của địa chỉ IP, V4V6. Đây là các biến thể của enum:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

IpAddrKind bây giờ là một kiểu dữ liệu tùy chỉnh mà chúng ta có thể sử dụng ở nơi khác trong mã của mình.

Giá trị Enum

Chúng ta có thể tạo các instance của mỗi biến thể của IpAddrKind như thế này:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Lưu ý rằng các biến thể của enum được đặt tên trong không gian tên dưới định danh của nó, và chúng ta sử dụng dấu hai chấm kép để tách chúng. Điều này hữu ích bởi vì bây giờ cả hai giá trị IpAddrKind::V4IpAddrKind::V6 đều thuộc cùng một kiểu: IpAddrKind. Chúng ta sau đó có thể, ví dụ, định nghĩa một hàm nhận bất kỳ IpAddrKind nào:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Và chúng ta có thể gọi hàm này với cả hai biến thể:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Sử dụng enum thậm chí còn có nhiều lợi thế hơn. Khi suy nghĩ nhiều hơn về kiểu địa chỉ IP của chúng ta, hiện tại chúng ta không có cách nào để lưu trữ dữ liệu thực tế của địa chỉ IP; chúng ta chỉ biết nó thuộc loại nào. Vì bạn vừa mới học về struct trong Chương 5, bạn có thể bị cám dỗ để giải quyết vấn đề này với struct như được thể hiện trong Listing 6-1.

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

Ở đây, chúng ta đã định nghĩa một struct IpAddr có hai trường: một trường kind là kiểu IpAddrKind (enum mà chúng ta đã định nghĩa trước đó) và một trường address kiểu String. Chúng ta có hai instance của struct này. Instance đầu tiên là home, và nó có giá trị IpAddrKind::V4 làm kind với dữ liệu địa chỉ liên quan là 127.0.0.1. Instance thứ hai là loopback. Nó có biến thể khác của IpAddrKind làm giá trị kind của nó, V6, và có địa chỉ ::1 liên kết với nó. Chúng ta đã sử dụng một struct để nhóm các giá trị kindaddress lại với nhau, vì vậy bây giờ biến thể được liên kết với giá trị.

Tuy nhiên, biểu diễn cùng một khái niệm chỉ bằng enum thì ngắn gọn hơn: thay vì một enum bên trong một struct, chúng ta có thể đưa dữ liệu trực tiếp vào mỗi biến thể enum. Định nghĩa mới này của enum IpAddr nói rằng cả hai biến thể V4V6 sẽ có các giá trị String liên quan:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

Chúng ta gắn dữ liệu trực tiếp vào mỗi biến thể của enum, vì vậy không cần một struct bổ sung. Ở đây, cũng dễ dàng hơn để thấy một chi tiết khác về cách enum hoạt động: tên của mỗi biến thể enum mà chúng ta định nghĩa cũng trở thành một hàm xây dựng một instance của enum đó. Nghĩa là, IpAddr::V4() là một lệnh gọi hàm nhận một đối số String và trả về một instance của kiểu IpAddr. Chúng ta tự động nhận được hàm constructor này được định nghĩa là kết quả của việc định nghĩa enum.

Có một lợi thế khác khi sử dụng enum thay vì struct: mỗi biến thể có thể có các loại và số lượng dữ liệu liên quan khác nhau. Địa chỉ IP phiên bản bốn sẽ luôn có bốn thành phần số mà sẽ có giá trị từ 0 đến 255. Nếu chúng ta muốn lưu trữ địa chỉ V4 dưới dạng bốn giá trị u8 nhưng vẫn biểu đạt địa chỉ V6 dưới dạng một giá trị String, chúng ta sẽ không thể làm được với một struct. Enum xử lý trường hợp này một cách dễ dàng:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

Chúng ta đã thể hiện một số cách khác nhau để định nghĩa cấu trúc dữ liệu để lưu trữ phiên bản bốn và phiên bản sáu địa chỉ IP. Tuy nhiên, hóa ra, muốn lưu trữ địa chỉ IP và mã hóa loại nào của chúng là rất phổ biến đến nỗi thư viện chuẩn đã có một định nghĩa mà chúng ta có thể sử dụng! Hãy xem cách thư viện chuẩn định nghĩa IpAddr: nó có chính xác enum và các biến thể mà chúng ta đã định nghĩa và sử dụng, nhưng nó nhúng dữ liệu địa chỉ bên trong các biến thể dưới dạng hai struct khác nhau, được định nghĩa khác nhau cho mỗi biến thể:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

Đoạn mã này minh họa rằng bạn có thể đặt bất kỳ loại dữ liệu nào bên trong một biến thể enum: chuỗi, kiểu số, hoặc struct, ví dụ vậy. Bạn thậm chí có thể bao gồm một enum khác! Ngoài ra, các kiểu thư viện chuẩn thường không phức tạp hơn nhiều so với những gì bạn có thể nghĩ ra.

Lưu ý rằng mặc dù thư viện chuẩn chứa một định nghĩa cho IpAddr, chúng ta vẫn có thể tạo và sử dụng định nghĩa của riêng mình mà không gây xung đột vì chúng ta chưa đưa định nghĩa của thư viện chuẩn vào phạm vi của mình. Chúng ta sẽ nói thêm về việc đưa các kiểu vào phạm vi trong Chương 7.

Hãy xem một ví dụ khác về enum trong Listing 6-2: ví dụ này có sự đa dạng về các kiểu nhúng trong các biến thể của nó.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Enum này có bốn biến thể với các kiểu khác nhau:

  • Quit Không có dữ liệu nào liên kết với nó.
  • Move Có các trường có tên, giống như một struct.
  • Write Bao gồm một String duy nhất.
  • ChangeColor Bao gồm ba giá trị i32.

Định nghĩa một enum với các biến thể như trong Listing 6-2 tương tự như việc định nghĩa các loại định nghĩa struct khác nhau, ngoại trừ việc enum không sử dụng từ khóa struct và tất cả các biến thể được nhóm lại với nhau dưới kiểu Message. Các struct sau đây có thể lưu trữ cùng dữ liệu mà các biến thể enum trước đó lưu trữ:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

Nhưng nếu chúng ta sử dụng các struct khác nhau, mỗi struct có kiểu riêng, chúng ta không thể dễ dàng định nghĩa một hàm để nhận bất kỳ loại thông điệp nào trong số này như chúng ta có thể làm với enum Message được định nghĩa trong Listing 6-2, vốn là một kiểu duy nhất.

Có một điểm tương đồng nữa giữa enum và struct: giống như chúng ta có thể định nghĩa các phương thức trên struct bằng cách sử dụng impl, chúng ta cũng có thể định nghĩa các phương thức trên enum. Đây là một phương thức có tên call mà chúng ta có thể định nghĩa trên enum Message của chúng ta:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

Nội dung của phương thức sẽ sử dụng self để lấy giá trị mà chúng ta đã gọi phương thức trên đó. Trong ví dụ này, chúng ta đã tạo một biến m có giá trị Message::Write(String::from("hello")), và đó là giá trị mà self sẽ là trong nội dung của phương thức call khi m.call() chạy.

Hãy xem một enum khác trong thư viện chuẩn rất phổ biến và hữu ích: Option.

Enum Option và Lợi thế của Nó So với Giá trị Null

Phần này khám phá một trường hợp nghiên cứu của Option, một enum khác được định nghĩa bởi thư viện chuẩn. Kiểu Option mã hóa kịch bản rất phổ biến trong đó một giá trị có thể là một thứ gì đó hoặc có thể là không có gì.

Ví dụ, nếu bạn yêu cầu phần tử đầu tiên trong danh sách không rỗng, bạn sẽ nhận được một giá trị. Nếu bạn yêu cầu phần tử đầu tiên trong danh sách rỗng, bạn sẽ không nhận được gì. Biểu đạt khái niệm này trong hệ thống kiểu có nghĩa là trình biên dịch có thể kiểm tra liệu bạn đã xử lý tất cả các trường hợp mà bạn nên xử lý hay chưa; điều này có thể ngăn chặn các lỗi cực kỳ phổ biến trong các ngôn ngữ lập trình khác.

Thiết kế ngôn ngữ lập trình thường được nghĩ đến theo nghĩa của các tính năng bạn bao gồm, nhưng các tính năng bạn loại trừ cũng quan trọng. Rust không có tính năng null mà nhiều ngôn ngữ khác có. Null là một giá trị có nghĩa là không có giá trị nào ở đó. Trong các ngôn ngữ có null, biến luôn có thể ở một trong hai trạng thái: null hoặc không-null.

Trong bài thuyết trình năm 2009 của mình "Null References: The Billion Dollar Mistake," Tony Hoare, người phát minh ra null, đã nói như sau:

Tôi gọi đó là sai lầm hàng tỷ đô la của mình. Vào thời điểm đó, tôi đang thiết kế hệ thống kiểu đầu tiên toàn diện cho các tham chiếu trong một ngôn ngữ hướng đối tượng. Mục tiêu của tôi là đảm bảo rằng tất cả việc sử dụng tham chiếu phải hoàn toàn an toàn, với việc kiểm tra được thực hiện tự động bởi trình biên dịch. Nhưng tôi đã không thể cưỡng lại cám dỗ đặt vào một tham chiếu null, đơn giản vì nó rất dễ thực hiện. Điều này đã dẫn đến vô số lỗi, lỗ hổng và hệ thống bị sập, có lẽ đã gây ra tổn thất và thiệt hại hàng tỷ đô la trong bốn mươi năm qua.

Vấn đề với giá trị null là nếu bạn cố gắng sử dụng một giá trị null như một giá trị không-null, bạn sẽ gặp một loại lỗi nào đó. Bởi vì thuộc tính null hoặc không-null này là phổ biến, rất dễ mắc phải loại lỗi này.

Tuy nhiên, khái niệm mà null đang cố gắng biểu đạt vẫn là một khái niệm hữu ích: một null là một giá trị hiện không hợp lệ hoặc vắng mặt vì một lý do nào đó.

Vấn đề thực sự không phải là với khái niệm mà là với cách triển khai cụ thể. Vì vậy, Rust không có null, nhưng nó có một enum có thể mã hóa khái niệm về một giá trị đang hiện diện hoặc vắng mặt. Enum này là Option<T>, và nó được định nghĩa bởi thư viện chuẩn như sau:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Enum Option<T> rất hữu ích đến nỗi nó thậm chí được đưa vào prelude; bạn không cần phải đưa nó vào phạm vi một cách rõ ràng. Các biến thể của nó cũng được bao gồm trong prelude: bạn có thể sử dụng SomeNone trực tiếp mà không cần tiền tố Option::. Enum Option<T> vẫn chỉ là một enum thông thường, và Some(T)None vẫn là các biến thể của kiểu Option<T>.

Cú pháp <T> là một tính năng của Rust mà chúng ta chưa nói đến. Đó là một tham số kiểu generic, và chúng ta sẽ đề cập đến generic chi tiết hơn trong Chương 10. Hiện tại, tất cả những gì bạn cần biết là <T> có nghĩa là biến thể Some của enum Option có thể chứa một phần dữ liệu của bất kỳ kiểu nào, và mỗi kiểu cụ thể được sử dụng thay thế cho T làm cho toàn bộ kiểu Option<T> trở thành một kiểu khác. Dưới đây là một số ví dụ về việc sử dụng các giá trị Option để lưu trữ các kiểu số và kiểu ký tự:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

Kiểu của some_numberOption<i32>. Kiểu của some_charOption<char>, là một kiểu khác. Rust có thể suy ra các kiểu này bởi vì chúng ta đã chỉ định một giá trị bên trong biến thể Some. Đối với absent_number, Rust yêu cầu chúng ta chú thích toàn bộ kiểu Option: trình biên dịch không thể suy ra kiểu mà biến thể Some tương ứng sẽ giữ bằng cách chỉ nhìn vào một giá trị None. Ở đây, chúng ta nói với Rust rằng chúng ta muốn absent_number có kiểu Option<i32>.

Khi chúng ta có một giá trị Some, chúng ta biết rằng một giá trị đang hiện diện và giá trị đó được giữ bên trong Some. Khi chúng ta có một giá trị None, theo một nghĩa nào đó, nó có nghĩa giống như null: chúng ta không có một giá trị hợp lệ. Vậy tại sao việc có Option<T> lại tốt hơn việc có null?

Nói ngắn gọn, bởi vì Option<T>T (trong đó T có thể là bất kỳ kiểu nào) là các kiểu khác nhau, trình biên dịch sẽ không cho phép chúng ta sử dụng giá trị Option<T> như thể nó là một giá trị hợp lệ chắc chắn. Ví dụ, đoạn mã này sẽ không biên dịch, bởi vì nó đang cố gắng cộng một i8 với một Option<i8>:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

Nếu chúng ta chạy mã này, chúng ta sẽ nhận được một thông báo lỗi như thế này:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Mạnh mẽ! Về thực chất, thông báo lỗi này có nghĩa là Rust không hiểu cách cộng một i8 và một Option<i8>, bởi vì chúng là các kiểu khác nhau. Khi chúng ta có một giá trị của một kiểu như i8 trong Rust, trình biên dịch sẽ đảm bảo rằng chúng ta luôn có một giá trị hợp lệ. Chúng ta có thể tiến hành một cách tự tin mà không cần kiểm tra null trước khi sử dụng giá trị đó. Chỉ khi chúng ta có một Option<i8> (hoặc bất kỳ kiểu giá trị nào mà chúng ta đang làm việc) thì chúng ta mới phải lo lắng về việc có thể không có một giá trị, và trình biên dịch sẽ đảm bảo chúng ta xử lý trường hợp đó trước khi sử dụng giá trị.

Nói cách khác, bạn phải chuyển đổi một Option<T> thành một T trước khi bạn có thể thực hiện các hoạt động T với nó. Nói chung, điều này giúp bắt được một trong những vấn đề phổ biến nhất với null: giả định rằng một thứ không phải null khi nó thực sự là null.

Việc loại bỏ nguy cơ giả định không chính xác một giá trị không-null giúp bạn tự tin hơn về mã của mình. Để có một giá trị có thể là null, bạn phải chọn tham gia một cách rõ ràng bằng cách làm cho kiểu của giá trị đó là Option<T>. Sau đó, khi bạn sử dụng giá trị đó, bạn bắt buộc phải xử lý một cách rõ ràng trường hợp khi giá trị là null. Mọi nơi mà một giá trị có kiểu không phải là Option<T>, bạn có thể an toàn giả định rằng giá trị không phải là null. Đây là một quyết định thiết kế có chủ ý của Rust để hạn chế sự phổ biến của null và tăng tính an toàn của mã Rust.

Vậy làm thế nào để bạn lấy giá trị T ra khỏi một biến thể Some khi bạn có một giá trị kiểu Option<T> để bạn có thể sử dụng giá trị đó? Enum Option<T> có một số lượng lớn các phương thức hữu ích trong nhiều tình huống khác nhau; bạn có thể kiểm tra chúng trong tài liệu của nó. Làm quen với các phương thức trên Option<T> sẽ cực kỳ hữu ích trong hành trình của bạn với Rust.

Nói chung, để sử dụng một giá trị Option<T>, bạn muốn có mã sẽ xử lý mỗi biến thể. Bạn muốn một số mã chỉ chạy khi bạn có giá trị Some(T), và mã này được phép sử dụng T bên trong. Bạn muốn một số mã khác chỉ chạy nếu bạn có một giá trị None, và mã đó không có giá trị T nào để sử dụng. Biểu thức match là một cấu trúc luồng điều khiển làm chính xác điều này khi được sử dụng với enum: nó sẽ chạy mã khác nhau tùy thuộc vào biến thể nào của enum mà nó có, và mã đó có thể sử dụng dữ liệu bên trong giá trị phù hợp.

Cấu trúc Điều khiển Luồng match

Rust có một cấu trúc điều khiển luồng cực kỳ mạnh mẽ gọi là match, cho phép bạn so sánh một giá trị với một loạt các mẫu và sau đó thực thi mã dựa trên mẫu nào phù hợp. Các mẫu có thể được tạo thành từ các giá trị văn bản, tên biến, ký tự đại diện, và nhiều thứ khác; Chương 19 bao gồm tất cả các loại mẫu khác nhau và chức năng của chúng. Sức mạnh của match đến từ tính biểu đạt của các mẫu và việc trình biên dịch xác nhận rằng tất cả các trường hợp có thể xảy ra đều được xử lý.

Hãy nghĩ về biểu thức match giống như một máy phân loại tiền xu: các đồng xu trượt xuống một đường ray với các lỗ có kích thước khác nhau dọc theo nó, và mỗi đồng xu rơi qua lỗ đầu tiên mà nó gặp và vừa với nó. Tương tự như vậy, các giá trị đi qua từng mẫu trong một match, và tại mẫu đầu tiên mà giá trị "vừa vặn," giá trị đó rơi vào khối mã liên quan để được sử dụng trong quá trình thực thi.

Nói về tiền xu, hãy sử dụng chúng làm ví dụ với match! Chúng ta có thể viết một hàm nhận một đồng xu không xác định của Hoa Kỳ và, tương tự như máy đếm, xác định loại đồng xu nào và trả về giá trị của nó bằng xu, như trong Listing 6-3.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Hãy phân tích match trong hàm value_in_cents. Đầu tiên, chúng ta liệt kê từ khóa match và theo sau là một biểu thức, trong trường hợp này là giá trị coin. Điều này có vẻ rất giống với một biểu thức điều kiện được sử dụng với if, nhưng có một sự khác biệt lớn: với if, điều kiện cần phải đánh giá thành một giá trị Boolean, nhưng ở đây nó có thể là bất kỳ kiểu dữ liệu nào. Kiểu của coin trong ví dụ này là enum Coin mà chúng ta đã định nghĩa ở dòng đầu tiên.

Tiếp theo là các nhánh của match. Một nhánh có hai phần: một mẫu và một đoạn mã. Nhánh đầu tiên ở đây có mẫu là giá trị Coin::Penny và sau đó là toán tử => tách biệt mẫu và mã sẽ chạy. Mã trong trường hợp này chỉ đơn giản là giá trị 1. Mỗi nhánh được tách biệt với nhánh tiếp theo bằng một dấu phẩy.

Khi biểu thức match thực thi, nó so sánh giá trị kết quả với mẫu của mỗi nhánh, theo thứ tự. Nếu một mẫu khớp với giá trị, đoạn mã liên kết với mẫu đó sẽ được thực thi. Nếu mẫu đó không khớp với giá trị, việc thực thi tiếp tục đến nhánh tiếp theo, tương tự như trong máy phân loại tiền xu. Chúng ta có thể có nhiều nhánh tùy theo nhu cầu: trong Listing 6-3, match của chúng ta có bốn nhánh.

Đoạn mã liên kết với mỗi nhánh là một biểu thức, và giá trị kết quả của biểu thức trong nhánh khớp là giá trị được trả về cho toàn bộ biểu thức match.

Chúng ta thường không sử dụng dấu ngoặc nhọn nếu mã trong nhánh match ngắn gọn, như trong Listing 6-3 nơi mỗi nhánh chỉ trả về một giá trị. Nếu bạn muốn chạy nhiều dòng mã trong một nhánh match, bạn phải sử dụng dấu ngoặc nhọn, và dấu phẩy sau nhánh sau đó là tùy chọn. Ví dụ, đoạn mã sau in ra "Lucky penny!" mỗi khi phương thức được gọi với một Coin::Penny, nhưng vẫn trả về giá trị cuối cùng của khối, 1:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

Các Mẫu Gắn với Giá trị

Một tính năng hữu ích khác của nhánh match là chúng có thể gắn với các phần của giá trị khớp với mẫu. Đây là cách chúng ta có thể trích xuất các giá trị từ các biến thể enum.

Ví dụ, hãy thay đổi một trong các biến thể enum của chúng ta để lưu trữ dữ liệu bên trong nó. Từ năm 1999 đến 2008, Hoa Kỳ đã đúc đồng 25 xu với các thiết kế khác nhau cho mỗi tiểu bang trong số 50 tiểu bang trên một mặt. Không có đồng xu nào khác có thiết kế tiểu bang, vì vậy chỉ có đồng quarter có giá trị bổ sung này. Chúng ta có thể thêm thông tin này vào enum bằng cách thay đổi biến thể Quarter để bao gồm một giá trị UsState được lưu trữ bên trong nó, như chúng ta đã làm trong Listing 6-4.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}

Hãy tưởng tượng rằng một người bạn đang cố gắng sưu tập tất cả 50 đồng quarter của các tiểu bang. Trong khi chúng ta phân loại tiền lẻ theo loại đồng xu, chúng ta cũng sẽ nói to tên của tiểu bang liên quan đến mỗi đồng quarter để nếu đó là một đồng mà bạn của chúng ta chưa có, họ có thể thêm nó vào bộ sưu tập của mình.

Trong biểu thức match cho đoạn mã này, chúng ta thêm một biến có tên state vào mẫu khớp với các giá trị của biến thể Coin::Quarter. Khi một Coin::Quarter khớp, biến state sẽ gắn với giá trị của tiểu bang của đồng quarter đó. Sau đó, chúng ta có thể sử dụng state trong đoạn mã cho nhánh đó, như sau:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

Nếu chúng ta gọi value_in_cents(Coin::Quarter(UsState::Alaska)), coin sẽ là Coin::Quarter(UsState::Alaska). Khi chúng ta so sánh giá trị đó với từng nhánh match, không có nhánh nào khớp cho đến khi chúng ta đến Coin::Quarter(state). Tại thời điểm đó, giá trị gắn với state sẽ là UsState::Alaska. Chúng ta có thể sử dụng giá trị gắn đó trong biểu thức println!, từ đó lấy được giá trị tiểu bang bên trong ra khỏi biến thể enum Coin cho Quarter.

Khớp với Option<T>

Trong phần trước, chúng ta muốn lấy giá trị T bên trong ra khỏi trường hợp Some khi sử dụng Option<T>; chúng ta cũng có thể xử lý Option<T> bằng cách sử dụng match, như chúng ta đã làm với enum Coin! Thay vì so sánh các đồng xu, chúng ta sẽ so sánh các biến thể của Option<T>, nhưng cách biểu thức match hoạt động vẫn giữ nguyên.

Giả sử chúng ta muốn viết một hàm nhận một Option<i32> và, nếu có giá trị bên trong, cộng thêm 1 vào giá trị đó. Nếu không có giá trị bên trong, hàm sẽ trả về giá trị None và không cố gắng thực hiện bất kỳ thao tác nào.

Hàm này rất dễ viết, nhờ match, và sẽ trông giống như Listing 6-5.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Hãy xem xét lần thực thi đầu tiên của plus_one chi tiết hơn. Khi chúng ta gọi plus_one(five), biến x trong thân của plus_one sẽ có giá trị Some(5). Sau đó, chúng ta so sánh giá trị đó với từng nhánh match:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Giá trị Some(5) không khớp với mẫu None, vì vậy chúng ta tiếp tục đến nhánh tiếp theo:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5) có khớp với Some(i) không? Có! Chúng ta có cùng một biến thể. i gắn với giá trị được chứa trong Some, vì vậy i nhận giá trị 5. Sau đó, mã trong nhánh match được thực thi, vì vậy chúng ta cộng 1 vào giá trị của i và tạo ra một giá trị Some mới với tổng 6 bên trong.

Bây giờ hãy xem xét lần gọi thứ hai của plus_one trong Listing 6-5, trong đó xNone. Chúng ta đi vào match và so sánh với nhánh đầu tiên:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Nó khớp! Không có giá trị để cộng thêm, vì vậy chương trình dừng lại và trả về giá trị None ở phía bên phải của =>. Bởi vì nhánh đầu tiên đã khớp, không có nhánh nào khác được so sánh.

Kết hợp match và enum rất hữu ích trong nhiều tình huống. Bạn sẽ thấy mẫu này rất nhiều trong mã Rust: match đối với một enum, gắn một biến với dữ liệu bên trong, và sau đó thực thi mã dựa trên nó. Ban đầu có thể hơi khó hiểu, nhưng một khi bạn đã quen với nó, bạn sẽ ước rằng mọi ngôn ngữ đều có nó. Nó luôn là một tính năng yêu thích của người dùng.

Các Match Đều Phải Đầy đủ

Có một khía cạnh khác của match mà chúng ta cần thảo luận: các mẫu của nhánh phải bao gồm tất cả các khả năng. Hãy xem xét phiên bản này của hàm plus_one của chúng ta, có một lỗi và sẽ không biên dịch được:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Chúng ta không xử lý trường hợp None, vì vậy mã này sẽ gây ra lỗi. May mắn thay, đây là một lỗi mà Rust biết cách phát hiện. Nếu chúng ta cố gắng biên dịch mã này, chúng ta sẽ nhận được lỗi này:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
 ::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576:5
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Rust biết rằng chúng ta không bao gồm mọi trường hợp có thể xảy ra, và thậm chí còn biết mẫu nào chúng ta đã quên! Các match trong Rust là đầy đủ: chúng ta phải liệt kê tất cả các khả năng để mã hợp lệ. Đặc biệt là trong trường hợp Option<T>, khi Rust ngăn chúng ta quên xử lý rõ ràng trường hợp None, nó bảo vệ chúng ta khỏi việc giả định rằng chúng ta có một giá trị khi chúng ta có thể có null, từ đó làm cho lỗi hàng tỷ đô la đã thảo luận trước đó trở nên không thể.

Mẫu Bắt tất cả và Placeholder _

Sử dụng enum, chúng ta cũng có thể thực hiện các hành động đặc biệt cho một số giá trị cụ thể, nhưng đối với tất cả các giá trị khác, thực hiện một hành động mặc định. Hãy tưởng tượng chúng ta đang triển khai một trò chơi, trong đó, nếu bạn tung được số 3 trên xúc xắc, người chơi của bạn không di chuyển, mà thay vào đó nhận được một chiếc mũ đẹp mới. Nếu bạn tung được số 7, người chơi của bạn mất một chiếc mũ đẹp. Đối với tất cả các giá trị khác, người chơi của bạn di chuyển số ô đó trên bàn chơi. Dưới đây là một match triển khai logic đó, với kết quả của việc tung xúc xắc được cố định thay vì một giá trị ngẫu nhiên, và tất cả logic khác được biểu diễn bằng các hàm không có nội dung vì việc triển khai chúng nằm ngoài phạm vi của ví dụ này:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

Đối với hai nhánh đầu tiên, các mẫu là các giá trị văn bản 37. Đối với nhánh cuối cùng bao gồm mọi giá trị có thể khác, mẫu là biến mà chúng ta đã chọn đặt tên là other. Mã chạy cho nhánh other sử dụng biến bằng cách truyền nó vào hàm move_player.

Mã này biên dịch được, mặc dù chúng ta chưa liệt kê tất cả các giá trị có thể có của một u8, bởi vì mẫu cuối cùng sẽ khớp với tất cả các giá trị không được liệt kê cụ thể. Mẫu bắt tất cả này đáp ứng yêu cầu rằng match phải đầy đủ. Lưu ý rằng chúng ta phải đặt nhánh bắt tất cả cuối cùng vì các mẫu được đánh giá theo thứ tự. Nếu chúng ta đặt nhánh bắt tất cả sớm hơn, các nhánh khác sẽ không bao giờ chạy, vì vậy Rust sẽ cảnh báo chúng ta nếu chúng ta thêm nhánh sau một nhánh bắt tất cả!

Rust cũng có một mẫu mà chúng ta có thể sử dụng khi muốn bắt tất cả nhưng không muốn sử dụng giá trị trong mẫu bắt tất cả: _ là một mẫu đặc biệt khớp với bất kỳ giá trị nào và không gắn với giá trị đó. Điều này nói với Rust rằng chúng ta sẽ không sử dụng giá trị, vì vậy Rust sẽ không cảnh báo chúng ta về một biến không được sử dụng.

Hãy thay đổi luật của trò chơi: bây giờ, nếu bạn tung bất kỳ số nào khác ngoài 3 hoặc 7, bạn phải tung lại. Chúng ta không còn cần sử dụng giá trị bắt tất cả nữa, vì vậy chúng ta có thể thay đổi mã của mình để sử dụng _ thay vì biến có tên other:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

Ví dụ này cũng đáp ứng yêu cầu đầy đủ vì chúng ta đang rõ ràng bỏ qua tất cả các giá trị khác trong nhánh cuối cùng; chúng ta không quên bất cứ điều gì.

Cuối cùng, chúng ta sẽ thay đổi luật của trò chơi một lần nữa để không có gì khác xảy ra trong lượt của bạn nếu bạn tung bất kỳ số nào khác ngoài 3 hoặc 7. Chúng ta có thể biểu đạt điều đó bằng cách sử dụng giá trị đơn vị (kiểu tuple rỗng mà chúng ta đã đề cập trong phần "Kiểu Tuple") làm mã đi kèm với nhánh _:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

Ở đây, chúng ta đang nói với Rust một cách rõ ràng rằng chúng ta sẽ không sử dụng bất kỳ giá trị nào khác không khớp với mẫu trong một nhánh trước đó, và chúng ta không muốn chạy bất kỳ mã nào trong trường hợp này.

Còn nhiều điều về mẫu và khớp mà chúng ta sẽ đề cập trong Chương 19. Hiện tại, chúng ta sẽ tiếp tục với cú pháp if let, có thể hữu ích trong các tình huống mà biểu thức match hơi dài dòng.

Điều khiển Luồng Ngắn Gọn với if letlet else

Cú pháp if let cho phép bạn kết hợp iflet thành một cách ít dài dòng hơn để xử lý các giá trị khớp với một mẫu trong khi bỏ qua phần còn lại. Hãy xem xét chương trình trong Listing 6-6 khớp với một giá trị Option<u8> trong biến config_max nhưng chỉ muốn thực thi mã nếu giá trị là biến thể Some.

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {max}"),
        _ => (),
    }
}

Nếu giá trị là Some, chúng ta in ra giá trị trong biến thể Some bằng cách gắn giá trị với biến max trong mẫu. Chúng ta không muốn làm gì với giá trị None. Để thỏa mãn biểu thức match, chúng ta phải thêm _ => () sau khi xử lý chỉ một biến thể, đó là mã mẫu khó chịu cần thêm.

Thay vào đó, chúng ta có thể viết điều này ngắn gọn hơn bằng cách sử dụng if let. Đoạn mã sau hoạt động giống như match trong Listing 6-6:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }
}

Cú pháp if let lấy một mẫu và một biểu thức được phân tách bằng dấu bằng. Nó hoạt động giống cách như match, trong đó biểu thức được đưa vào match và mẫu là nhánh đầu tiên của nó. Trong trường hợp này, mẫu là Some(max), và max gắn với giá trị bên trong Some. Sau đó, chúng ta có thể sử dụng max trong phần thân của khối if let tương tự như cách chúng ta sử dụng max trong nhánh match tương ứng. Mã trong khối if let chỉ chạy nếu giá trị khớp với mẫu.

Sử dụng if let có nghĩa là ít phải gõ hơn, ít thụt đầu dòng, và ít mã mẫu. Tuy nhiên, bạn mất đi kiểm tra tính đầy đủ mà match bắt buộc để đảm bảo bạn không quên xử lý bất kỳ trường hợp nào. Việc lựa chọn giữa matchif let phụ thuộc vào những gì bạn đang làm trong tình huống cụ thể và liệu việc đạt được sự ngắn gọn có phải là một sự đánh đổi phù hợp cho việc mất đi kiểm tra tính đầy đủ hay không.

Nói cách khác, bạn có thể coi if let như là cú pháp đường tắt cho match mà chạy mã khi giá trị khớp với một mẫu và sau đó bỏ qua tất cả các giá trị khác.

Chúng ta có thể bao gồm một else với if let. Khối mã đi kèm với else giống như khối mã sẽ đi kèm với trường hợp _ trong biểu thức match tương đương với if letelse. Nhớ lại định nghĩa enum Coin trong Listing 6-4, trong đó biến thể Quarter cũng chứa một giá trị UsState. Nếu chúng ta muốn đếm tất cả các đồng xu không phải quarter mà chúng ta thấy đồng thời thông báo về tiểu bang của các quarter, chúng ta có thể làm điều đó với biểu thức match, như thế này:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }
}

Hoặc chúng ta có thể sử dụng biểu thức if letelse, như thế này:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }
}

Duy trì trên "Đường Hạnh phúc" với let...else

Mẫu phổ biến là thực hiện một số tính toán khi một giá trị hiện diện và trả về một giá trị mặc định nếu không. Tiếp tục với ví dụ của chúng ta về tiền xu với một giá trị UsState, nếu chúng ta muốn nói điều gì đó hài hước tùy thuộc vào tuổi của tiểu bang trên đồng quarter, chúng ta có thể giới thiệu một phương thức trên UsState để kiểm tra tuổi của một tiểu bang, như sau:

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Sau đó, chúng ta có thể sử dụng if let để khớp với loại xu, giới thiệu một biến state trong phần thân của điều kiện, như trong Listing 6-7.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Điều đó hoàn thành công việc, nhưng nó đã đẩy công việc vào trong phần thân của câu lệnh if let, và nếu công việc cần làm phức tạp hơn, có thể khó theo dõi chính xác cách các nhánh cấp cao liên quan đến nhau. Chúng ta cũng có thể tận dụng thực tế là các biểu thức tạo ra một giá trị hoặc để tạo ra state từ if let hoặc để trả về sớm, như trong Listing 6-8. (Bạn cũng có thể làm tương tự với match.)

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Điều này cũng hơi khó theo dõi theo cách riêng của nó! Một nhánh của if let tạo ra một giá trị, và nhánh kia trả về hoàn toàn từ hàm.

Để làm cho mẫu phổ biến này dễ diễn đạt hơn, Rust có let...else. Cú pháp let...else lấy một mẫu ở phía bên trái và một biểu thức ở bên phải, rất giống với if let, nhưng nó không có nhánh if, chỉ có nhánh else. Nếu mẫu khớp, nó sẽ gắn giá trị từ mẫu trong phạm vi bên ngoài. Nếu mẫu không khớp, chương trình sẽ chuyển vào nhánh else, phải trả về từ hàm.

Trong Listing 6-9, bạn có thể thấy Listing 6-8 trông như thế nào khi sử dụng let...else thay cho if let.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

Lưu ý rằng nó vẫn "trên đường hạnh phúc" trong phần thân chính của hàm theo cách này, không có luồng điều khiển khác biệt đáng kể cho hai nhánh như if let đã làm.

Nếu bạn có tình huống mà chương trình của bạn có logic quá dài dòng để diễn đạt bằng match, hãy nhớ rằng if letlet...else cũng có trong bộ công cụ Rust của bạn.

Tóm tắt

Bây giờ chúng ta đã đề cập đến cách sử dụng enum để tạo các kiểu tùy chỉnh có thể là một trong một tập hợp các giá trị được liệt kê. Chúng ta đã thấy cách kiểu Option<T> của thư viện tiêu chuẩn giúp bạn sử dụng hệ thống kiểu để ngăn chặn lỗi. Khi các giá trị enum có dữ liệu bên trong, bạn có thể sử dụng match hoặc if let để trích xuất và sử dụng các giá trị đó, tùy thuộc vào số lượng trường hợp bạn cần xử lý.

Các chương trình Rust của bạn giờ đây có thể diễn đạt các khái niệm trong miền của bạn bằng cách sử dụng struct và enum. Việc tạo các kiểu tùy chỉnh để sử dụng trong API của bạn đảm bảo tính an toàn kiểu: trình biên dịch sẽ đảm bảo rằng các hàm của bạn chỉ nhận các giá trị thuộc kiểu mà mỗi hàm mong đợi.

Để cung cấp một API được tổ chức tốt cho người dùng của bạn, dễ dàng sử dụng và chỉ hiển thị chính xác những gì người dùng của bạn sẽ cần, bây giờ hãy chuyển sang các mô-đun của Rust.

Quản lý Dự án Ngày càng Lớn với Packages, Crates, và Modules

Khi bạn viết các chương trình lớn, việc tổ chức mã của bạn sẽ ngày càng trở nên quan trọng. Bằng cách nhóm các chức năng liên quan và tách riêng mã với các tính năng riêng biệt, bạn sẽ làm rõ nơi tìm mã thực hiện một tính năng cụ thể và nơi để thay đổi cách một tính năng hoạt động.

Các chương trình chúng ta đã viết cho đến nay đều nằm trong một module trong một tệp. Khi một dự án phát triển, bạn nên tổ chức mã bằng cách chia nó thành nhiều module và sau đó là nhiều tệp. Một package có thể chứa nhiều binary crate và tùy chọn một library crate. Khi một package phát triển, bạn có thể trích xuất các phần thành các crate riêng biệt trở thành các phụ thuộc bên ngoài. Chương này đề cập đến tất cả các kỹ thuật này. Đối với các dự án rất lớn bao gồm một tập hợp các package có liên quan phát triển cùng nhau, Cargo cung cấp workspaces, mà chúng ta sẽ đề cập trong "Cargo Workspaces" ở Chương 14.

Chúng ta cũng sẽ thảo luận về việc đóng gói chi tiết triển khai, điều này cho phép bạn tái sử dụng mã ở mức cao hơn: khi bạn đã triển khai một thao tác, mã khác có thể gọi mã của bạn thông qua giao diện công khai mà không cần biết cách triển khai hoạt động như thế nào. Cách bạn viết mã định nghĩa những phần nào là công khai cho mã khác sử dụng và những phần nào là chi tiết triển khai riêng tư mà bạn có quyền thay đổi. Đây là một cách khác để hạn chế lượng chi tiết mà bạn phải giữ trong đầu.

Một khái niệm liên quan là phạm vi: ngữ cảnh lồng nhau mà mã được viết có một tập hợp các tên được định nghĩa là "trong phạm vi." Khi đọc, viết và biên dịch mã, các lập trình viên và trình biên dịch cần biết liệu một tên cụ thể tại một vị trí cụ thể đề cập đến một biến, hàm, struct, enum, module, hằng số, hoặc mục khác và mục đó có ý nghĩa gì. Bạn có thể tạo phạm vi và thay đổi những tên nào đang ở trong hoặc ngoài phạm vi. Bạn không thể có hai mục với cùng một tên trong cùng một phạm vi; các công cụ có sẵn để giải quyết xung đột tên.

Rust có một số tính năng cho phép bạn quản lý tổ chức mã của mình, bao gồm những chi tiết nào được hiển thị, những chi tiết nào là riêng tư, và những tên nào nằm trong mỗi phạm vi trong chương trình của bạn. Những tính năng này, đôi khi được gọi chung là hệ thống module, bao gồm:

  • Packages: Một tính năng của Cargo cho phép bạn xây dựng, kiểm tra và chia sẻ crates
  • Crates: Một cây các module tạo ra một thư viện hoặc một tệp thực thi
  • Modules và use: Cho phép bạn kiểm soát tổ chức, phạm vi và quyền riêng tư của các đường dẫn
  • Paths: Một cách để đặt tên cho một mục, chẳng hạn như struct, hàm hoặc module

Trong chương này, chúng ta sẽ đề cập đến tất cả các tính năng này, thảo luận về cách chúng tương tác, và giải thích cách sử dụng chúng để quản lý phạm vi. Đến cuối chương, bạn sẽ có hiểu biết vững chắc về hệ thống module và có thể làm việc với các phạm vi như một chuyên gia!

Packages và Crates

Phần đầu tiên của hệ thống module mà chúng ta sẽ đề cập đến là packages và crates.

Một crate là đơn vị code nhỏ nhất mà trình biên dịch Rust xem xét tại một thời điểm. Ngay cả khi bạn chạy rustc thay vì cargo và truyền vào một tệp mã nguồn duy nhất (như chúng ta đã làm trong "Viết và Chạy Chương Trình Rust" ở Chương 1), trình biên dịch xem tệp đó là một crate. Crates có thể chứa modules, và các modules có thể được định nghĩa trong các tệp khác được biên dịch cùng với crate, như chúng ta sẽ thấy trong các phần tiếp theo.

Một crate có thể có một trong hai hình thức: binary crate hoặc library crate. Binary crates là các chương trình bạn có thể biên dịch thành một tệp thực thi để chạy, như một chương trình dòng lệnh hoặc một máy chủ. Mỗi crate này phải có một hàm gọi là main để xác định điều gì sẽ xảy ra khi chương trình thực thi chạy. Tất cả các crate chúng ta đã tạo cho đến nay đều là binary crates.

Library crates không có hàm main, và chúng không được biên dịch thành tệp thực thi. Thay vào đó, chúng định nghĩa các chức năng được thiết kế để chia sẻ giữa nhiều dự án. Ví dụ, crate rand mà chúng ta đã sử dụng trong Chương 2 cung cấp chức năng tạo số ngẫu nhiên. Hầu hết thời gian khi các lập trình viên Rust nói “crate,“ họ thường muốn nói đến library crate, và họ sử dụng "crate" thay thế cho khái niệm lập trình chung là “library.“

Crate root là một tệp mã nguồn mà trình biên dịch Rust bắt đầu từ đó và tạo thành module gốc của crate của bạn (chúng ta sẽ giải thích kỹ về modules trong "Định nghĩa Modules để Kiểm soát Phạm vi và Quyền riêng tư").

Một package là một gói gồm một hoặc nhiều crates cung cấp một tập hợp chức năng. Một package chứa một tệp Cargo.toml mô tả cách xây dựng các crates đó. Cargo thực chất là một package chứa binary crate cho công cụ dòng lệnh mà bạn đã sử dụng để xây dựng mã của mình. Package Cargo cũng chứa một library crate mà binary crate phụ thuộc vào. Các dự án khác có thể phụ thuộc vào library crate Cargo để sử dụng logic tương tự như công cụ dòng lệnh Cargo sử dụng.

Một package có thể chứa nhiều binary crates tùy thích, nhưng nhiều nhất chỉ có một library crate. Một package phải chứa ít nhất một crate, dù đó là library hay binary crate.

Hãy đi qua những gì xảy ra khi chúng ta tạo một package. Đầu tiên, chúng ta nhập lệnh cargo new my-project:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

Sau khi chúng ta chạy cargo new my-project, chúng ta sử dụng ls để xem những gì Cargo tạo ra. Trong thư mục dự án, có một tệp Cargo.toml, tạo ra một package. Có một thư mục src chứa main.rs. Mở Cargo.toml trong trình soạn thảo của bạn và lưu ý rằng không có đề cập đến src/main.rs. Cargo tuân theo quy ước rằng src/main.rs là crate root của một binary crate có cùng tên với package. Tương tự, Cargo biết rằng nếu thư mục package chứa src/lib.rs, thì package chứa một library crate có cùng tên với package, và src/lib.rs là crate root. Cargo truyền các tệp crate root cho rustc để xây dựng thư viện hoặc chương trình.

Ở đây, chúng ta có một package chỉ chứa src/main.rs, nghĩa là nó chỉ chứa một binary crate có tên là my-project. Nếu một package chứa src/main.rssrc/lib.rs, nó có hai crates: một binary và một library, cả hai đều có cùng tên với package. Một package có thể có nhiều binary crates bằng cách đặt các tệp trong thư mục src/bin: mỗi tệp sẽ là một binary crate riêng biệt.

Định nghĩa Modules để Kiểm soát Phạm vi và Quyền riêng tư

Trong phần này, chúng ta sẽ nói về modules và các phần khác của hệ thống module, cụ thể là paths (đường dẫn), cho phép bạn đặt tên cho các item; từ khóa use đưa một đường dẫn vào phạm vi; và từ khóa pub để công khai các item. Chúng ta cũng sẽ thảo luận về từ khóa as, các gói bên ngoài, và toán tử glob.

Bảng tóm tắt về Modules

Trước khi đi vào chi tiết về modules và đường dẫn, ở đây chúng tôi cung cấp một tài liệu tham khảo nhanh về cách modules, đường dẫn, từ khóa use và từ khóa pub hoạt động trong trình biên dịch, và cách mà hầu hết các nhà phát triển tổ chức mã của họ. Chúng ta sẽ đi qua các ví dụ cho từng quy tắc này trong suốt chương này, nhưng đây là một nơi tuyệt vời để tham khảo như một lời nhắc về cách modules hoạt động.

  • Bắt đầu từ crate root: Khi biên dịch một crate, trình biên dịch đầu tiên tìm kiếm trong tệp crate root (thường là src/lib.rs cho library crate hoặc src/main.rs cho binary crate) để biên dịch mã.
  • Khai báo modules: Trong tệp crate root, bạn có thể khai báo modules mới; giả sử bạn khai báo một module "garden" với mod garden;. Trình biên dịch sẽ tìm kiếm mã của module ở những nơi sau:
    • Trực tiếp, trong dấu ngoặc nhọn thay thế dấu chấm phẩy sau mod garden
    • Trong tệp src/garden.rs
    • Trong tệp src/garden/mod.rs
  • Khai báo submodules: Trong bất kỳ tệp nào khác ngoài crate root, bạn có thể khai báo submodules. Ví dụ, bạn có thể khai báo mod vegetables; trong src/garden.rs. Trình biên dịch sẽ tìm kiếm mã của submodule trong thư mục có tên của module cha ở những nơi sau:
    • Trực tiếp, ngay sau mod vegetables, trong dấu ngoặc nhọn thay vì dấu chấm phẩy
    • Trong tệp src/garden/vegetables.rs
    • Trong tệp src/garden/vegetables/mod.rs
  • Đường dẫn đến mã trong modules: Khi một module là một phần của crate của bạn, bạn có thể tham chiếu đến mã trong module đó từ bất kỳ nơi nào khác trong cùng crate đó, miễn là các quy tắc quyền riêng tư cho phép, sử dụng đường dẫn đến mã. Ví dụ, một kiểu Asparagus trong module vegetables của garden sẽ được tìm thấy tại crate::garden::vegetables::Asparagus.
  • Riêng tư vs. công khai: Mã trong một module mặc định là riêng tư từ các module cha của nó. Để làm cho một module công khai, khai báo nó với pub mod thay vì mod. Để làm cho các item trong một module công khai cũng công khai, sử dụng pub trước khai báo của chúng.
  • Từ khóa use: Trong một phạm vi, từ khóa use tạo ra các lối tắt đến các item để giảm lặp lại các đường dẫn dài. Trong bất kỳ phạm vi nào có thể tham chiếu đến crate::garden::vegetables::Asparagus, bạn có thể tạo một lối tắt với use crate::garden::vegetables::Asparagus; và từ đó trở đi bạn chỉ cần viết Asparagus để sử dụng kiểu đó trong phạm vi.

Ở đây, chúng ta tạo một binary crate có tên backyard minh họa các quy tắc này. Thư mục của crate, cũng có tên là backyard, chứa các tệp và thư mục sau:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

Tệp crate root trong trường hợp này là src/main.rs, và nó chứa:

use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {plant:?}!");
}

Dòng pub mod garden; cho trình biên dịch biết để bao gồm mã mà nó tìm thấy trong src/garden.rs, đó là:

pub mod vegetables;

Ở đây, pub mod vegetables; có nghĩa là mã trong src/garden/vegetables.rs cũng được bao gồm. Mã đó là:

#[derive(Debug)]
pub struct Asparagus {}

Bây giờ chúng ta hãy đi vào chi tiết của các quy tắc này và minh họa chúng trong hành động!

Nhóm Mã Liên quan trong Modules

Modules cho phép chúng ta tổ chức mã trong một crate để dễ đọc và tái sử dụng. Modules cũng cho phép chúng ta kiểm soát quyền riêng tư của các item vì mã trong một module mặc định là riêng tư. Các item riêng tư là chi tiết triển khai nội bộ không có sẵn cho việc sử dụng bên ngoài. Chúng ta có thể chọn làm cho các modules và các item bên trong chúng công khai, điều này làm cho chúng hiện ra để cho phép mã bên ngoài sử dụng và phụ thuộc vào chúng.

Làm ví dụ, chúng ta hãy viết một library crate cung cấp chức năng của một nhà hàng. Chúng ta sẽ định nghĩa các chữ ký của các hàm nhưng để phần thân của chúng trống để tập trung vào việc tổ chức mã hơn là triển khai của một nhà hàng.

Trong ngành công nghiệp nhà hàng, một số phần của nhà hàng được gọi là front of house (tiền sảnh) và những phần khác là back of house (hậu trường). Front of house là nơi khách hàng đến; điều này bao gồm nơi các chủ nhà đón tiếp khách hàng, nhân viên phục vụ ghi nhận đơn hàng và thanh toán, và người pha chế pha đồ uống. Back of house là nơi các đầu bếp và người nấu ăn làm việc trong nhà bếp, người rửa bát dọn dẹp, và người quản lý làm công việc hành chính.

Để cấu trúc crate của chúng ta theo cách này, chúng ta có thể tổ chức các hàm của nó thành các modules lồng nhau. Tạo một thư viện mới có tên restaurant bằng cách chạy cargo new restaurant --lib. Sau đó nhập mã trong Listing 7-1 vào src/lib.rs để định nghĩa một số modules và chữ ký hàm; mã này là phần front of house.

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

Chúng ta định nghĩa một module với từ khóa mod theo sau là tên của module (trong trường hợp này, front_of_house). Phần thân của module sau đó đi vào trong dấu ngoặc nhọn. Bên trong modules, chúng ta có thể đặt các module khác, như trong trường hợp này với các module hostingserving. Modules cũng có thể chứa các định nghĩa cho các item khác, như structs, enums, constants, traits, và như trong Listing 7-1, các hàm.

Bằng cách sử dụng modules, chúng ta có thể nhóm các định nghĩa liên quan với nhau và đặt tên tại sao chúng liên quan. Các lập trình viên sử dụng mã này có thể điều hướng mã dựa trên các nhóm thay vì phải đọc qua tất cả các định nghĩa, làm cho việc tìm kiếm các định nghĩa liên quan đến họ trở nên dễ dàng hơn. Các lập trình viên thêm chức năng mới vào mã này sẽ biết nơi để đặt mã để giữ cho chương trình được tổ chức.

Trước đây, chúng ta đã đề cập rằng src/main.rssrc/lib.rs được gọi là crate roots. Lý do cho tên của chúng là vì nội dung của một trong hai tệp này tạo thành một module có tên crate ở gốc của cấu trúc module của crate, được gọi là cây module.

Listing 7-2 hiển thị cây module cho cấu trúc trong Listing 7-1.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

Cây này cho thấy cách một số modules lồng bên trong các module khác; ví dụ, hosting lồng bên trong front_of_house. Cây cũng cho thấy rằng một số modules là siblings (anh chị em), có nghĩa là chúng được định nghĩa trong cùng một module; hostingserving là anh chị em được định nghĩa trong front_of_house. Nếu module A được chứa bên trong module B, chúng ta nói rằng module A là con của module B và module B là cha của module A. Lưu ý rằng toàn bộ cây module được gốc dưới module ẩn có tên crate.

Cây module có thể nhắc bạn nhớ đến cây thư mục của hệ thống tệp trên máy tính của bạn; đây là một so sánh rất đúng! Giống như các thư mục trong hệ thống tệp, bạn sử dụng modules để tổ chức mã của mình. Và giống như các tệp trong một thư mục, chúng ta cần một cách để tìm thấy các module của mình.

Đường dẫn để Tham chiếu đến một Item trong Cây Module

Để chỉ cho Rust vị trí một item trong cây module, chúng ta sử dụng một đường dẫn tương tự như cách chúng ta sử dụng đường dẫn khi điều hướng trong hệ thống tệp. Để gọi một hàm, chúng ta cần biết đường dẫn của nó.

Một đường dẫn có thể có hai hình thức:

  • Một đường dẫn tuyệt đối là đường dẫn đầy đủ bắt đầu từ gốc của crate; đối với mã từ một crate bên ngoài, đường dẫn tuyệt đối bắt đầu bằng tên crate, và đối với mã từ crate hiện tại, nó bắt đầu bằng từ khóa crate.
  • Một đường dẫn tương đối bắt đầu từ module hiện tại và sử dụng self, super, hoặc một định danh trong module hiện tại.

Cả đường dẫn tuyệt đối và tương đối đều được theo sau bởi một hoặc nhiều định danh được phân tách bởi dấu hai chấm kép (::).

Quay lại Listing 7-1, giả sử chúng ta muốn gọi hàm add_to_waitlist. Điều này cũng giống như việc hỏi: đâu là đường dẫn của hàm add_to_waitlist? Listing 7-3 chứa nội dung của Listing 7-1 nhưng đã loại bỏ một số modules và hàm.

Chúng ta sẽ thể hiện hai cách để gọi hàm add_to_waitlist từ một hàm mới, eat_at_restaurant, được định nghĩa trong gốc của crate. Các đường dẫn này là đúng, nhưng vẫn còn một vấn đề khác sẽ ngăn ví dụ này biên dịch như hiện tại. Chúng ta sẽ giải thích lý do sau.

Hàm eat_at_restaurant là một phần của API công khai của thư viện crate của chúng ta, vì vậy chúng ta đánh dấu nó bằng từ khóa pub. Trong phần "Hiển thị Đường dẫn với từ khóa pub", chúng ta sẽ đi sâu hơn về pub.

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Lần đầu tiên chúng ta gọi hàm add_to_waitlist trong eat_at_restaurant, chúng ta sử dụng đường dẫn tuyệt đối. Hàm add_to_waitlist được định nghĩa trong cùng crate với eat_at_restaurant, điều đó có nghĩa là chúng ta có thể sử dụng từ khóa crate để bắt đầu một đường dẫn tuyệt đối. Sau đó, chúng ta bao gồm từng module liên tiếp cho đến khi chúng ta đến được add_to_waitlist. Bạn có thể tưởng tượng một hệ thống tệp với cùng cấu trúc: chúng ta sẽ chỉ định đường dẫn /front_of_house/hosting/add_to_waitlist để chạy chương trình add_to_waitlist; việc sử dụng tên crate để bắt đầu từ gốc của crate giống như việc sử dụng / để bắt đầu từ gốc hệ thống tệp trong shell của bạn.

Lần thứ hai chúng ta gọi add_to_waitlist trong eat_at_restaurant, chúng ta sử dụng một đường dẫn tương đối. Đường dẫn bắt đầu với front_of_house, tên của module được định nghĩa ở cùng cấp độ của cây module với eat_at_restaurant. Ở đây, tương đương với hệ thống tệp sẽ là sử dụng đường dẫn front_of_house/hosting/add_to_waitlist. Việc bắt đầu bằng tên module có nghĩa là đường dẫn là tương đối.

Việc chọn sử dụng đường dẫn tương đối hay tuyệt đối là một quyết định bạn sẽ đưa ra dựa trên dự án của mình, và nó phụ thuộc vào việc bạn có nhiều khả năng di chuyển mã định nghĩa item riêng biệt hay cùng với mã sử dụng item đó. Ví dụ, nếu chúng ta di chuyển module front_of_house và hàm eat_at_restaurant vào một module có tên customer_experience, chúng ta sẽ cần cập nhật đường dẫn tuyệt đối đến add_to_waitlist, nhưng đường dẫn tương đối vẫn sẽ hợp lệ. Tuy nhiên, nếu chúng ta di chuyển riêng hàm eat_at_restaurant vào một module có tên dining, đường dẫn tuyệt đối đến lệnh gọi add_to_waitlist sẽ giữ nguyên, nhưng đường dẫn tương đối sẽ cần được cập nhật. Ưu tiên chung của chúng ta là chỉ định đường dẫn tuyệt đối vì có khả năng cao hơn là chúng ta sẽ muốn di chuyển các định nghĩa mã và lệnh gọi các item độc lập với nhau.

Hãy thử biên dịch Listing 7-3 và tìm hiểu tại sao nó chưa thể biên dịch! Các lỗi mà chúng ta nhận được được hiển thị trong Listing 7-4.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
2  |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

Các thông báo lỗi nói rằng module hosting là riêng tư. Nói cách khác, chúng ta có các đường dẫn đúng cho module hosting và hàm add_to_waitlist, nhưng Rust không cho phép chúng ta sử dụng chúng vì nó không có quyền truy cập vào các phần riêng tư. Trong Rust, tất cả các item (hàm, phương thức, cấu trúc, enums, modules, và hằng số) mặc định là riêng tư đối với các module cha. Nếu bạn muốn làm cho một item như một hàm hoặc cấu trúc riêng tư, bạn đặt nó trong một module.

Các item trong một module cha không thể sử dụng các item riêng tư bên trong các module con, nhưng các item trong các module con có thể sử dụng các item trong module tổ tiên của chúng. Điều này là vì các module con bọc và ẩn chi tiết triển khai của chúng, nhưng các module con có thể thấy bối cảnh mà chúng được định nghĩa. Để tiếp tục với ẩn dụ của chúng ta, hãy nghĩ về các quy tắc quyền riêng tư giống như văn phòng phía sau của một nhà hàng: những gì diễn ra ở đó là riêng tư đối với khách hàng nhà hàng, nhưng các quản lý văn phòng có thể thấy và làm mọi thứ trong nhà hàng mà họ điều hành.

Rust đã chọn để hệ thống module hoạt động theo cách này để việc ẩn các chi tiết triển khai bên trong là mặc định. Bằng cách đó, bạn biết những phần nào của mã bên trong mà bạn có thể thay đổi mà không làm hỏng mã bên ngoài. Tuy nhiên, Rust có cung cấp cho bạn tùy chọn để hiển thị các phần bên trong của mã module con cho các module tổ tiên bên ngoài bằng cách sử dụng từ khóa pub để làm một item công khai.

Hiển thị Đường dẫn với từ khóa pub

Hãy quay lại lỗi trong Listing 7-4 đã nói với chúng ta rằng module hosting là riêng tư. Chúng ta muốn hàm eat_at_restaurant trong module cha có quyền truy cập vào hàm add_to_waitlist trong module con, vì vậy chúng ta đánh dấu module hosting bằng từ khóa pub, như được hiển thị trong Listing 7-5.

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Thật không may, mã trong Listing 7-5 vẫn dẫn đến lỗi trình biên dịch, như được hiển thị trong Listing 7-6.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

Chuyện gì đã xảy ra? Thêm từ khóa pub trước mod hosting làm cho module công khai. Với thay đổi này, nếu chúng ta có thể truy cập front_of_house, chúng ta có thể truy cập hosting. Nhưng nội dung của hosting vẫn là riêng tư; việc làm cho module công khai không làm cho nội dung của nó công khai. Từ khóa pub trên một module chỉ cho phép mã trong các module tổ tiên của nó tham chiếu đến nó, không phải truy cập vào mã bên trong của nó. Bởi vì các module là vùng chứa, không có nhiều điều chúng ta có thể làm chỉ bằng cách làm cho module công khai; chúng ta cần đi xa hơn và chọn làm cho một hoặc nhiều item trong module công khai.

Các lỗi trong Listing 7-6 nói rằng hàm add_to_waitlist là riêng tư. Các quy tắc quyền riêng tư áp dụng cho các cấu trúc, enums, hàm, và phương thức cũng như các module.

Hãy cũng làm cho hàm add_to_waitlist công khai bằng cách thêm từ khóa pub trước định nghĩa của nó, như trong Listing 7-7.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

Bây giờ mã sẽ biên dịch! Để hiểu tại sao việc thêm từ khóa pub cho phép chúng ta sử dụng các đường dẫn này trong eat_at_restaurant liên quan đến các quy tắc quyền riêng tư, hãy xem đường dẫn tuyệt đối và tương đối.

Trong đường dẫn tuyệt đối, chúng ta bắt đầu với crate, gốc của cây module của crate. Module front_of_house được định nghĩa trong gốc của crate. Mặc dù front_of_house không công khai, vì hàm eat_at_restaurant được định nghĩa trong cùng module với front_of_house (nghĩa là, eat_at_restaurantfront_of_house là anh chị em), chúng ta có thể tham chiếu đến front_of_house từ eat_at_restaurant. Tiếp theo là module hosting được đánh dấu là pub. Chúng ta có thể truy cập module cha của hosting, nên chúng ta có thể truy cập hosting. Cuối cùng, hàm add_to_waitlist được đánh dấu là pub và chúng ta có thể truy cập module cha của nó, vì vậy lệnh gọi hàm này hoạt động!

Trong đường dẫn tương đối, logic giống với đường dẫn tuyệt đối ngoại trừ bước đầu tiên: thay vì bắt đầu từ gốc của crate, đường dẫn bắt đầu từ front_of_house. Module front_of_house được định nghĩa trong cùng module với eat_at_restaurant, nên đường dẫn tương đối bắt đầu từ module mà eat_at_restaurant được định nghĩa hoạt động. Sau đó, vì hostingadd_to_waitlist được đánh dấu là pub, phần còn lại của đường dẫn hoạt động, và lệnh gọi hàm này hợp lệ!

Nếu bạn có kế hoạch chia sẻ crate thư viện của mình để các dự án khác có thể sử dụng mã của bạn, API công khai của bạn là hợp đồng của bạn với người dùng của crate xác định cách họ có thể tương tác với mã của bạn. Có nhiều cân nhắc xung quanh việc quản lý thay đổi đối với API công khai của bạn để giúp mọi người dễ dàng hơn khi phụ thuộc vào crate của bạn. Những cân nhắc này nằm ngoài phạm vi của cuốn sách này; nếu bạn quan tâm đến chủ đề này, hãy xem Các Hướng dẫn API của Rust.

Các Phương pháp Tốt nhất cho Packages với Binary và Library

Chúng ta đã đề cập rằng một package có thể chứa cả gốc của binary crate src/main.rs cũng như gốc của library crate src/lib.rs, và cả hai crates sẽ có tên package theo mặc định. Thông thường, các package với mô hình này chứa cả library và binary crate sẽ có đủ lượng mã trong binary crate để khởi động một chương trình thực thi gọi mã được khai báo trong library crate. Điều này cho phép các dự án khác hưởng lợi từ hầu hết chức năng mà package cung cấp vì mã của library crate có thể được chia sẻ.

Cây module nên được định nghĩa trong src/lib.rs. Sau đó, bất kỳ item công khai nào có thể được sử dụng trong binary crate bằng cách bắt đầu đường dẫn với tên của package. Binary crate trở thành người dùng của library crate giống như một crate bên ngoài hoàn toàn sẽ sử dụng library crate: nó chỉ có thể sử dụng API công khai. Điều này giúp bạn thiết kế một API tốt; không chỉ là bạn là tác giả, bạn cũng là khách hàng!

Trong Chương 12, chúng ta sẽ minh họa phương pháp tổ chức này với một chương trình dòng lệnh sẽ chứa cả binary crate và library crate.

Bắt đầu Đường dẫn Tương đối với super

Chúng ta có thể xây dựng đường dẫn tương đối bắt đầu ở module cha, thay vì module hiện tại hoặc gốc của crate, bằng cách sử dụng super ở đầu đường dẫn. Điều này giống như việc bắt đầu một đường dẫn hệ thống tệp với cú pháp .. có nghĩa là đi đến thư mục cha. Sử dụng super cho phép chúng ta tham chiếu đến một item mà chúng ta biết là trong module cha, điều này có thể làm cho việc sắp xếp lại cây module dễ dàng hơn khi module có liên quan chặt chẽ với module cha nhưng module cha có thể được di chuyển đến nơi khác trong cây module vào một ngày nào đó.

Hãy xem xét mã trong Listing 7-8 mô hình hóa tình huống mà một đầu bếp sửa một đơn hàng không chính xác và tự mang ra cho khách hàng. Hàm fix_incorrect_order được định nghĩa trong module back_of_house gọi hàm deliver_order được định nghĩa trong module cha bằng cách chỉ định đường dẫn đến deliver_order, bắt đầu với super.

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

Hàm fix_incorrect_order nằm trong module back_of_house, nên chúng ta có thể sử dụng super để đi đến module cha của back_of_house, trong trường hợp này là crate, gốc. Từ đó, chúng ta tìm kiếm deliver_order và tìm thấy nó. Thành công! Chúng ta nghĩ rằng module back_of_house và hàm deliver_order có khả năng sẽ giữ nguyên mối quan hệ với nhau và được di chuyển cùng nhau nếu chúng ta quyết định tổ chức lại cây module của crate. Do đó, chúng ta đã sử dụng super để chúng ta sẽ có ít nơi hơn để cập nhật mã trong tương lai nếu mã này được di chuyển đến một module khác.

Làm cho Structs và Enums Công khai

Chúng ta cũng có thể sử dụng pub để chỉ định structs và enums là công khai, nhưng có một vài chi tiết bổ sung đối với việc sử dụng pub với structs và enums. Nếu chúng ta sử dụng pub trước định nghĩa struct, chúng ta làm cho struct công khai, nhưng các trường của struct vẫn sẽ là riêng tư. Chúng ta có thể làm cho từng trường công khai hoặc không tùy theo từng trường hợp. Trong Listing 7-9, chúng ta đã định nghĩa một struct back_of_house::Breakfast công khai với một trường toast công khai nhưng một trường seasonal_fruit riêng tư. Điều này mô hình hóa trường hợp trong một nhà hàng mà khách hàng có thể chọn loại bánh mì đi kèm với bữa ăn, nhưng đầu bếp quyết định loại trái cây đi kèm với bữa ăn dựa trên mùa nào và có sẵn trong kho. Loại trái cây có sẵn thay đổi nhanh chóng, vì vậy khách hàng không thể chọn trái cây hoặc thậm chí không thể thấy họ sẽ nhận được loại trái cây nào.

mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast.
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like.
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal.
    // meal.seasonal_fruit = String::from("blueberries");
}

Bởi vì trường toast trong struct back_of_house::Breakfast là công khai, trong eat_at_restaurant chúng ta có thể viết và đọc trường toast bằng cách sử dụng ký hiệu dấu chấm. Lưu ý rằng chúng ta không thể sử dụng trường seasonal_fruit trong eat_at_restaurant, bởi vì seasonal_fruit là riêng tư. Hãy thử bỏ comment dòng sửa đổi giá trị trường seasonal_fruit để xem lỗi bạn nhận được!

Cũng lưu ý rằng bởi vì back_of_house::Breakfast có một trường riêng tư, struct cần cung cấp một hàm liên kết công khai để xây dựng một thể hiện của Breakfast (chúng ta đã đặt tên nó là summer ở đây). Nếu Breakfast không có một hàm như vậy, chúng ta không thể tạo một thể hiện của Breakfast trong eat_at_restaurant bởi vì chúng ta không thể đặt giá trị của trường riêng tư seasonal_fruit trong eat_at_restaurant.

Ngược lại, nếu chúng ta làm cho một enum công khai, tất cả các biến thể của nó đều công khai. Chúng ta chỉ cần từ khóa pub trước từ khóa enum, như được hiển thị trong Listing 7-10.

mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}

Vì chúng ta đã làm cho enum Appetizer công khai, chúng ta có thể sử dụng các biến thể SoupSalad trong eat_at_restaurant.

Enums không hữu ích lắm trừ khi các biến thể của chúng là công khai; sẽ rất phiền toái khi phải chú thích tất cả các biến thể enum với pub trong mọi trường hợp, vì vậy mặc định cho các biến thể enum là công khai. Structs thường hữu ích mà không cần các trường của chúng là công khai, vì vậy các trường struct tuân theo quy tắc chung là mọi thứ mặc định là riêng tư trừ khi được chú thích với pub.

Có một tình huống khác liên quan đến pub mà chúng ta chưa đề cập, và đó là tính năng hệ thống module cuối cùng của chúng ta: từ khóa use. Chúng ta sẽ đề cập đến use riêng lẻ trước, và sau đó chúng ta sẽ thể hiện cách kết hợp pubuse.

Đưa Đường dẫn vào Phạm vi với từ khóa use

Việc phải viết ra các đường dẫn để gọi hàm có thể cảm thấy bất tiện và lặp đi lặp lại. Trong Listing 7-7, cho dù chúng ta chọn đường dẫn tuyệt đối hoặc tương đối đến hàm add_to_waitlist, mỗi khi chúng ta muốn gọi add_to_waitlist chúng ta phải chỉ định cả front_of_househosting. May mắn thay, có một cách để đơn giản hóa quá trình này: chúng ta có thể tạo một lối tắt đến đường dẫn với từ khóa use một lần, và sau đó sử dụng tên ngắn hơn ở mọi nơi khác trong phạm vi.

Trong Listing 7-11, chúng ta đưa module crate::front_of_house::hosting vào phạm vi của hàm eat_at_restaurant để chúng ta chỉ cần chỉ định hosting::add_to_waitlist để gọi hàm add_to_waitlist trong eat_at_restaurant.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

Việc thêm use và một đường dẫn trong một phạm vi tương tự như việc tạo một liên kết tượng trưng trong hệ thống tệp. Bằng cách thêm use crate::front_of_house::hosting trong gốc crate, hosting bây giờ là một tên hợp lệ trong phạm vi đó, giống như thể module hosting đã được định nghĩa trong gốc crate. Đường dẫn được đưa vào phạm vi bằng use cũng kiểm tra quyền riêng tư, giống như bất kỳ đường dẫn nào khác.

Lưu ý rằng use chỉ tạo lối tắt cho phạm vi cụ thể mà use xảy ra. Listing 7-12 di chuyển hàm eat_at_restaurant vào một module con mới có tên là customer, sau đó là một phạm vi khác với câu lệnh use, vì vậy phần thân hàm sẽ không biên dịch được.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}

Lỗi trình biên dịch cho thấy lối tắt không còn áp dụng trong module customer:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of undeclared crate or module `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of undeclared crate or module `hosting`
   |
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

Lưu ý cũng có một cảnh báo rằng use không còn được sử dụng trong phạm vi của nó! Để khắc phục vấn đề này, hãy di chuyển use vào module customer, hoặc tham chiếu lối tắt trong module cha với super::hosting trong module con customer.

Tạo Đường dẫn use Thuần phong

Trong Listing 7-11, bạn có thể đã tự hỏi tại sao chúng ta chỉ định use crate::front_of_house::hosting và sau đó gọi hosting::add_to_waitlist trong eat_at_restaurant, thay vì chỉ định đường dẫn use hoàn toàn đến hàm add_to_waitlist để đạt được kết quả tương tự, như trong Listing 7-13.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}

Mặc dù cả Listing 7-11 và Listing 7-13 đều thực hiện cùng một nhiệm vụ, Listing 7-11 là cách thuần phong để đưa một hàm vào phạm vi với use. Việc đưa module cha của hàm vào phạm vi với use có nghĩa là chúng ta phải chỉ định module cha khi gọi hàm. Chỉ định module cha khi gọi hàm làm rõ rằng hàm không được định nghĩa cục bộ trong khi vẫn giảm thiểu việc lặp lại đường dẫn đầy đủ. Đoạn mã trong Listing 7-13 không rõ ràng về nơi add_to_waitlist được định nghĩa.

Mặt khác, khi đưa vào structs, enums, và các item khác với use, việc chỉ định đường dẫn đầy đủ là thuần phong. Listing 7-14 thể hiện cách thuần phong để đưa struct HashMap của thư viện chuẩn vào phạm vi của một binary crate.

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

Không có lý do mạnh mẽ đằng sau cách viết này: đó chỉ là quy ước đã nổi lên, và mọi người đã quen với việc đọc và viết mã Rust theo cách này.

Ngoại lệ cho cách viết này là nếu chúng ta đưa hai item có cùng tên vào phạm vi với các câu lệnh use, vì Rust không cho phép điều đó. Listing 7-15 thể hiện cách đưa hai kiểu Result vào phạm vi có cùng tên nhưng có module cha khác nhau, và cách tham chiếu đến chúng.

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}

Như bạn có thể thấy, việc sử dụng các module cha phân biệt hai kiểu Result. Nếu thay vào đó chúng ta chỉ định use std::fmt::Resultuse std::io::Result, chúng ta sẽ có hai kiểu Result trong cùng một phạm vi, và Rust sẽ không biết chúng ta muốn nói đến kiểu nào khi chúng ta sử dụng Result.

Cung cấp Tên Mới với từ khóa as

Có một giải pháp khác cho vấn đề đưa hai kiểu có cùng tên vào cùng một phạm vi với use: sau đường dẫn, chúng ta có thể chỉ định as và một tên cục bộ mới, hoặc alias (biệt danh), cho kiểu đó. Listing 7-16 thể hiện một cách khác để viết mã trong Listing 7-15 bằng cách đổi tên một trong hai kiểu Result sử dụng as.

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}

Trong câu lệnh use thứ hai, chúng ta đã chọn tên mới IoResult cho kiểu std::io::Result, điều này sẽ không xung đột với Result từ std::fmt mà chúng ta cũng đã đưa vào phạm vi. Listing 7-15 và Listing 7-16 đều được coi là thuần phong, vì vậy sự lựa chọn là ở bạn!

Tái xuất Tên với pub use

Khi chúng ta đưa một tên vào phạm vi với từ khóa use, tên đó là riêng tư đối với phạm vi mà chúng ta đã nhập nó vào. Để cho phép mã bên ngoài phạm vi đó tham chiếu đến tên đó như thể nó đã được định nghĩa trong phạm vi đó, chúng ta có thể kết hợp pubuse. Kỹ thuật này được gọi là re-exporting (tái xuất) vì chúng ta đưa một item vào phạm vi nhưng cũng làm cho item đó có sẵn cho người khác đưa vào phạm vi của họ.

Listing 7-17 thể hiện mã trong Listing 7-11 với use trong module gốc được thay đổi thành pub use.

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

Trước thay đổi này, mã bên ngoài sẽ phải gọi hàm add_to_waitlist bằng cách sử dụng đường dẫn restaurant::front_of_house::hosting::add_to_waitlist(), điều này cũng đòi hỏi module front_of_house phải được đánh dấu là pub. Bây giờ pub use này đã tái xuất module hosting từ module gốc, mã bên ngoài có thể sử dụng đường dẫn restaurant::hosting::add_to_waitlist() thay thế.

Tái xuất rất hữu ích khi cấu trúc nội bộ của mã của bạn khác với cách các lập trình viên gọi mã của bạn sẽ nghĩ về miền. Ví dụ, trong phép ẩn dụ nhà hàng này, những người điều hành nhà hàng nghĩ về "front of house" và "back of house." Nhưng khách hàng ghé thăm một nhà hàng có lẽ sẽ không nghĩ về các phần của nhà hàng theo những thuật ngữ đó. Với pub use, chúng ta có thể viết mã của mình với một cấu trúc nhưng hiển thị một cấu trúc khác. Làm như vậy giúp thư viện của chúng ta được tổ chức tốt cho các lập trình viên làm việc trên thư viện và các lập trình viên gọi thư viện. Chúng ta sẽ xem một ví dụ khác về pub use và cách nó ảnh hưởng đến tài liệu crate của bạn trong "Xuất một Public API Tiện lợi với pub use" trong Chương 14.

Sử dụng Các Gói Bên ngoài

Trong Chương 2, chúng ta đã lập trình một dự án trò chơi đoán số sử dụng một gói bên ngoài có tên là rand để có được số ngẫu nhiên. Để sử dụng rand trong dự án của chúng ta, chúng ta đã thêm dòng này vào Cargo.toml:

rand = "0.8.5"

Thêm rand như một phụ thuộc trong Cargo.toml cho Cargo biết phải tải xuống gói rand và bất kỳ phụ thuộc nào từ crates.io và làm cho rand có sẵn cho dự án của chúng ta.

Sau đó, để đưa các định nghĩa rand vào phạm vi của gói của chúng ta, chúng ta đã thêm một dòng use bắt đầu bằng tên của crate, rand, và liệt kê các item chúng ta muốn đưa vào phạm vi. Hãy nhớ lại rằng trong "Tạo một Số Ngẫu nhiên" trong Chương 2, chúng ta đã đưa trait Rng vào phạm vi và gọi hàm rand::thread_rng:

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}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Các thành viên của cộng đồng Rust đã làm cho nhiều gói có sẵn tại crates.io, và việc kéo bất kỳ gói nào vào gói của bạn đều liên quan đến các bước tương tự: liệt kê chúng trong tệp Cargo.toml của gói của bạn và sử dụng use để đưa các item từ các crate đó vào phạm vi.

Lưu ý rằng thư viện chuẩn std cũng là một crate bên ngoài đối với gói của chúng ta. Bởi vì thư viện chuẩn được đi kèm với ngôn ngữ Rust, chúng ta không cần phải thay đổi Cargo.toml để bao gồm std. Nhưng chúng ta cần tham chiếu đến nó với use để đưa các item từ đó vào phạm vi của gói của chúng ta. Ví dụ, với HashMap chúng ta sẽ sử dụng dòng này:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

Đây là một đường dẫn tuyệt đối bắt đầu bằng std, tên của crate thư viện chuẩn.

Sử dụng Đường dẫn Lồng nhau để Làm sạch Danh sách use Lớn

Nếu chúng ta sử dụng nhiều item được định nghĩa trong cùng một crate hoặc cùng một module, việc liệt kê mỗi item trên một dòng riêng có thể chiếm nhiều không gian dọc trong các tệp của chúng ta. Ví dụ, hai câu lệnh use này chúng ta đã có trong trò chơi đoán số trong Listing 2-4 đưa các item từ std vào phạm vi:

use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Thay vào đó, chúng ta có thể sử dụng đường dẫn lồng nhau để đưa các item tương tự vào phạm vi trong một dòng. Chúng ta làm điều này bằng cách chỉ định phần chung của đường dẫn, theo sau là hai dấu hai chấm, và sau đó là dấu ngoặc nhọn quanh danh sách các phần của đường dẫn khác nhau, như được hiển thị trong Listing 7-18.

use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

Trong các chương trình lớn hơn, việc đưa nhiều item vào phạm vi từ cùng một crate hoặc module sử dụng đường dẫn lồng nhau có thể giảm đáng kể số lượng câu lệnh use riêng biệt cần thiết!

Chúng ta có thể sử dụng đường dẫn lồng nhau ở bất kỳ cấp độ nào trong một đường dẫn, điều này rất hữu ích khi kết hợp hai câu lệnh use có chung một đường dẫn con. Ví dụ, Listing 7-19 thể hiện hai câu lệnh use: một câu lệnh đưa std::io vào phạm vi và một câu lệnh đưa std::io::Write vào phạm vi.

use std::io;
use std::io::Write;

Phần chung của hai đường dẫn này là std::io, và đó là toàn bộ đường dẫn đầu tiên. Để hợp nhất hai đường dẫn này thành một câu lệnh use, chúng ta có thể sử dụng self trong đường dẫn lồng nhau, như được hiển thị trong Listing 7-20.

use std::io::{self, Write};

Dòng này đưa std::iostd::io::Write vào phạm vi.

Toán tử Glob

Nếu chúng ta muốn đưa tất cả các item công khai được định nghĩa trong một đường dẫn vào phạm vi, chúng ta có thể chỉ định đường dẫn đó theo sau bởi toán tử glob *:

#![allow(unused)]
fn main() {
use std::collections::*;
}

Câu lệnh use này đưa tất cả các item công khai được định nghĩa trong std::collections vào phạm vi hiện tại. Hãy cẩn thận khi sử dụng toán tử glob! Glob có thể làm cho nó khó hơn để biết những tên nào đang trong phạm vi và tên được sử dụng trong chương trình của bạn được định nghĩa ở đâu. Ngoài ra, nếu phụ thuộc thay đổi các định nghĩa của nó, những gì bạn đã import cũng thay đổi theo, điều này có thể dẫn đến lỗi trình biên dịch khi bạn nâng cấp phụ thuộc nếu phụ thuộc đó thêm một định nghĩa có cùng tên với một định nghĩa của bạn trong cùng phạm vi, chẳng hạn.

Toán tử glob thường được sử dụng khi kiểm thử để đưa mọi thứ đang được kiểm thử vào module tests; chúng ta sẽ nói về điều đó trong "Cách Viết Kiểm thử" trong Chương 11. Toán tử glob cũng đôi khi được sử dụng như một phần của mẫu prelude: xem tài liệu thư viện chuẩn để biết thêm thông tin về mẫu đó.

Tách Module Thành Nhiều File Khác Nhau

Cho đến nay, tất cả các ví dụ trong chương này đều định nghĩa nhiều module trong một file. Khi các module trở nên lớn, bạn có thể muốn di chuyển các định nghĩa của chúng vào một file riêng biệt để làm cho mã dễ điều hướng hơn.

Ví dụ, hãy bắt đầu từ mã trong Listing 7-17 với nhiều module nhà hàng. Chúng ta sẽ trích xuất module vào các file thay vì định nghĩa tất cả module trong file gốc của crate. Trong trường hợp này, file gốc của crate là src/lib.rs, nhưng quy trình này cũng hoạt động với các crate nhị phân mà file gốc của crate là src/main.rs.

Đầu tiên, chúng ta sẽ trích xuất module front_of_house vào file riêng của nó. Xóa mã trong dấu ngoặc nhọn của module front_of_house, chỉ để lại khai báo mod front_of_house;, để src/lib.rs chứa mã hiển thị trong Listing 7-21. Lưu ý rằng mã này sẽ không biên dịch được cho đến khi chúng ta tạo file src/front_of_house.rs trong Listing 7-22.

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

Tiếp theo, đặt mã đã nằm trong dấu ngoặc nhọn vào một file mới có tên src/front_of_house.rs, như trong Listing 7-22. Trình biên dịch biết phải tìm file này vì nó đã gặp khai báo module trong gốc của crate với tên front_of_house.

pub mod hosting {
    pub fn add_to_waitlist() {}
}

Lưu ý rằng bạn chỉ cần tải một file bằng khai báo mod một lần trong cây module của bạn. Khi trình biên dịch biết file đó là một phần của dự án (và biết vị trí của mã trong cây module dựa vào nơi bạn đặt câu lệnh mod), các file khác trong dự án của bạn nên tham chiếu đến mã của file đã được tải bằng đường dẫn đến nơi nó được khai báo, như đã đề cập trong phần "Đường dẫn để tham chiếu đến một mục trong cây module". Nói cách khác, mod không phải là một thao tác "include" mà bạn có thể đã thấy trong các ngôn ngữ lập trình khác.

Tiếp theo, chúng ta sẽ trích xuất module hosting vào file riêng của nó. Quy trình này hơi khác vì hosting là một module con của front_of_house, không phải của module gốc. Chúng ta sẽ đặt file cho hosting trong một thư mục mới được đặt tên theo tổ tiên của nó trong cây module, trong trường hợp này là src/front_of_house.

Để bắt đầu di chuyển hosting, chúng ta thay đổi src/front_of_house.rs để chỉ chứa khai báo của module hosting:

pub mod hosting;

Sau đó chúng ta tạo một thư mục src/front_of_house và một file hosting.rs để chứa các định nghĩa được tạo trong module hosting:

pub fn add_to_waitlist() {}

Nếu thay vào đó chúng ta đặt hosting.rs trong thư mục src, trình biên dịch sẽ mong đợi mã hosting.rs nằm trong một module hosting được khai báo trong gốc của crate, chứ không phải được khai báo là con của module front_of_house. Các quy tắc của trình biên dịch về việc kiểm tra file nào cho mã của module nào có nghĩa là các thư mục và file khớp chặt chẽ hơn với cây module.

Đường dẫn file thay thế

Cho đến nay chúng ta đã đề cập đến các đường dẫn file kiểu mẫu nhất mà trình biên dịch Rust sử dụng, nhưng Rust cũng hỗ trợ một kiểu đường dẫn file cũ hơn. Đối với module có tên front_of_house được khai báo trong gốc của crate, trình biên dịch sẽ tìm kiếm mã của module trong:

  • src/front_of_house.rs (những gì chúng ta đã đề cập)
  • src/front_of_house/mod.rs (kiểu cũ hơn, vẫn được hỗ trợ)

Đối với module có tên hosting là một module con của front_of_house, trình biên dịch sẽ tìm kiếm mã của module trong:

  • src/front_of_house/hosting.rs (những gì chúng ta đã đề cập)
  • src/front_of_house/hosting/mod.rs (kiểu cũ hơn, vẫn được hỗ trợ)

Nếu bạn sử dụng cả hai kiểu cho cùng một module, bạn sẽ gặp lỗi biên dịch. Sử dụng kết hợp cả hai kiểu cho các module khác nhau trong cùng một dự án là được phép, nhưng có thể gây nhầm lẫn cho những người điều hướng dự án của bạn.

Nhược điểm chính của kiểu sử dụng các file có tên mod.rs là dự án của bạn có thể kết thúc với nhiều file có tên mod.rs, điều này có thể gây nhầm lẫn khi bạn mở chúng cùng lúc trong trình soạn thảo.

Chúng ta đã di chuyển mã của mỗi module vào một file riêng biệt, và cây module vẫn giữ nguyên. Các lệnh gọi hàm trong eat_at_restaurant sẽ hoạt động mà không cần bất kỳ sự sửa đổi nào, mặc dù các định nghĩa nằm trong các file khác nhau. Kỹ thuật này cho phép bạn di chuyển các module vào các file mới khi chúng tăng kích thước.

Lưu ý rằng câu lệnh pub use crate::front_of_house::hosting trong src/lib.rs cũng không thay đổi, và use cũng không có bất kỳ tác động nào đến việc những file nào được biên dịch như một phần của crate. Từ khóa mod khai báo các module, và Rust tìm kiếm trong một file có cùng tên với module để lấy mã đi vào module đó.

Tóm tắt

Rust cho phép bạn chia một gói thành nhiều crate và một crate thành nhiều module để bạn có thể tham chiếu đến các mục được định nghĩa trong một module từ một module khác. Bạn có thể làm điều này bằng cách chỉ định đường dẫn tuyệt đối hoặc đường dẫn tương đối. Các đường dẫn này có thể được đưa vào phạm vi với một câu lệnh use để bạn có thể sử dụng một đường dẫn ngắn hơn cho nhiều lần sử dụng mục đó trong phạm vi đó. Mã module là riêng tư theo mặc định, nhưng bạn có thể công khai các định nghĩa bằng cách thêm từ khóa pub.

Trong chương tiếp theo, chúng ta sẽ xem xét một số cấu trúc dữ liệu tập hợp trong thư viện chuẩn mà bạn có thể sử dụng trong mã được tổ chức gọn gàng của mình.

Các Bộ Sưu Tập Thông Dụng

Thư viện chuẩn của Rust bao gồm một số cấu trúc dữ liệu rất hữu ích được gọi là các bộ sưu tập (collections). Hầu hết các kiểu dữ liệu khác đại diện cho một giá trị cụ thể, nhưng các bộ sưu tập có thể chứa nhiều giá trị. Không giống như các kiểu mảng và bộ (tuple) tích hợp sẵn, dữ liệu mà các bộ sưu tập này trỏ đến được lưu trữ trên heap, có nghĩa là lượng dữ liệu không cần phải biết tại thời điểm biên dịch và có thể tăng hoặc giảm khi chương trình chạy. Mỗi loại bộ sưu tập có khả năng và chi phí khác nhau, và việc chọn một bộ sưu tập phù hợp cho tình huống hiện tại của bạn là một kỹ năng mà bạn sẽ phát triển theo thời gian. Trong chương này, chúng ta sẽ thảo luận về ba bộ sưu tập được sử dụng rất thường xuyên trong các chương trình Rust:

  • Một vector cho phép bạn lưu trữ một số lượng biến đổi các giá trị cạnh nhau.
  • Một chuỗi (string) là một bộ sưu tập các ký tự. Chúng ta đã đề cập đến kiểu String trước đây, nhưng trong chương này chúng ta sẽ nói về nó một cách chi tiết.
  • Một bảng băm (hash map) cho phép bạn liên kết một giá trị với một khóa cụ thể. Đây là một triển khai cụ thể của cấu trúc dữ liệu tổng quát hơn được gọi là map.

Để tìm hiểu về các loại bộ sưu tập khác được cung cấp bởi thư viện chuẩn, hãy xem tài liệu.

Chúng ta sẽ thảo luận về cách tạo và cập nhật vector, chuỗi, và bảng băm, cũng như những điều gì làm cho mỗi loại đặc biệt.

Lưu Trữ Danh Sách Giá Trị với Vector

Loại bộ sưu tập đầu tiên mà chúng ta sẽ xem xét là Vec<T>, còn được gọi là vector. Vector cho phép bạn lưu trữ nhiều giá trị trong một cấu trúc dữ liệu duy nhất, đặt tất cả các giá trị liền kề nhau trong bộ nhớ. Vector chỉ có thể lưu trữ các giá trị cùng kiểu. Chúng rất hữu ích khi bạn có một danh sách các mục, chẳng hạn như các dòng văn bản trong một tập tin hoặc giá của các mặt hàng trong một giỏ hàng.

Tạo một Vector Mới

Để tạo một vector rỗng mới, chúng ta gọi hàm Vec::new, như hiển thị trong Listing 8-1.

fn main() {
    let v: Vec<i32> = Vec::new();
}

Lưu ý rằng chúng ta đã thêm một chú thích kiểu ở đây. Bởi vì chúng ta không chèn bất kỳ giá trị nào vào vector này, Rust không biết chúng ta dự định lưu trữ những phần tử kiểu gì. Đây là một điểm quan trọng. Vector được triển khai bằng cách sử dụng generics; chúng ta sẽ đề cập đến cách sử dụng generics với các kiểu riêng của bạn trong Chương 10. Hiện tại, hãy biết rằng kiểu Vec<T> được cung cấp bởi thư viện chuẩn có thể chứa bất kỳ kiểu nào. Khi chúng ta tạo một vector để chứa một kiểu cụ thể, chúng ta có thể chỉ định kiểu đó trong dấu ngoặc nhọn. Trong Listing 8-1, chúng ta đã cho Rust biết rằng Vec<T> trong v sẽ chứa các phần tử kiểu i32.

Thường thì bạn sẽ tạo một Vec<T> với các giá trị ban đầu và Rust sẽ suy luận kiểu giá trị mà bạn muốn lưu trữ, vì vậy bạn hiếm khi cần phải chú thích kiểu. Rust cung cấp một cách tiện lợi với macro vec!, sẽ tạo một vector mới chứa các giá trị bạn cung cấp. Listing 8-2 tạo một Vec<i32> mới chứa các giá trị 1, 2, và 3. Kiểu số nguyên là i32 vì đó là kiểu số nguyên mặc định, như chúng ta đã thảo luận trong phần "Kiểu Dữ Liệu" của Chương 3.

fn main() {
    let v = vec![1, 2, 3];
}

Vì chúng ta đã cung cấp các giá trị i32 ban đầu, Rust có thể suy luận rằng kiểu của vVec<i32>, và không cần thiết phải chú thích kiểu. Tiếp theo, chúng ta sẽ xem cách sửa đổi một vector.

Cập Nhật một Vector

Để tạo một vector và sau đó thêm các phần tử vào nó, chúng ta có thể sử dụng phương thức push, như hiển thị trong Listing 8-3.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

Giống như với bất kỳ biến nào, nếu chúng ta muốn có thể thay đổi giá trị của nó, chúng ta cần làm cho nó có thể thay đổi bằng cách sử dụng từ khóa mut, như đã thảo luận trong Chương 3. Các số mà chúng ta đặt vào đều thuộc kiểu i32, và Rust suy luận điều này từ dữ liệu, vì vậy chúng ta không cần chú thích Vec<i32>.

Đọc Các Phần Tử của Vector

Có hai cách để tham chiếu đến một giá trị được lưu trữ trong vector: thông qua chỉ mục hoặc bằng cách sử dụng phương thức get. Trong các ví dụ sau, chúng ta đã chú thích các kiểu của giá trị được trả về từ các hàm này để có thêm độ rõ ràng.

Listing 8-4 hiển thị cả hai phương pháp truy cập giá trị trong vector, với cú pháp lập chỉ mục và phương thức get.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}

Lưu ý một vài chi tiết ở đây. Chúng ta sử dụng giá trị chỉ mục 2 để lấy phần tử thứ ba vì vector được đánh chỉ mục bằng số, bắt đầu từ không. Sử dụng &[] cho chúng ta một tham chiếu đến phần tử tại giá trị chỉ mục. Khi chúng ta sử dụng phương thức get với chỉ mục được truyền vào dưới dạng đối số, chúng ta nhận được một Option<&T> mà chúng ta có thể sử dụng với match.

Rust cung cấp hai cách để tham chiếu đến một phần tử để bạn có thể chọn cách chương trình hoạt động khi bạn cố gắng sử dụng một giá trị chỉ mục nằm ngoài phạm vi của các phần tử hiện có. Ví dụ, hãy xem điều gì xảy ra khi chúng ta có một vector gồm năm phần tử và sau đó chúng ta cố gắng truy cập một phần tử tại chỉ mục 100 với mỗi kỹ thuật, như hiển thị trong Listing 8-5.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}

Khi chúng ta chạy mã này, phương thức [] đầu tiên sẽ làm cho chương trình hoảng loạn vì nó tham chiếu đến một phần tử không tồn tại. Phương pháp này được sử dụng tốt nhất khi bạn muốn chương trình của mình gặp sự cố nếu có một nỗ lực truy cập một phần tử ngoài phạm vi của vector.

Khi phương thức get được truyền một chỉ mục ngoài vector, nó trả về None mà không hoảng loạn. Bạn sẽ sử dụng phương pháp này nếu việc truy cập một phần tử ngoài phạm vi của vector có thể xảy ra thỉnh thoảng trong các trường hợp bình thường. Mã của bạn sau đó sẽ có logic để xử lý việc có cả Some(&element) hoặc None, như đã thảo luận trong Chương 6. Ví dụ, chỉ mục có thể đến từ một người nhập một số. Nếu họ vô tình nhập một số quá lớn và chương trình nhận được giá trị None, bạn có thể cho người dùng biết có bao nhiêu mục trong vector hiện tại và cho họ một cơ hội khác để nhập một giá trị hợp lệ. Điều đó sẽ thân thiện với người dùng hơn là làm cho chương trình gặp sự cố do một lỗi đánh máy!

Khi chương trình có một tham chiếu hợp lệ, bộ kiểm tra mượn sẽ thực thi các quy tắc quyền sở hữu và mượn (được đề cập trong Chương 4) để đảm bảo tham chiếu này và bất kỳ tham chiếu nào khác tới nội dung của vector vẫn có giá trị. Nhớ lại quy tắc cho rằng bạn không thể có tham chiếu có thể thay đổi và không thể thay đổi trong cùng phạm vi. Quy tắc đó áp dụng trong Listing 8-6, nơi chúng ta giữ một tham chiếu bất biến đến phần tử đầu tiên trong một vector và cố gắng thêm một phần tử vào cuối. Chương trình này sẽ không hoạt động nếu chúng ta cũng cố gắng tham chiếu đến phần tử đó sau đó trong hàm.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}

Biên dịch mã này sẽ dẫn đến lỗi này:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Mã trong Listing 8-6 có vẻ như nên hoạt động: tại sao một tham chiếu đến phần tử đầu tiên lại quan tâm đến những thay đổi ở cuối vector? Lỗi này là do cách vector hoạt động: vì vector đặt các giá trị liền kề nhau trong bộ nhớ, việc thêm một phần tử mới vào cuối vector có thể yêu cầu cấp phát bộ nhớ mới và sao chép các phần tử cũ sang không gian mới, nếu không có đủ chỗ để đặt tất cả các phần tử liền kề nhau tại nơi vector hiện đang được lưu trữ. Trong trường hợp đó, tham chiếu đến phần tử đầu tiên sẽ trỏ đến bộ nhớ đã giải phóng. Các quy tắc mượn ngăn chặn các chương trình rơi vào tình huống đó.

Lưu ý: Để biết thêm về chi tiết triển khai của kiểu Vec<T>, hãy xem "The Rustonomicon".

Lặp Qua Các Giá Trị trong một Vector

Để truy cập từng phần tử trong một vector theo lượt, chúng ta sẽ lặp qua tất cả các phần tử thay vì sử dụng chỉ mục để truy cập từng phần tử một lần. Listing 8-7 hiển thị cách sử dụng vòng lặp for để lấy các tham chiếu bất biến đến từng phần tử trong một vector các giá trị i32 và in chúng ra.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}

Chúng ta cũng có thể lặp qua các tham chiếu có thể thay đổi đến từng phần tử trong một vector có thể thay đổi để thực hiện các thay đổi cho tất cả các phần tử. Vòng lặp for trong Listing 8-8 sẽ thêm 50 vào mỗi phần tử.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

Để thay đổi giá trị mà tham chiếu có thể thay đổi đề cập đến, chúng ta phải sử dụng toán tử giải tham chiếu * để đến được giá trị trong i trước khi có thể sử dụng toán tử +=. Chúng ta sẽ nói thêm về toán tử giải tham chiếu trong phần "Theo con trỏ đến giá trị" của Chương 15.

Việc lặp qua một vector, dù là bất biến hay có thể thay đổi, đều an toàn nhờ vào quy tắc của bộ kiểm tra mượn. Nếu chúng ta cố gắng chèn hoặc xóa các mục trong thân vòng lặp for trong Listing 8-7 và Listing 8-8, chúng ta sẽ gặp lỗi biên dịch tương tự như lỗi chúng ta gặp với mã trong Listing 8-6. Tham chiếu đến vector mà vòng lặp for giữ ngăn chặn việc sửa đổi đồng thời toàn bộ vector.

Sử dụng Enum để Lưu Trữ Nhiều Kiểu

Vector chỉ có thể lưu trữ các giá trị có cùng kiểu. Điều này có thể gây bất tiện; chắc chắn có những trường hợp sử dụng cần lưu trữ một danh sách các mục thuộc các kiểu khác nhau. May mắn thay, các biến thể của một enum được định nghĩa dưới cùng một kiểu enum, vì vậy khi chúng ta cần một kiểu để đại diện cho các phần tử của các kiểu khác nhau, chúng ta có thể định nghĩa và sử dụng một enum!

Ví dụ, giả sử chúng ta muốn lấy các giá trị từ một hàng trong một bảng tính trong đó một số cột trong hàng chứa số nguyên, một số chứa số dấu phẩy động, và một số chứa chuỗi. Chúng ta có thể định nghĩa một enum có các biến thể sẽ chứa các kiểu giá trị khác nhau, và tất cả các biến thể enum sẽ được coi là cùng một kiểu: kiểu của enum đó. Sau đó chúng ta có thể tạo một vector để chứa enum đó và do đó, cuối cùng, chứa các kiểu khác nhau. Chúng ta đã minh họa điều này trong Listing 8-9.

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}

Rust cần biết những kiểu nào sẽ có trong vector tại thời điểm biên dịch để biết chính xác bao nhiêu bộ nhớ trên heap sẽ cần để lưu trữ mỗi phần tử. Chúng ta cũng phải rõ ràng về những kiểu nào được phép trong vector này. Nếu Rust cho phép một vector chứa bất kỳ kiểu nào, sẽ có khả năng một hoặc nhiều kiểu sẽ gây ra lỗi với các hoạt động được thực hiện trên các phần tử của vector. Sử dụng enum cùng với biểu thức match nghĩa là Rust sẽ đảm bảo tại thời điểm biên dịch rằng mọi trường hợp khả dĩ đều được xử lý, như đã thảo luận trong Chương 6.

Nếu bạn không biết tập hợp đầy đủ các kiểu mà một chương trình sẽ nhận được tại thời điểm chạy để lưu trữ trong vector, kỹ thuật enum sẽ không hoạt động. Thay vào đó, bạn có thể sử dụng trait object, mà chúng ta sẽ đề cập trong Chương 18.

Giờ chúng ta đã thảo luận về một số cách sử dụng vector phổ biến nhất, hãy đảm bảo xem xét tài liệu API để biết tất cả các phương thức hữu ích được định nghĩa cho Vec<T> bởi thư viện chuẩn. Ví dụ, ngoài push, phương thức pop loại bỏ và trả về phần tử cuối cùng.

Giải phóng một Vector sẽ Giải phóng Các Phần tử của Nó

Giống như bất kỳ struct nào khác, một vector được giải phóng khi nó ra khỏi phạm vi, như được chú thích trong Listing 8-10.

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}

Khi vector bị giải phóng, tất cả nội dung của nó cũng bị giải phóng, nghĩa là các số nguyên mà nó chứa sẽ được dọn sạch. Bộ kiểm tra mượn đảm bảo rằng bất kỳ tham chiếu nào đến nội dung của vector chỉ được sử dụng khi bản thân vector còn có giá trị.

Hãy chuyển sang loại bộ sưu tập tiếp theo: String!

Lưu Trữ Văn Bản Mã Hóa UTF-8 với Chuỗi

Chúng ta đã nói về chuỗi trong Chương 4, nhưng bây giờ chúng ta sẽ xem xét chúng chi tiết hơn. Những người mới học Rust thường bị mắc kẹt với chuỗi vì sự kết hợp của ba lý do: khuynh hướng của Rust trong việc tiết lộ các lỗi có thể xảy ra, chuỗi là một cấu trúc dữ liệu phức tạp hơn nhiều so với những gì nhiều lập trình viên đánh giá, và UTF-8. Những yếu tố này kết hợp theo cách dường như khó khăn khi bạn đến từ các ngôn ngữ lập trình khác.

Chúng ta thảo luận về chuỗi trong bối cảnh của các bộ sưu tập vì chuỗi được triển khai như một bộ sưu tập các byte, cộng thêm một số phương thức để cung cấp chức năng hữu ích khi những byte này được diễn giải dưới dạng văn bản. Trong phần này, chúng ta sẽ nói về các hoạt động trên String mà mọi loại bộ sưu tập đều có, chẳng hạn như tạo, cập nhật và đọc. Chúng ta cũng sẽ thảo luận về những cách mà String khác với các bộ sưu tập khác, cụ thể là cách mà việc lập chỉ mục vào một String bị phức tạp hóa bởi sự khác biệt giữa cách con người và máy tính diễn giải dữ liệu String.

Chuỗi Là Gì?

Đầu tiên, chúng ta sẽ định nghĩa những gì chúng ta hiểu bằng thuật ngữ chuỗi. Rust chỉ có một kiểu chuỗi trong ngôn ngữ cốt lõi, đó là lát cắt chuỗi str thường được thấy dưới dạng mượn &str. Trong Chương 4, chúng ta đã nói về lát cắt chuỗi, là tham chiếu đến một số dữ liệu chuỗi mã hóa UTF-8 được lưu trữ ở nơi khác. Các chuỗi nghĩa đen, chẳng hạn, được lưu trữ trong tệp nhị phân của chương trình và do đó là lát cắt chuỗi.

Kiểu String, được cung cấp bởi thư viện chuẩn của Rust thay vì được mã hóa trong ngôn ngữ cốt lõi, là một kiểu chuỗi có thể phát triển, có thể thay đổi, được sở hữu, mã hóa UTF-8. Khi người dùng Rust nhắc đến "chuỗi" trong Rust, họ có thể nhắc đến kiểu String hoặc kiểu lát cắt chuỗi &str, không chỉ một trong những kiểu đó. Mặc dù phần này chủ yếu nói về String, cả hai kiểu đều được sử dụng nhiều trong thư viện chuẩn của Rust, và cả String và lát cắt chuỗi đều được mã hóa UTF-8.

Tạo Một Chuỗi Mới

Nhiều hoạt động tương tự có sẵn với Vec<T> cũng có sẵn với StringString thực sự được triển khai như một bao bọc xung quanh một vector byte với một số đảm bảo, hạn chế và khả năng bổ sung. Một ví dụ về một hàm hoạt động giống nhau với Vec<T>String là hàm new để tạo một thể hiện, như hiển thị trong Listing 8-11.

fn main() {
    let mut s = String::new();
}

Dòng này tạo ra một chuỗi mới, rỗng có tên là s, mà chúng ta sau đó có thể tải dữ liệu vào. Thường thì chúng ta sẽ có một số dữ liệu ban đầu mà chúng ta muốn bắt đầu chuỗi với. Để làm điều đó, chúng ta sử dụng phương thức to_string, có sẵn trên bất kỳ kiểu nào triển khai trait Display, như các chuỗi nghĩa đen có. Listing 8-12 hiển thị hai ví dụ.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}

Mã này tạo ra một chuỗi chứa initial contents.

Chúng ta cũng có thể sử dụng hàm String::from để tạo một String từ một chuỗi nghĩa đen. Mã trong Listing 8-13 tương đương với mã trong Listing 8-12 sử dụng to_string.

fn main() {
    let s = String::from("initial contents");
}

Vì chuỗi được sử dụng cho rất nhiều thứ, chúng ta có thể sử dụng nhiều API chung khác nhau cho chuỗi, cung cấp cho chúng ta rất nhiều tùy chọn. Một số trong số chúng có vẻ thừa thãi, nhưng tất cả đều có vị trí riêng của mình! Trong trường hợp này, String::fromto_string làm cùng một việc, vì vậy việc bạn chọn cái nào là vấn đề của phong cách và khả năng đọc.

Hãy nhớ rằng chuỗi được mã hóa UTF-8, vì vậy chúng ta có thể bao gồm bất kỳ dữ liệu nào được mã hóa đúng cách trong chúng, như hiển thị trong Listing 8-14.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Tất cả những điều này đều là các giá trị String hợp lệ.

Cập Nhật một Chuỗi

Một String có thể tăng kích thước và nội dung của nó có thể thay đổi, giống như nội dung của một Vec<T>, nếu bạn đẩy thêm dữ liệu vào nó. Ngoài ra, bạn có thể thuận tiện sử dụng toán tử + hoặc macro format! để nối các giá trị String.

Thêm vào Chuỗi với push_strpush

Chúng ta có thể làm cho một String phát triển bằng cách sử dụng phương thức push_str để thêm một lát cắt chuỗi, như hiển thị trong Listing 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

Sau hai dòng này, s sẽ chứa foobar. Phương thức push_str lấy một lát cắt chuỗi vì chúng ta không nhất thiết muốn lấy quyền sở hữu của tham số. Ví dụ, trong mã trong Listing 8-16, chúng ta muốn có thể sử dụng s2 sau khi thêm nội dung của nó vào s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}

Nếu phương thức push_str lấy quyền sở hữu của s2, chúng ta sẽ không thể in giá trị của nó trên dòng cuối cùng. Tuy nhiên, mã này hoạt động như chúng ta mong đợi!

Phương thức push lấy một ký tự duy nhất làm tham số và thêm nó vào String. Listing 8-17 thêm chữ cái l vào một String bằng cách sử dụng phương thức push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

Kết quả là, s sẽ chứa lol.

Nối với Toán Tử + hoặc Macro format!

Thường thì bạn sẽ muốn kết hợp hai chuỗi hiện có. Một cách để làm điều đó là sử dụng toán tử +, như hiển thị trong Listing 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}

Chuỗi s3 sẽ chứa Hello, world!. Lý do s1 không còn hợp lệ sau khi thêm, và lý do chúng ta sử dụng một tham chiếu đến s2, có liên quan đến chữ ký của phương thức được gọi khi chúng ta sử dụng toán tử +. Toán tử + sử dụng phương thức add, có chữ ký trông giống như thế này:

fn add(self, s: &str) -> String {

Trong thư viện chuẩn, bạn sẽ thấy add được định nghĩa bằng cách sử dụng generics và liên kết kiểu. Ở đây, chúng ta đã thay thế bằng các kiểu cụ thể, điều này xảy ra khi chúng ta gọi phương thức này với các giá trị String. Chúng ta sẽ thảo luận về generics trong Chương 10. Chữ ký này cung cấp cho chúng ta những manh mối chúng ta cần để hiểu các phần phức tạp của toán tử +.

Đầu tiên, s2 có một &, có nghĩa là chúng ta đang thêm một tham chiếu của chuỗi thứ hai vào chuỗi đầu tiên. Điều này là do tham số s trong hàm add: chúng ta chỉ có thể thêm một &str vào một String; chúng ta không thể thêm hai giá trị String với nhau. Nhưng khoan—kiểu của &s2&String, không phải &str, như đã được chỉ định trong tham số thứ hai cho add. Vậy tại sao Listing 8-18 biên dịch?

Lý do chúng ta có thể sử dụng &s2 trong lệnh gọi đến add là vì trình biên dịch có thể ép buộc đối số &String thành &str. Khi chúng ta gọi phương thức add, Rust sử dụng ép buộc deref, nơi đây biến &s2 thành &s2[..]. Chúng ta sẽ thảo luận về ép buộc deref chi tiết hơn trong Chương 15. Bởi vì add không lấy quyền sở hữu của tham số s, s2 vẫn sẽ là một String hợp lệ sau hoạt động này.

Thứ hai, chúng ta có thể thấy trong chữ ký rằng add lấy quyền sở hữu của selfself không&. Điều này có nghĩa s1 trong Listing 8-18 sẽ bị di chuyển vào lệnh gọi add và sẽ không còn hợp lệ sau đó. Vì vậy, mặc dù let s3 = s1 + &s2; trông như thể nó sẽ sao chép cả hai chuỗi và tạo một chuỗi mới, câu lệnh này thực sự lấy quyền sở hữu của s1, thêm một bản sao nội dung của s2, và sau đó trả lại quyền sở hữu của kết quả. Nói cách khác, nó trông như thể nó đang tạo ra rất nhiều bản sao, nhưng không phải vậy; việc triển khai hiệu quả hơn là sao chép.

Nếu chúng ta cần nối nhiều chuỗi, hành vi của toán tử + trở nên khó xử lý:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

Lúc này, s sẽ là tic-tac-toe. Với tất cả các ký tự +", khó có thể thấy điều gì đang xảy ra. Để kết hợp các chuỗi theo cách phức tạp hơn, chúng ta có thể sử dụng macro format!:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

Mã này cũng đặt s thành tic-tac-toe. Macro format! hoạt động giống như println!, nhưng thay vì in đầu ra ra màn hình, nó trả về một String với nội dung. Phiên bản mã sử dụng format! dễ đọc hơn nhiều, và mã được tạo ra bởi macro format! sử dụng tham chiếu nên lời gọi này không lấy quyền sở hữu của bất kỳ tham số nào của nó.

Lập Chỉ Mục vào Chuỗi

Trong nhiều ngôn ngữ lập trình khác, việc truy cập các ký tự riêng lẻ trong một chuỗi bằng cách tham chiếu đến chúng bằng chỉ mục là một hoạt động hợp lệ và phổ biến. Tuy nhiên, nếu bạn cố gắng truy cập các phần của một String sử dụng cú pháp lập chỉ mục trong Rust, bạn sẽ gặp lỗi. Xem xét mã không hợp lệ trong Listing 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}

Mã này sẽ dẫn đến lỗi sau:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
          but trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Lỗi và ghi chú kể câu chuyện: chuỗi Rust không hỗ trợ lập chỉ mục. Nhưng tại sao không? Để trả lời câu hỏi đó, chúng ta cần thảo luận về cách Rust lưu trữ chuỗi trong bộ nhớ.

Biểu Diễn Nội Bộ

Một String là một bao bọc trên một Vec<u8>. Hãy xem một số ví dụ chuỗi được mã hóa UTF-8 đúng của chúng ta từ Listing 8-14. Đầu tiên là ví dụ này:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Trong trường hợp này, len sẽ là 4, có nghĩa là vector lưu trữ chuỗi "Hola" dài 4 byte. Mỗi chữ cái này chiếm một byte khi được mã hóa trong UTF-8. Tuy nhiên, dòng sau đây có thể làm bạn ngạc nhiên (lưu ý rằng chuỗi này bắt đầu bằng chữ cái Cyrillic viết hoa Ze, không phải số 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

Nếu bạn được hỏi chuỗi dài bao nhiêu, bạn có thể nói 12. Thực tế, câu trả lời của Rust là 24: đó là số byte cần thiết để mã hóa "Здравствуйте" trong UTF-8, vì mỗi giá trị vô hướng Unicode trong chuỗi đó chiếm 2 byte lưu trữ. Do đó, một chỉ mục vào byte của chuỗi sẽ không phải lúc nào cũng tương quan với một giá trị vô hướng Unicode hợp lệ. Để minh họa, hãy xem xét mã Rust không hợp lệ này:

let hello = "Здравствуйте";
let answer = &hello[0];

Bạn đã biết rằng answer sẽ không phải là З, chữ cái đầu tiên. Khi được mã hóa trong UTF-8, byte đầu tiên của З208 và byte thứ hai là 151, vì vậy nó sẽ có vẻ như answer thực sự nên là 208, nhưng 208 không phải là một ký tự hợp lệ tự nó. Trả về 208 có thể không phải là điều mà người dùng muốn nếu họ hỏi về chữ cái đầu tiên của chuỗi này; tuy nhiên, đó là dữ liệu duy nhất mà Rust có tại chỉ mục byte 0. Người dùng thường không muốn giá trị byte được trả về, ngay cả khi chuỗi chỉ chứa các chữ cái Latin: nếu &"hi"[0] là mã hợp lệ trả về giá trị byte, nó sẽ trả về 104, không phải h.

Câu trả lời, vì vậy, là để tránh trả về một giá trị không mong đợi và gây ra lỗi có thể không được phát hiện ngay lập tức, Rust không biên dịch mã này và ngăn chặn hiểu lầm sớm trong quá trình phát triển.

Byte, Giá Trị Vô Hướng và Cụm Tự Hình! Ôi Trời!

Một điểm khác về UTF-8 là thực sự có ba cách liên quan để nhìn chuỗi từ góc độ của Rust: dưới dạng byte, giá trị vô hướng, và cụm tự hình (điều gần nhất với những gì chúng ta gọi là chữ cái).

Nếu chúng ta nhìn vào từ tiếng Hindi "नमस्ते" được viết bằng chữ viết Devanagari, nó được lưu trữ dưới dạng một vector các giá trị u8 trông như thế này:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Đó là 18 byte và đây là cách máy tính cuối cùng lưu trữ dữ liệu này. Nếu chúng ta nhìn vào chúng như các giá trị vô hướng Unicode, đó là kiểu char của Rust, những byte đó trông như thế này:

['न', 'म', 'स', '्', 'त', 'े']

Có sáu giá trị char ở đây, nhưng giá trị thứ tư và thứ sáu không phải là chữ cái: đó là các dấu phụ không có ý nghĩa khi đứng một mình. Cuối cùng, nếu chúng ta nhìn vào chúng như cụm tự hình, chúng ta sẽ có được những gì mà một người sẽ gọi là bốn chữ cái tạo nên từ tiếng Hindi:

["न", "म", "स्", "ते"]

Rust cung cấp các cách khác nhau để diễn giải dữ liệu chuỗi thô mà máy tính lưu trữ để mỗi chương trình có thể chọn cách diễn giải mà nó cần, bất kể ngôn ngữ con người nào mà dữ liệu đó thuộc về.

Một lý do cuối cùng mà Rust không cho phép chúng ta lập chỉ mục vào một String để lấy một ký tự là vì các hoạt động lập chỉ mục được mong đợi luôn mất thời gian không đổi (O(1)). Nhưng không thể đảm bảo hiệu suất đó với một String, vì Rust sẽ phải đi qua nội dung từ đầu đến chỉ mục để xác định có bao nhiêu ký tự hợp lệ.

Cắt Chuỗi

Lập chỉ mục vào một chuỗi thường là một ý tưởng tồi vì không rõ kiểu trả về của hoạt động lập chỉ mục chuỗi nên là gì: một giá trị byte, một ký tự, một cụm tự hình, hay một lát cắt chuỗi. Do đó, nếu bạn thực sự cần sử dụng chỉ mục để tạo lát cắt chuỗi, Rust yêu cầu bạn cần cụ thể hơn.

Thay vì lập chỉ mục bằng [] với một số duy nhất, bạn có thể sử dụng [] với một phạm vi để tạo một lát cắt chuỗi chứa các byte cụ thể:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

Ở đây, s sẽ là một &str chứa bốn byte đầu tiên của chuỗi. Trước đó, chúng ta đã đề cập rằng mỗi ký tự này chiếm hai byte, điều đó có nghĩa s sẽ là Зд.

Nếu chúng ta cố gắng cắt chỉ một phần byte của một ký tự với một cái gì đó như &hello[0..1], Rust sẽ hoảng loạn tại thời điểm chạy theo cách tương tự như khi một chỉ mục không hợp lệ được truy cập trong một vector:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Bạn nên thận trọng khi tạo lát cắt chuỗi với phạm vi, vì làm như vậy có thể làm sập chương trình của bạn.

Phương Thức để Lặp Qua Chuỗi

Cách tốt nhất để hoạt động trên các phần của chuỗi là rõ ràng về việc bạn muốn ký tự hay byte. Đối với các giá trị vô hướng Unicode riêng lẻ, hãy sử dụng phương thức chars. Gọi chars trên "Зд" tách ra và trả về hai giá trị kiểu char, và bạn có thể lặp qua kết quả để truy cập từng phần tử:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Mã này sẽ in ra như sau:

З
д

Ngoài ra, phương thức bytes trả về từng byte thô, điều này có thể phù hợp cho miền của bạn:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Mã này sẽ in ra bốn byte tạo nên chuỗi này:

208
151
208
180

Nhưng hãy nhớ rằng các giá trị vô hướng Unicode hợp lệ có thể bao gồm nhiều hơn một byte.

Lấy cụm tự hình từ chuỗi, như với chữ viết Devanagari, là phức tạp, vì vậy chức năng này không được cung cấp bởi thư viện chuẩn. Các crate có sẵn trên crates.io nếu đây là chức năng bạn cần.

Chuỗi Không Đơn Giản

Tóm lại, chuỗi là phức tạp. Các ngôn ngữ lập trình khác nhau đưa ra những lựa chọn khác nhau về cách trình bày sự phức tạp này cho lập trình viên. Rust đã chọn làm cho việc xử lý dữ liệu String đúng cách trở thành hành vi mặc định cho tất cả các chương trình Rust, điều này có nghĩa là lập trình viên phải suy nghĩ nhiều hơn về việc xử lý dữ liệu UTF-8 ngay từ đầu. Sự đánh đổi này tiết lộ nhiều sự phức tạp của chuỗi hơn là rõ ràng trong các ngôn ngữ lập trình khác, nhưng nó ngăn bạn phải xử lý lỗi liên quan đến các ký tự không phải ASCII sau này trong vòng đời phát triển của bạn.

Tin tốt là thư viện chuẩn cung cấp rất nhiều chức năng được xây dựng dựa trên các kiểu String&str để giúp xử lý các tình huống phức tạp này một cách chính xác. Hãy chắc chắn kiểm tra tài liệu để biết các phương thức hữu ích như contains để tìm kiếm trong một chuỗi và replace để thay thế các phần của một chuỗi bằng một chuỗi khác.

Hãy chuyển sang một thứ ít phức tạp hơn một chút: bảng băm!

Lưu Trữ Khóa với Giá Trị Liên Kết trong Bảng Băm

Loại bộ sưu tập phổ biến cuối cùng của chúng ta là bảng băm. Kiểu HashMap<K, V> lưu trữ một ánh xạ từ khóa kiểu K đến giá trị kiểu V bằng cách sử dụng một hàm băm, xác định cách nó đặt các khóa và giá trị này vào bộ nhớ. Nhiều ngôn ngữ lập trình hỗ trợ cấu trúc dữ liệu này, nhưng chúng thường sử dụng tên khác nhau, chẳng hạn như hash, map, object, hash table, dictionary, hoặc associative array, chỉ để kể vài tên.

Bảng băm hữu ích khi bạn muốn tra cứu dữ liệu không bằng cách sử dụng chỉ mục, như bạn có thể làm với vector, mà bằng cách sử dụng khóa có thể thuộc bất kỳ kiểu nào. Ví dụ, trong một trò chơi, bạn có thể theo dõi điểm số của mỗi đội trong một bảng băm trong đó mỗi khóa là tên của một đội và giá trị là điểm số của mỗi đội. Cho tên đội, bạn có thể lấy ra điểm số của nó.

Chúng ta sẽ xem qua API cơ bản của bảng băm trong phần này, nhưng còn nhiều thứ hữu ích khác ẩn trong các hàm được định nghĩa trên HashMap<K, V> bởi thư viện chuẩn. Như mọi khi, hãy kiểm tra tài liệu của thư viện chuẩn để biết thêm thông tin.

Tạo Bảng Băm Mới

Một cách để tạo bảng băm rỗng là sử dụng new và thêm các phần tử bằng insert. Trong Listing 8-20, chúng ta đang theo dõi điểm số của hai đội có tên là BlueYellow. Đội Blue bắt đầu với 10 điểm, và đội Yellow bắt đầu với 50.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

Lưu ý rằng chúng ta cần use HashMap từ phần collections của thư viện chuẩn trước. Trong ba bộ sưu tập phổ biến của chúng ta, bộ sưu tập này được sử dụng ít nhất, nên nó không được đưa vào phạm vi tự động trong phần prelude. Bảng băm cũng nhận được ít hỗ trợ hơn từ thư viện chuẩn; chẳng hạn, không có macro tích hợp sẵn để xây dựng chúng.

Giống như vector, bảng băm lưu trữ dữ liệu của chúng trên heap. HashMap này có khóa kiểu String và giá trị kiểu i32. Giống như vector, bảng băm là đồng nhất: tất cả các khóa phải có cùng kiểu, và tất cả các giá trị phải có cùng kiểu.

Truy Cập Giá Trị trong Bảng Băm

Chúng ta có thể lấy một giá trị từ bảng băm bằng cách cung cấp khóa của nó cho phương thức get, như được hiển thị trong Listing 8-21.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}

Ở đây, score sẽ có giá trị được liên kết với đội Blue, và kết quả sẽ là 10. Phương thức get trả về một Option<&V>; nếu không có giá trị cho khóa đó trong bảng băm, get sẽ trả về None. Chương trình này xử lý Option bằng cách gọi copied để lấy một Option<i32> thay vì một Option<&i32>, sau đó unwrap_or để đặt score thành không nếu scores không có mục cho khóa.

Chúng ta có thể lặp qua từng cặp khóa-giá trị trong một bảng băm theo cách tương tự như chúng ta làm với vector, sử dụng vòng lặp for:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

Mã này sẽ in mỗi cặp theo thứ tự ngẫu nhiên:

Yellow: 50
Blue: 10

Bảng Băm và Quyền Sở Hữu

Đối với các kiểu triển khai trait Copy, như i32, các giá trị được sao chép vào bảng băm. Đối với các giá trị được sở hữu như String, các giá trị sẽ bị di chuyển và bảng băm sẽ là chủ sở hữu của các giá trị đó, như được minh họa trong Listing 8-22.

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}

Chúng ta không thể sử dụng các biến field_namefield_value sau khi chúng đã được di chuyển vào bảng băm bằng lệnh gọi đến insert.

Nếu chúng ta chèn tham chiếu đến các giá trị vào bảng băm, các giá trị sẽ không bị di chuyển vào bảng băm. Các giá trị mà các tham chiếu trỏ đến phải hợp lệ ít nhất miễn là bảng băm còn hợp lệ. Chúng ta sẽ nói thêm về các vấn đề này trong "Xác thực Tham chiếu với Vòng đời" trong Chương 10.

Cập Nhật Bảng Băm

Mặc dù số lượng cặp khóa và giá trị có thể tăng lên, mỗi khóa duy nhất chỉ có thể có một giá trị liên kết với nó tại một thời điểm (nhưng không ngược lại: ví dụ, cả đội Blue và đội Yellow đều có thể có giá trị 10 được lưu trữ trong bảng băm scores).

Khi bạn muốn thay đổi dữ liệu trong một bảng băm, bạn phải quyết định cách xử lý trường hợp khi một khóa đã có một giá trị được gán. Bạn có thể thay thế giá trị cũ bằng giá trị mới, hoàn toàn bỏ qua giá trị cũ. Bạn có thể giữ giá trị cũ và bỏ qua giá trị mới, chỉ thêm giá trị mới nếu khóa không đã có một giá trị. Hoặc bạn có thể kết hợp giá trị cũ và giá trị mới. Hãy xem cách thực hiện mỗi điều này!

Ghi Đè Một Giá Trị

Nếu chúng ta chèn một khóa và một giá trị vào một bảng băm và sau đó chèn cùng khóa đó với một giá trị khác, giá trị liên kết với khóa đó sẽ bị thay thế. Mặc dù mã trong Listing 8-23 gọi insert hai lần, bảng băm sẽ chỉ chứa một cặp khóa-giá trị vì chúng ta đang chèn giá trị cho khóa của đội Blue cả hai lần.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}

Mã này sẽ in {"Blue": 25}. Giá trị ban đầu 10 đã bị ghi đè.

Chỉ Thêm Khóa và Giá Trị Nếu Khóa Không Tồn Tại

Thông thường, người ta kiểm tra xem một khóa cụ thể đã tồn tại trong bảng băm với một giá trị hay chưa và sau đó thực hiện các hành động sau: nếu khóa đó tồn tại trong bảng băm, giá trị hiện tại nên giữ nguyên như vậy; nếu khóa không tồn tại, chèn nó và một giá trị cho nó.

Bảng băm có một API đặc biệt cho việc này gọi là entry lấy khóa bạn muốn kiểm tra làm tham số. Giá trị trả về của phương thức entry là một enum gọi là Entry đại diện cho một giá trị có thể tồn tại hoặc không. Giả sử chúng ta muốn kiểm tra xem khóa cho đội Yellow có giá trị liên kết với nó không. Nếu không, chúng ta muốn chèn giá trị 50, và tương tự cho đội Blue. Sử dụng API entry, mã trông giống như Listing 8-24.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}

Phương thức or_insert trên Entry được định nghĩa để trả về một tham chiếu có thể thay đổi đến giá trị cho khóa Entry tương ứng nếu khóa đó tồn tại, và nếu không, nó chèn tham số làm giá trị mới cho khóa này và trả về một tham chiếu có thể thay đổi đến giá trị mới. Kỹ thuật này sạch sẽ hơn nhiều so với việc tự viết logic và, ngoài ra, hoạt động tốt hơn với bộ kiểm tra mượn.

Chạy mã trong Listing 8-24 sẽ in {"Yellow": 50, "Blue": 10}. Lệnh gọi đầu tiên đến entry sẽ chèn khóa cho đội Yellow với giá trị 50 vì đội Yellow chưa có giá trị. Lệnh gọi thứ hai đến entry sẽ không thay đổi bảng băm vì đội Blue đã có giá trị 10.

Cập Nhật Giá Trị Dựa trên Giá Trị Cũ

Trường hợp sử dụng phổ biến khác cho bảng băm là tra cứu giá trị của một khóa và sau đó cập nhật nó dựa trên giá trị cũ. Ví dụ, Listing 8-25 hiển thị mã đếm số lần xuất hiện của mỗi từ trong một đoạn văn bản. Chúng ta sử dụng bảng băm với các từ làm khóa và tăng giá trị để theo dõi số lần chúng ta đã thấy từ đó. Nếu đó là lần đầu tiên chúng ta thấy một từ, chúng ta sẽ chèn giá trị 0 trước.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}

Mã này sẽ in {"world": 2, "hello": 1, "wonderful": 1}. Bạn có thể thấy các cặp khóa-giá trị giống nhau được in ra theo thứ tự khác nhau: hãy nhớ lại từ "Truy cập Giá trị trong Bảng băm" rằng việc lặp qua một bảng băm xảy ra theo thứ tự ngẫu nhiên.

Phương thức split_whitespace trả về một iterator qua các lát cắt con, được phân tách bởi khoảng trắng, của giá trị trong text. Phương thức or_insert trả về một tham chiếu có thể thay đổi (&mut V) đến giá trị cho khóa được chỉ định. Ở đây, chúng ta lưu trữ tham chiếu có thể thay đổi đó trong biến count, vì vậy để gán cho giá trị đó, chúng ta phải giải tham chiếu count bằng cách sử dụng dấu hoa thị (*). Tham chiếu có thể thay đổi hết phạm vi tại cuối vòng lặp for, vì vậy tất cả các thay đổi này là an toàn và được cho phép bởi các quy tắc mượn.

Hàm Băm

Theo mặc định, HashMap sử dụng một hàm băm gọi là SipHash có thể cung cấp khả năng chống lại các cuộc tấn công từ chối dịch vụ (DoS) liên quan đến bảng băm1. Đây không phải là thuật toán băm nhanh nhất có sẵn, nhưng sự đánh đổi cho bảo mật tốt hơn đi kèm với sự sụt giảm hiệu suất là đáng giá. Nếu bạn phân tích mã của mình và thấy rằng hàm băm mặc định quá chậm cho mục đích của bạn, bạn có thể chuyển sang một hàm khác bằng cách chỉ định một bộ băm khác. Một hasher là một kiểu triển khai trait BuildHasher. Chúng ta sẽ nói về trait và cách triển khai chúng trong Chương 10. Bạn không nhất thiết phải triển khai bộ băm riêng của bạn từ đầu; crates.io có các thư viện được chia sẻ bởi người dùng Rust khác cung cấp các hasher triển khai nhiều thuật toán băm phổ biến.

Tóm tắt

Vector, chuỗi và bảng băm sẽ cung cấp một lượng lớn chức năng cần thiết trong các chương trình khi bạn cần lưu trữ, truy cập và sửa đổi dữ liệu. Đây là một số bài tập mà bạn bây giờ đã có thể giải quyết:

  1. Cho một danh sách các số nguyên, sử dụng vector và trả về trung vị (khi được sắp xếp, giá trị ở vị trí giữa) và mode (giá trị xuất hiện nhiều nhất; một bảng băm sẽ hữu ích ở đây) của danh sách.
  2. Chuyển đổi chuỗi sang pig latin. Phụ âm đầu tiên của mỗi từ được di chuyển đến cuối từ và thêm ay, vì vậy first trở thành irst-fay. Các từ bắt đầu bằng một nguyên âm được thêm hay vào cuối thay vào đó (apple trở thành apple-hay). Hãy ghi nhớ các chi tiết về mã hóa UTF-8!
  3. Sử dụng bảng băm và vector, tạo một giao diện văn bản để cho phép người dùng thêm tên nhân viên vào một phòng ban trong công ty; ví dụ, "Add Sally to Engineering" hoặc "Add Amir to Sales." Sau đó cho phép người dùng lấy danh sách tất cả mọi người trong một phòng ban hoặc tất cả mọi người trong công ty theo phòng ban, được sắp xếp theo thứ tự bảng chữ cái.

Tài liệu API thư viện chuẩn mô tả các phương thức mà vector, chuỗi, và bảng băm có sẽ hữu ích cho các bài tập này!

Chúng ta đang đi vào các chương trình phức tạp hơn trong đó các hoạt động có thể thất bại, vì vậy đó là thời điểm hoàn hảo để thảo luận về xử lý lỗi. Chúng ta sẽ làm điều đó tiếp theo!


  1. https://en.wikipedia.org/wiki/SipHash

Xử Lý Lỗi

Lỗi là điều không thể tránh khỏi trong phần mềm, vì vậy Rust có một số tính năng để xử lý các tình huống khi có sự cố xảy ra. Trong nhiều trường hợp, Rust yêu cầu bạn phải thừa nhận khả năng xảy ra lỗi và thực hiện một số hành động trước khi mã của bạn được biên dịch. Yêu cầu này làm cho chương trình của bạn trở nên mạnh mẽ hơn bằng cách đảm bảo rằng bạn sẽ phát hiện lỗi và xử lý chúng một cách thích hợp trước khi triển khai mã của bạn lên môi trường sản phẩm!

Rust phân loại lỗi thành hai danh mục chính: lỗi có thể khôi phục và lỗi không thể khôi phục. Đối với lỗi có thể khôi phục, chẳng hạn như lỗi không tìm thấy tệp, chúng ta rất có thể chỉ muốn báo cáo vấn đề cho người dùng và thử lại thao tác đó. Lỗi không thể khôi phục luôn là dấu hiệu của các lỗi phần mềm, chẳng hạn như cố gắng truy cập vào vị trí vượt quá giới hạn của mảng, và vì vậy chúng ta muốn dừng chương trình ngay lập tức.

Hầu hết các ngôn ngữ không phân biệt giữa hai loại lỗi này và xử lý cả hai theo cùng một cách, sử dụng các cơ chế như exception. Rust không có exception. Thay vào đó, nó có kiểu Result<T, E> cho lỗi có thể khôi phục và macro panic! để dừng thực thi khi chương trình gặp lỗi không thể khôi phục. Chương này đề cập đến việc gọi panic! trước, sau đó nói về việc trả về giá trị Result<T, E>. Ngoài ra, chúng ta sẽ khám phá các cân nhắc khi quyết định liệu có nên cố gắng khôi phục từ lỗi hay dừng thực thi.

Lỗi Không Thể Khôi Phục với panic!

Đôi khi những điều không mong muốn xảy ra trong code của bạn, và bạn không thể làm gì để ngăn chặn nó. Trong những trường hợp này, Rust có macro panic!. Có hai cách để gây ra panic trong thực tế: thực hiện một hành động khiến code của chúng ta panic (ví dụ như truy cập vượt quá giới hạn của mảng) hoặc gọi trực tiếp macro panic!. Trong cả hai trường hợp, chúng ta gây ra panic trong chương trình. Theo mặc định, những panic này sẽ hiển thị thông báo lỗi, unwind (tháo gỡ), dọn dẹp stack, và thoát. Thông qua một biến môi trường, bạn cũng có thể yêu cầu Rust hiển thị call stack khi xảy ra panic để dễ dàng theo dõi nguồn gốc của panic.

Tháo Gỡ Stack hoặc Hủy Bỏ Khi Xảy Ra Panic

Theo mặc định, khi panic xảy ra, chương trình bắt đầu tháo gỡ (unwinding), có nghĩa là Rust đi ngược lại stack và dọn dẹp dữ liệu từ mỗi hàm mà nó gặp phải. Tuy nhiên, việc đi ngược lại và dọn dẹp tốn rất nhiều công sức. Vì vậy, Rust cho phép bạn chọn phương án thay thế là hủy bỏ (aborting) ngay lập tức, điều này kết thúc chương trình mà không dọn dẹp gì cả.

Bộ nhớ mà chương trình đang sử dụng sau đó sẽ cần được dọn dẹp bởi hệ điều hành. Nếu trong dự án của bạn cần làm cho tệp nhị phân kết quả nhỏ nhất có thể, bạn có thể chuyển từ tháo gỡ sang hủy bỏ khi panic bằng cách thêm panic = 'abort' vào các phần [profile] thích hợp trong tệp Cargo.toml. Ví dụ, nếu bạn muốn hủy bỏ khi panic trong chế độ phát hành (release mode), hãy thêm điều này:

[profile.release]
panic = 'abort'

Hãy thử gọi panic! trong một chương trình đơn giản:

fn main() {
    panic!("crash and burn");
}

Khi bạn chạy chương trình, bạn sẽ thấy điều gì đó như thế này:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Lệnh gọi panic! gây ra thông báo lỗi trong hai dòng cuối cùng. Dòng đầu tiên hiển thị thông báo panic của chúng ta và vị trí trong mã nguồn nơi panic xảy ra: src/main.rs:2:5 chỉ ra rằng đó là dòng thứ hai, ký tự thứ năm trong tệp src/main.rs của chúng ta.

Trong trường hợp này, dòng được chỉ ra là một phần của code chúng ta, và nếu chúng ta đến dòng đó, chúng ta sẽ thấy lời gọi macro panic!. Trong các trường hợp khác, lời gọi panic! có thể nằm trong code mà code của chúng ta gọi, và tên tệp và số dòng được báo cáo bởi thông báo lỗi sẽ là code của người khác nơi macro panic! được gọi, không phải dòng code của chúng ta cuối cùng dẫn đến lời gọi panic!.

Chúng ta có thể sử dụng backtrace của các hàm từ lời gọi panic! để tìm ra phần code của chúng ta đang gây ra vấn đề. Để hiểu cách sử dụng backtrace của panic!, hãy xem xét một ví dụ khác và xem nó như thế nào khi lời gọi panic! đến từ một thư viện do lỗi trong code của chúng ta thay vì từ code của chúng ta gọi trực tiếp macro. Listing 9-1 có một số code cố gắng truy cập một chỉ mục trong vector vượt quá phạm vi các chỉ mục hợp lệ.

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

Ở đây, chúng ta đang cố gắng truy cập phần tử thứ 100 của vector (ở chỉ mục 99 vì chỉ mục bắt đầu từ 0), nhưng vector chỉ có ba phần tử. Trong trường hợp này, Rust sẽ panic. Sử dụng [] được cho là để trả về một phần tử, nhưng nếu bạn truyền một chỉ mục không hợp lệ, không có phần tử nào mà Rust có thể trả về ở đây một cách chính xác.

Trong C, việc cố gắng đọc vượt quá giới hạn của một cấu trúc dữ liệu là hành vi không xác định. Bạn có thể nhận được bất cứ thứ gì ở vị trí trong bộ nhớ mà sẽ tương ứng với phần tử đó trong cấu trúc dữ liệu, mặc dù bộ nhớ không thuộc về cấu trúc đó. Điều này được gọi là buffer overread (đọc quá bộ đệm) và có thể dẫn đến lỗ hổng bảo mật nếu kẻ tấn công có thể thao túng chỉ mục theo cách để đọc dữ liệu mà họ không được phép truy cập, được lưu trữ sau cấu trúc dữ liệu.

Để bảo vệ chương trình của bạn khỏi loại lỗ hổng này, nếu bạn cố gắng đọc một phần tử ở chỉ mục không tồn tại, Rust sẽ dừng thực thi và từ chối tiếp tục. Hãy thử và xem:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Lỗi này chỉ đến dòng 4 của tệp main.rs nơi chúng ta cố gắng truy cập chỉ mục 99 của vector trong v.

Dòng note: cho chúng ta biết rằng chúng ta có thể thiết lập biến môi trường RUST_BACKTRACE để nhận backtrace chính xác về những gì đã xảy ra để gây ra lỗi. Một backtrace là danh sách tất cả các hàm đã được gọi để đến thời điểm này. Backtraces trong Rust hoạt động giống như trong các ngôn ngữ khác: chìa khóa để đọc backtrace là bắt đầu từ đầu và đọc cho đến khi bạn thấy các tệp bạn đã viết. Đó là nơi vấn đề bắt nguồn. Các dòng phía trên đó là code mà code của bạn đã gọi; các dòng bên dưới là code đã gọi code của bạn. Các dòng trước và sau này có thể bao gồm code Rust cốt lõi, code thư viện chuẩn, hoặc các crate mà bạn đang sử dụng. Hãy thử lấy backtrace bằng cách thiết lập biến môi trường RUST_BACKTRACE thành bất kỳ giá trị nào ngoại trừ 0. Listing 9-2 hiển thị đầu ra tương tự như những gì bạn sẽ thấy.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Đó là rất nhiều thông tin! Đầu ra chính xác bạn thấy có thể khác tùy thuộc vào hệ điều hành và phiên bản Rust của bạn. Để nhận được backtrace với thông tin này, các ký hiệu gỡ lỗi phải được bật. Các ký hiệu gỡ lỗi được bật theo mặc định khi sử dụng cargo build hoặc cargo run mà không có cờ --release, như chúng ta đang làm ở đây.

Trong đầu ra ở Listing 9-2, dòng 6 của backtrace chỉ đến dòng trong dự án chúng ta đang gây ra vấn đề: dòng 4 của src/main.rs. Nếu chúng ta không muốn chương trình của chúng ta panic, chúng ta nên bắt đầu điều tra ở vị trí được chỉ ra bởi dòng đầu tiên đề cập đến tệp chúng ta đã viết. Trong Listing 9-1, nơi chúng ta cố ý viết code sẽ panic, cách để sửa panic là không yêu cầu một phần tử vượt quá phạm vi chỉ mục của vector. Khi code của bạn panic trong tương lai, bạn sẽ cần phải tìm ra hành động nào mà code đang thực hiện với những giá trị nào để gây ra panic và code nên làm gì thay thế.

Chúng ta sẽ quay lại panic! và khi nào chúng ta nên và không nên sử dụng panic! để xử lý các điều kiện lỗi trong phần "To panic! or Not to panic!" sau này trong chương này. Tiếp theo, chúng ta sẽ xem xét cách khôi phục từ lỗi sử dụng Result.

Các lỗi có thể khôi phục với Result

Hầu hết các lỗi không đủ nghiêm trọng để yêu cầu chương trình dừng hoàn toàn. Đôi khi khi một hàm thất bại, đó là vì lý do mà bạn có thể dễ dàng hiểu và phản hồi. Ví dụ, nếu bạn cố mở một tập tin và thao tác đó thất bại vì tập tin không tồn tại, bạn có thể muốn tạo tập tin đó thay vì kết thúc tiến trình.

Nhắc lại từ "Xử lý thất bại tiềm ẩn với Result" trong Chương 2 rằng enum Result được định nghĩa với hai biến thể, OkErr, như sau:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

TE là các tham số kiểu generic: chúng ta sẽ thảo luận chi tiết về generics trong Chương 10. Điều bạn cần biết ngay bây giờ là T đại diện cho kiểu của giá trị sẽ được trả về trong trường hợp thành công bên trong biến thể Ok, và E đại diện cho kiểu của lỗi sẽ được trả về trong trường hợp thất bại bên trong biến thể Err. Bởi vì Result có các tham số kiểu generic này, chúng ta có thể sử dụng kiểu Result và các hàm được định nghĩa trên nó trong nhiều tình huống khác nhau, nơi giá trị thành công và giá trị lỗi mà chúng ta muốn trả về có thể khác nhau.

Hãy gọi một hàm trả về giá trị Result vì hàm có thể thất bại. Trong Listing 9-3, chúng ta thử mở một tập tin.

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

Kiểu trả về của File::openResult<T, E>. Tham số generic T đã được điền bởi quá trình triển khai của File::open với kiểu của giá trị thành công, std::fs::File, là một handle của tập tin. Kiểu của E được sử dụng trong giá trị lỗi là std::io::Error. Kiểu trả về này có nghĩa là lời gọi đến File::open có thể thành công và trả về một handle của tập tin mà chúng ta có thể đọc hoặc ghi. Lời gọi hàm cũng có thể thất bại: ví dụ, tập tin có thể không tồn tại, hoặc chúng ta có thể không có quyền truy cập vào tập tin. Hàm File::open cần có cách để nói cho chúng ta biết liệu nó đã thành công hay thất bại và đồng thời cung cấp cho chúng ta handle của tập tin hoặc thông tin lỗi. Thông tin này chính xác là những gì mà enum Result truyền đạt.

Trong trường hợp File::open thành công, giá trị trong biến greeting_file_result sẽ là một thực thể của Ok chứa một handle của tập tin. Trong trường hợp thất bại, giá trị trong greeting_file_result sẽ là một thực thể của Err chứa thêm thông tin về loại lỗi đã xảy ra.

Chúng ta cần thêm vào mã trong Listing 9-3 để thực hiện các hành động khác nhau tùy thuộc vào giá trị mà File::open trả về. Listing 9-4 cho thấy một cách để xử lý Result bằng cách sử dụng công cụ cơ bản, biểu thức match mà chúng ta đã thảo luận trong Chương 6.

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

Lưu ý rằng, giống như enum Option, enum Result và các biến thể của nó đã được đưa vào phạm vi bởi prelude, vì vậy chúng ta không cần chỉ định Result:: trước các biến thể OkErr trong các nhánh của match.

Khi kết quả là Ok, mã này sẽ trả về giá trị file bên trong từ biến thể Ok, và sau đó chúng ta gán giá trị handle của tập tin đó cho biến greeting_file. Sau match, chúng ta có thể sử dụng handle của tập tin để đọc hoặc ghi.

Nhánh khác của match xử lý trường hợp chúng ta nhận được giá trị Err từ File::open. Trong ví dụ này, chúng ta đã chọn gọi macro panic!. Nếu không có tập tin nào có tên hello.txt trong thư mục hiện tại của chúng ta và chúng ta chạy mã này, chúng ta sẽ thấy đầu ra sau đây từ macro panic!:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Như thường lệ, đầu ra này cho chúng ta biết chính xác điều gì đã sai.

Xử lý các lỗi khác nhau

Mã trong Listing 9-4 sẽ gọi panic! bất kể vì sao File::open thất bại. Tuy nhiên, chúng ta muốn thực hiện các hành động khác nhau với các lý do thất bại khác nhau. Nếu File::open thất bại vì tập tin không tồn tại, chúng ta muốn tạo tập tin và trả về handle cho tập tin mới. Nếu File::open thất bại vì bất kỳ lý do nào khác—ví dụ, vì chúng ta không có quyền mở tập tin—chúng ta vẫn muốn mã gọi panic! theo cách giống như trong Listing 9-4. Để làm điều này, chúng ta thêm một biểu thức match bên trong, như trong Listing 9-5.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}

Kiểu của giá trị mà File::open trả về bên trong biến thể Errio::Error, đây là một struct được cung cấp bởi thư viện chuẩn. Struct này có một phương thức kind mà chúng ta có thể gọi để lấy một giá trị io::ErrorKind. Enum io::ErrorKind được cung cấp bởi thư viện chuẩn và có các biến thể đại diện cho các loại lỗi khác nhau có thể phát sinh từ một thao tác io. Biến thể mà chúng ta muốn sử dụng là ErrorKind::NotFound, cho biết tập tin mà chúng ta đang cố gắng mở không tồn tại. Vì vậy, chúng ta khớp với greeting_file_result, nhưng chúng ta cũng có một match bên trong trên error.kind().

Điều kiện mà chúng ta muốn kiểm tra trong match bên trong là liệu giá trị trả về bởi error.kind() có phải là biến thể NotFound của enum ErrorKind hay không. Nếu đúng, chúng ta cố gắng tạo tập tin với File::create. Tuy nhiên, vì File::create cũng có thể thất bại, chúng ta cần một nhánh thứ hai trong biểu thức match bên trong. Khi tập tin không thể được tạo, một thông báo lỗi khác được in ra. Nhánh thứ hai của match bên ngoài không đổi, vì vậy chương trình sẽ panic khi có bất kỳ lỗi nào khác ngoài lỗi tập tin không tồn tại.

Các lựa chọn thay thế cho việc sử dụng match với Result<T, E>

match thật là nhiều! Biểu thức match rất hữu ích nhưng cũng rất nguyên thủy. Trong Chương 13, bạn sẽ học về closures, được sử dụng với nhiều phương thức được định nghĩa trên Result<T, E>. Các phương thức này có thể ngắn gọn hơn việc sử dụng match khi xử lý các giá trị Result<T, E> trong mã của bạn.

Ví dụ, đây là một cách khác để viết logic tương tự như đã hiển thị trong Listing 9-5, lần này sử dụng closures và phương thức unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Có vấn đề khi tạo tập tin: {error:?}");
            })
        } else {
            panic!("Có vấn đề khi mở tập tin: {error:?}");
        }
    });
}

Mặc dù mã này có cùng hành vi với Listing 9-5, nhưng nó không chứa bất kỳ biểu thức match nào và dễ đọc hơn. Quay lại ví dụ này sau khi bạn đã đọc Chương 13, và tìm kiếm phương thức unwrap_or_else trong tài liệu của thư viện chuẩn. Nhiều phương thức khác có thể làm gọn các biểu thức match lồng nhau lớn khi bạn xử lý lỗi.

Các cách viết tắt cho Panic khi có lỗi: unwrapexpect

Sử dụng match hoạt động khá tốt, nhưng có thể hơi dài dòng và không phải lúc nào cũng truyền đạt ý định rõ ràng. Kiểu Result<T, E> có nhiều phương thức trợ giúp được định nghĩa trên nó để thực hiện các tác vụ khác nhau, cụ thể hơn. Phương thức unwrap là một phương thức viết tắt được triển khai giống như biểu thức match mà chúng ta đã viết trong Listing 9-4. Nếu giá trị Result là biến thể Ok, unwrap sẽ trả về giá trị bên trong Ok. Nếu Result là biến thể Err, unwrap sẽ gọi macro panic! cho chúng ta. Đây là một ví dụ về unwrap đang hoạt động:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

Nếu chúng ta chạy mã này mà không có tập tin hello.txt, chúng ta sẽ thấy một thông báo lỗi từ lời gọi panic! mà phương thức unwrap thực hiện:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Tương tự, phương thức expect cho phép chúng ta chọn thông báo lỗi panic!. Sử dụng expect thay vì unwrap và cung cấp thông báo lỗi tốt có thể truyền đạt ý định của bạn và làm cho việc theo dõi nguồn gốc của một panic dễ dàng hơn. Cú pháp của expect trông như thế này:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

Chúng ta sử dụng expect theo cách tương tự như unwrap: để trả về handle tập tin hoặc gọi macro panic!. Thông báo lỗi được sử dụng bởi expect trong lời gọi của nó đến panic! sẽ là tham số mà chúng ta truyền cho expect, thay vì thông báo panic! mặc định mà unwrap sử dụng. Đây là cách nó trông:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Trong mã chất lượng sản xuất, hầu hết các Rustacean chọn expect thay vì unwrap và cung cấp thêm ngữ cảnh về lý do tại sao thao tác được mong đợi sẽ luôn thành công. Bằng cách đó, nếu giả định của bạn được chứng minh là sai, bạn có thêm thông tin để sử dụng trong việc gỡ lỗi.

Lan truyền lỗi

Khi triển khai của một hàm gọi thứ gì đó có thể thất bại, thay vì xử lý lỗi bên trong chính hàm, bạn có thể trả về lỗi cho mã gọi hàm đó để nó có thể quyết định phải làm gì. Điều này được gọi là lan truyền lỗi và cung cấp nhiều quyền kiểm soát hơn cho mã gọi hàm, nơi có thể có nhiều thông tin hoặc logic hơn để quyết định cách xử lý lỗi so với những gì bạn có sẵn trong ngữ cảnh của mã của bạn.

Ví dụ, Listing 9-6 hiển thị một hàm đọc tên người dùng từ một tập tin. Nếu tập tin không tồn tại hoặc không thể đọc được, hàm này sẽ trả về những lỗi đó cho mã đã gọi hàm.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

Hàm này có thể được viết theo cách ngắn gọn hơn nhiều, nhưng chúng ta sẽ bắt đầu bằng cách làm nhiều thao tác thủ công để khám phá việc xử lý lỗi; cuối cùng, chúng ta sẽ hiển thị cách viết ngắn gọn hơn. Hãy xem xét kiểu trả về của hàm trước: Result<String, io::Error>. Điều này có nghĩa là hàm đang trả về một giá trị thuộc kiểu Result<T, E>, trong đó tham số generic T đã được điền với kiểu cụ thể String và kiểu generic E đã được điền với kiểu cụ thể io::Error.

Nếu hàm này thành công mà không có bất kỳ vấn đề nào, mã gọi hàm này sẽ nhận được một giá trị Ok chứa một String—tên người dùng mà hàm này đã đọc từ tập tin. Nếu hàm này gặp bất kỳ vấn đề nào, mã gọi sẽ nhận được một giá trị Err chứa một instance của io::Error có chứa thêm thông tin về những vấn đề đã xảy ra. Chúng ta đã chọn io::Error làm kiểu trả về của hàm này vì đó là kiểu của giá trị lỗi được trả về từ cả hai thao tác mà chúng ta đang gọi trong thân hàm có thể thất bại: hàm File::open và phương thức read_to_string.

Thân hàm bắt đầu bằng cách gọi hàm File::open. Sau đó, chúng ta xử lý giá trị Result với một match tương tự như match trong Listing 9-4. Nếu File::open thành công, handle tập tin trong biến mẫu file sẽ trở thành giá trị trong biến có thể thay đổi username_file và hàm tiếp tục. Trong trường hợp Err, thay vì gọi panic!, chúng ta sử dụng từ khóa return để thoát sớm khỏi hàm hoàn toàn và truyền giá trị lỗi từ File::open, hiện đang nằm trong biến mẫu e, trở lại cho mã gọi như là giá trị lỗi của hàm này.

Vì vậy, nếu chúng ta có một handle tập tin trong username_file, hàm tiếp theo sẽ tạo một String mới trong biến username và gọi phương thức read_to_string trên handle tập tin trong username_file để đọc nội dung của tập tin vào username. Phương thức read_to_string cũng trả về một Result vì nó có thể thất bại, ngay cả khi File::open đã thành công. Vì vậy, chúng ta cần một match khác để xử lý Result đó: nếu read_to_string thành công, thì hàm của chúng ta đã thành công, và chúng ta trả về tên người dùng từ tập tin hiện đang nằm trong username được bọc trong một Ok. Nếu read_to_string thất bại, chúng ta trả về giá trị lỗi theo cách tương tự như cách chúng ta trả về giá trị lỗi trong match đã xử lý giá trị trả về của File::open. Tuy nhiên, chúng ta không cần nói return một cách rõ ràng, vì đây là biểu thức cuối cùng trong hàm.

Mã gọi mã này sẽ xử lý việc nhận giá trị Ok chứa tên người dùng hoặc giá trị Err chứa một io::Error. Mã gọi quyết định phải làm gì với những giá trị này. Nếu mã gọi nhận được giá trị Err, nó có thể gọi panic! và làm crash chương trình, sử dụng một tên người dùng mặc định, hoặc tìm kiếm tên người dùng từ một nơi khác ngoài tập tin, ví dụ vậy. Chúng ta không có đủ thông tin về những gì mã gọi thực sự đang cố gắng làm, vì vậy chúng ta lan truyền tất cả thông tin thành công hoặc lỗi lên trên để nó xử lý một cách phù hợp.

Mẫu lan truyền lỗi này rất phổ biến trong Rust đến nỗi Rust cung cấp toán tử dấu hỏi ? để làm điều này dễ dàng hơn.

Một cách viết tắt để lan truyền lỗi: Toán tử ?

Listing 9-7 hiển thị một triển khai của read_username_from_file có cùng chức năng như trong Listing 9-6, nhưng triển khai này sử dụng toán tử ?.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

Dấu ? được đặt sau một giá trị Result được định nghĩa để hoạt động theo cách gần như giống như các biểu thức match mà chúng ta đã định nghĩa để xử lý các giá trị Result trong Listing 9-6. Nếu giá trị của ResultOk, giá trị bên trong Ok sẽ được trả về từ biểu thức này, và chương trình sẽ tiếp tục. Nếu giá trị là Err, Err sẽ được trả về từ toàn bộ hàm như thể chúng ta đã sử dụng từ khóa return để giá trị lỗi được lan truyền đến mã gọi.

Có một sự khác biệt giữa những gì mà biểu thức match từ Listing 9-6 làm và những gì toán tử ? làm: các giá trị lỗi mà toán tử ? được gọi trên chúng đi qua hàm from, được định nghĩa trong trait From trong thư viện chuẩn, được sử dụng để chuyển đổi giá trị từ một kiểu sang một kiểu khác. Khi toán tử ? gọi hàm from, kiểu lỗi nhận được được chuyển đổi thành kiểu lỗi được định nghĩa trong kiểu trả về của hàm hiện tại. Điều này hữu ích khi một hàm trả về một kiểu lỗi để đại diện cho tất cả các cách mà một hàm có thể thất bại, ngay cả khi các phần có thể thất bại vì nhiều lý do khác nhau.

Ví dụ, chúng ta có thể thay đổi hàm read_username_from_file trong Listing 9-7 để trả về một kiểu lỗi tùy chỉnh có tên OurError mà chúng ta định nghĩa. Nếu chúng ta cũng định nghĩa impl From<io::Error> for OurError để xây dựng một instance của OurError từ một io::Error, thì các lời gọi toán tử ? trong thân của read_username_from_file sẽ gọi from và chuyển đổi các kiểu lỗi mà không cần thêm bất kỳ mã nào vào hàm.

Trong ngữ cảnh của Listing 9-7, dấu ? ở cuối của lời gọi File::open sẽ trả về giá trị bên trong Ok cho biến username_file. Nếu xảy ra lỗi, toán tử ? sẽ thoát sớm khỏi toàn bộ hàm và đưa bất kỳ giá trị Err nào cho mã gọi. Điều tương tự cũng áp dụng cho dấu ? ở cuối lời gọi read_to_string.

Toán tử ? loại bỏ rất nhiều mã soạn sẵn và làm cho triển khai của hàm này đơn giản hơn. Chúng ta thậm chí có thể làm cho mã này ngắn gọn hơn nữa bằng cách nối các lời gọi phương thức ngay sau ?, như trong Listing 9-8.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

Chúng ta đã di chuyển việc tạo String mới trong username lên đầu hàm; phần đó không thay đổi. Thay vì tạo biến username_file, chúng ta đã nối lời gọi đến read_to_string trực tiếp vào kết quả của File::open("hello.txt")?. Chúng ta vẫn có một dấu ? ở cuối lời gọi read_to_string, và chúng ta vẫn trả về một giá trị Ok chứa username khi cả File::openread_to_string đều thành công thay vì trả về lỗi. Chức năng một lần nữa giống như trong Listing 9-6 và Listing 9-7; đây chỉ là một cách viết khác, phong cách hơn.

Listing 9-9 hiển thị một cách để làm cho nó thậm chí còn ngắn gọn hơn bằng cách sử dụng fs::read_to_string.

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

Đọc một tập tin vào một chuỗi là một hoạt động khá phổ biến, vì vậy thư viện chuẩn cung cấp hàm fs::read_to_string thuận tiện để mở tập tin, tạo một String mới, đọc nội dung của tập tin, đưa nội dung vào String đó, và trả về nó. Tất nhiên, sử dụng fs::read_to_string không cho chúng ta cơ hội để giải thích tất cả việc xử lý lỗi, vì vậy chúng ta đã làm nó theo cách dài dòng hơn trước.

Toán tử ? có thể được sử dụng ở đâu

Toán tử ? chỉ có thể được sử dụng trong các hàm có kiểu trả về tương thích với giá trị mà ? được sử dụng trên đó. Điều này là vì toán tử ? được định nghĩa để thực hiện việc trả về sớm một giá trị từ hàm, theo cùng cách như biểu thức match mà chúng ta đã định nghĩa trong Listing 9-6. Trong Listing 9-6, match đang sử dụng một giá trị Result, và nhánh trả về sớm trả về một giá trị Err(e). Kiểu trả về của hàm phải là một Result để nó tương thích với return này.

Trong Listing 9-10, hãy xem lỗi mà chúng ta sẽ nhận được nếu chúng ta sử dụng toán tử ? trong một hàm main với kiểu trả về không tương thích với kiểu của giá trị mà chúng ta sử dụng ? trên đó:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

Mã này mở một tập tin, điều này có thể thất bại. Toán tử ? theo sau giá trị Result được trả về bởi File::open, nhưng hàm main này có kiểu trả về là (), không phải Result. Khi chúng ta biên dịch mã này, chúng ta nhận được thông báo lỗi sau:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

Lỗi này chỉ ra rằng chúng ta chỉ được phép sử dụng toán tử ? trong một hàm trả về Result, Option, hoặc một kiểu khác mà có triển khai FromResidual.

Để sửa lỗi, bạn có hai lựa chọn. Một lựa chọn là thay đổi kiểu trả về của hàm để tương thích với giá trị mà bạn đang sử dụng toán tử ? trên đó miễn là bạn không có hạn chế nào ngăn bạn làm điều đó. Lựa chọn khác là sử dụng match hoặc một trong các phương thức của Result<T, E> để xử lý Result<T, E> theo cách phù hợp.

Thông báo lỗi cũng đề cập rằng ? có thể được sử dụng với các giá trị Option<T> cũng như. Như với việc sử dụng ? trên Result, bạn chỉ có thể sử dụng ? trên Option trong một hàm trả về một Option. Hành vi của toán tử ? khi được gọi trên một Option<T> tương tự như hành vi của nó khi được gọi trên một Result<T, E>: nếu giá trị là None, None sẽ được trả về sớm từ hàm tại điểm đó. Nếu giá trị là Some, giá trị bên trong Some là giá trị kết quả của biểu thức, và hàm tiếp tục. Listing 9-11 có một ví dụ về một hàm tìm ký tự cuối cùng của dòng đầu tiên trong văn bản đã cho.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

Hàm này trả về Option<char> vì có thể có một ký tự ở đó, nhưng cũng có thể không có. Mã này nhận tham số slice chuỗi text và gọi phương thức lines trên nó, trả về một iterator trên các dòng trong chuỗi. Vì hàm này muốn kiểm tra dòng đầu tiên, nó gọi next trên iterator để lấy giá trị đầu tiên từ iterator. Nếu text là chuỗi rỗng, lời gọi này đến next sẽ trả về None, trong trường hợp đó chúng ta sử dụng ? để dừng và trả về None từ last_char_of_first_line. Nếu text không phải là chuỗi rỗng, next sẽ trả về một giá trị Some chứa một slice chuỗi của dòng đầu tiên trong text.

Dấu ? trích xuất slice chuỗi, và chúng ta có thể gọi chars trên slice chuỗi đó để lấy một iterator của các ký tự của nó. Chúng ta quan tâm đến ký tự cuối cùng trong dòng đầu tiên này, vì vậy chúng ta gọi last để trả về mục cuối cùng trong iterator. Đây là một Option vì có thể dòng đầu tiên là chuỗi rỗng; ví dụ, nếu text bắt đầu bằng một dòng trống nhưng có các ký tự trên các dòng khác, như trong "\nhi". Tuy nhiên, nếu có một ký tự cuối cùng trên dòng đầu tiên, nó sẽ được trả về trong biến thể Some. Toán tử ? ở giữa cung cấp cho chúng ta một cách súc tích để biểu thị logic này, cho phép chúng ta triển khai hàm trong một dòng. Nếu chúng ta không thể sử dụng toán tử ? trên Option, chúng ta sẽ phải triển khai logic này bằng cách sử dụng nhiều lời gọi phương thức hơn hoặc một biểu thức match.

Lưu ý rằng bạn có thể sử dụng toán tử ? trên một Result trong một hàm trả về Result, và bạn có thể sử dụng toán tử ? trên một Option trong một hàm trả về Option, nhưng bạn không thể kết hợp và phù hợp. Toán tử ? sẽ không tự động chuyển đổi một Result thành một Option hoặc ngược lại; trong những trường hợp đó, bạn có thể sử dụng các phương thức như phương thức ok trên Result hoặc phương thức ok_or trên Option để thực hiện chuyển đổi một cách rõ ràng.

Cho đến nay, tất cả các hàm main mà chúng ta đã sử dụng đều trả về (). Hàm main rất đặc biệt vì nó là điểm vào và điểm ra của một chương trình thực thi, và có các hạn chế về kiểu trả về của nó để chương trình hoạt động như mong đợi.

May mắn thay, main cũng có thể trả về một Result<(), E>. Listing 9-12 có mã từ Listing 9-10, nhưng chúng ta đã thay đổi kiểu trả về của main thành Result<(), Box<dyn Error>> và đã thêm một giá trị trả về Ok(()) vào cuối. Mã này sẽ biên dịch bây giờ.

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

Kiểu Box<dyn Error> là một trait object, chúng ta sẽ nói về nó trong "Sử dụng các Trait Object cho phép các giá trị của các kiểu khác nhau" trong Chương 18. Hiện tại, bạn có thể đọc Box<dyn Error> như là "bất kỳ loại lỗi nào." Sử dụng ? trên một giá trị Result trong một hàm main với kiểu lỗi Box<dyn Error> được cho phép vì nó cho phép bất kỳ giá trị Err nào được trả về sớm. Mặc dù thân của hàm main này sẽ chỉ trả về các lỗi của kiểu std::io::Error, nhưng bằng cách chỉ định Box<dyn Error>, chữ ký này sẽ tiếp tục chính xác ngay cả khi thêm mã trả về các lỗi khác được thêm vào thân của main.

Khi một hàm main trả về một Result<(), E>, chương trình thực thi sẽ thoát với giá trị 0 nếu main trả về Ok(()) và sẽ thoát với giá trị khác 0 nếu main trả về một giá trị Err. Các chương trình thực thi được viết bằng C trả về số nguyên khi chúng thoát: các chương trình thoát thành công trả về số nguyên 0, và các chương trình bị lỗi trả về một số nguyên khác 0. Rust cũng trả về số nguyên từ các chương trình thực thi để tương thích với quy ước này.

Hàm main có thể trả về bất kỳ kiểu nào triển khai trait std::process::Termination, chứa một hàm report trả về một ExitCode. Tham khảo tài liệu của thư viện chuẩn để biết thêm thông tin về việc triển khai trait Termination cho các kiểu của riêng bạn.

Bây giờ chúng ta đã thảo luận về chi tiết của việc gọi panic! hoặc trả về Result, hãy quay lại chủ đề về cách quyết định sử dụng cái nào trong trường hợp nào.

panic! Hay Không panic!

Vậy làm thế nào để quyết định khi nào nên gọi panic! và khi nào nên trả về Result? Khi code panic, không có cách nào để phục hồi. Bạn có thể gọi panic! cho bất kỳ tình huống lỗi nào, dù có cách phục hồi hay không, nhưng như vậy bạn đang đưa ra quyết định rằng một tình huống là không thể khôi phục thay mặt cho mã gọi. Khi bạn chọn trả về giá trị Result, bạn đang cung cấp cho mã gọi các lựa chọn. Mã gọi có thể chọn thử phục hồi theo cách phù hợp với tình huống của nó, hoặc có thể quyết định rằng giá trị Err trong trường hợp này là không thể khôi phục, vì vậy nó có thể gọi panic! và biến lỗi có thể khôi phục thành không thể khôi phục. Do đó, việc trả về Result là lựa chọn mặc định tốt khi bạn định nghĩa một hàm có thể thất bại.

Trong các tình huống như ví dụ, mã nguyên mẫu và kiểm thử, thì việc viết mã panic thay vì trả về Result là phù hợp hơn. Hãy tìm hiểu lý do tại sao, sau đó thảo luận về các tình huống mà trình biên dịch không thể biết thất bại là không thể, nhưng bạn với tư cách là con người thì có thể biết. Chương này sẽ kết thúc với một số hướng dẫn chung về cách quyết định liệu có nên panic trong mã thư viện hay không.

Ví dụ, Mã Nguyên mẫu và Kiểm thử

Khi bạn đang viết một ví dụ để minh họa một khái niệm nào đó, việc thêm vào mã xử lý lỗi mạnh mẽ có thể làm cho ví dụ kém rõ ràng hơn. Trong các ví dụ, mọi người hiểu rằng việc gọi một phương thức như unwrap có thể panic là nhằm làm giữ chỗ cho cách bạn muốn ứng dụng của mình xử lý lỗi, điều này có thể khác nhau dựa trên những gì phần còn lại của mã bạn đang làm.

Tương tự, các phương thức unwrapexpect rất tiện lợi khi tạo nguyên mẫu, trước khi bạn sẵn sàng quyết định cách xử lý lỗi. Chúng để lại các dấu hiệu rõ ràng trong mã của bạn cho khi bạn sẵn sàng làm cho chương trình của mình mạnh mẽ hơn.

Nếu một lệnh gọi phương thức thất bại trong một bài kiểm thử, bạn muốn toàn bộ bài kiểm thử thất bại, ngay cả khi phương thức đó không phải là chức năng đang được kiểm thử. Bởi vì panic! là cách mà một bài kiểm thử được đánh dấu là thất bại, việc gọi unwrap hoặc expect chính xác là điều nên xảy ra.

Các trường hợp mà bạn có nhiều thông tin hơn trình biên dịch

Việc gọi unwrap hoặc expect cũng thích hợp khi bạn có một số logic khác đảm bảo rằng Result sẽ có giá trị Ok, nhưng logic đó không phải là điều mà trình biên dịch hiểu được. Bạn vẫn sẽ có một giá trị Result mà bạn cần xử lý: bất kỳ hoạt động nào bạn đang gọi vẫn có khả năng thất bại nói chung, mặc dù về mặt logic điều đó là không thể trong tình huống cụ thể của bạn. Nếu bạn có thể đảm bảo bằng cách kiểm tra mã theo cách thủ công rằng bạn sẽ không bao giờ có biến thể Err, thì hoàn toàn có thể chấp nhận được việc gọi unwrap, và thậm chí tốt hơn là ghi lại lý do tại sao bạn nghĩ rằng bạn sẽ không bao giờ có biến thể Err trong văn bản expect. Đây là một ví dụ:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

Chúng ta đang tạo một thể hiện IpAddr bằng cách phân tích một chuỗi được mã hóa cứng. Chúng ta có thể thấy rằng 127.0.0.1 là một địa chỉ IP hợp lệ, vì vậy việc sử dụng expect là chấp nhận được ở đây. Tuy nhiên, việc có một chuỗi hợp lệ được mã hóa cứng không thay đổi kiểu trả về của phương thức parse: chúng ta vẫn nhận được một giá trị Result, và trình biên dịch sẽ vẫn yêu cầu chúng ta xử lý Result như thể biến thể Err là một khả năng bởi vì trình biên dịch không đủ thông minh để thấy rằng chuỗi này luôn là một địa chỉ IP hợp lệ. Nếu chuỗi địa chỉ IP đến từ người dùng thay vì được mã hóa cứng vào chương trình và do đó khả năng thất bại, chúng ta chắc chắn sẽ muốn xử lý Result bằng một cách mạnh mẽ hơn. Việc đề cập đến giả định rằng địa chỉ IP này được mã hóa cứng sẽ nhắc nhở chúng ta thay đổi expect thành mã xử lý lỗi tốt hơn nếu trong tương lai, chúng ta cần lấy địa chỉ IP từ một nguồn khác thay vì mã hóa cứng.

Hướng dẫn cho việc xử lý lỗi

Nên cho phép code của bạn panic khi có khả năng code của bạn có thể kết thúc trong một trạng thái xấu. Trong ngữ cảnh này, một trạng thái xấu là khi một số giả định, đảm bảo, hợp đồng, hoặc bất biến đã bị phá vỡ, chẳng hạn như khi các giá trị không hợp lệ, giá trị mâu thuẫn nhau, hoặc giá trị bị thiếu được truyền vào code của bạn—cộng với một hoặc nhiều điều kiện sau:

  • Trạng thái xấu là điều không mong đợi, trái ngược với điều gì đó có thể xảy ra thỉnh thoảng, như người dùng nhập dữ liệu sai định dạng.
  • Code của bạn sau điểm này cần dựa vào việc không ở trong trạng thái xấu này, thay vì kiểm tra vấn đề ở từng bước.
  • Không có cách tốt để mã hóa thông tin này trong các kiểu dữ liệu bạn sử dụng. Chúng ta sẽ nghiên cứu một ví dụ về ý nghĩa của điều này trong "Mã hóa Trạng thái và Hành vi như Kiểu dữ liệu" trong Chương 18.

Nếu ai đó gọi code của bạn và truyền vào các giá trị không hợp lý, tốt nhất là trả về một lỗi nếu bạn có thể để người dùng thư viện có thể quyết định họ muốn làm gì trong trường hợp đó. Tuy nhiên, trong các trường hợp mà việc tiếp tục có thể không an toàn hoặc có hại, lựa chọn tốt nhất có thể là gọi panic! và cảnh báo người sử dụng thư viện của bạn về lỗi trong code của họ để họ có thể sửa nó trong quá trình phát triển. Tương tự, panic! thường là lựa chọn phù hợp nếu bạn đang gọi code bên ngoài mà bạn không kiểm soát được và nó trả về một trạng thái không hợp lệ mà bạn không có cách nào để sửa chữa.

Tuy nhiên, khi thất bại là điều được mong đợi, thì việc trả về Result sẽ phù hợp hơn là gọi panic!. Ví dụ bao gồm một trình phân tích cú pháp được cung cấp dữ liệu không đúng định dạng hoặc một yêu cầu HTTP trả về trạng thái chỉ ra rằng bạn đã đạt đến giới hạn tỷ lệ. Trong những trường hợp này, việc trả về Result cho biết rằng thất bại là một khả năng được mong đợi mà mã gọi phải quyết định cách xử lý.

Khi code của bạn thực hiện một hoạt động có thể gây rủi ro cho người dùng nếu nó được gọi bằng các giá trị không hợp lệ, code của bạn nên xác minh các giá trị hợp lệ trước và panic nếu các giá trị không hợp lệ. Điều này chủ yếu là vì lý do an toàn: cố gắng hoạt động trên dữ liệu không hợp lệ có thể làm lộ code của bạn trước các lỗ hổng. Đây là lý do chính khiến thư viện tiêu chuẩn sẽ gọi panic! nếu bạn cố gắng truy cập bộ nhớ ngoài giới hạn: việc cố gắng truy cập bộ nhớ không thuộc về cấu trúc dữ liệu hiện tại là một vấn đề bảo mật phổ biến. Các hàm thường có hợp đồng: hành vi của chúng chỉ được đảm bảo nếu đầu vào đáp ứng các yêu cầu cụ thể. Việc panic khi hợp đồng bị vi phạm là hợp lý vì vi phạm hợp đồng luôn chỉ ra lỗi ở phía gọi, và không phải là loại lỗi mà bạn muốn mã gọi phải xử lý một cách rõ ràng. Trên thực tế, không có cách hợp lý nào để mã gọi có thể khôi phục; lập trình viên gọi cần sửa mã. Các hợp đồng cho một hàm, đặc biệt là khi vi phạm sẽ gây ra panic, nên được giải thích trong tài liệu API cho hàm đó.

Tuy nhiên, việc có nhiều kiểm tra lỗi trong tất cả các hàm của bạn sẽ dài dòng và phiền toái. May mắn thay, bạn có thể sử dụng hệ thống kiểu của Rust (và do đó việc kiểm tra kiểu được thực hiện bởi trình biên dịch) để thực hiện nhiều kiểm tra cho bạn. Nếu hàm của bạn có một kiểu cụ thể làm tham số, bạn có thể tiếp tục với logic mã của bạn, biết rằng trình biên dịch đã đảm bảo bạn có một giá trị hợp lệ. Ví dụ, nếu bạn có một kiểu thay vì một Option, chương trình của bạn mong đợi có cái gì đó chứ không phải không có gì. Mã của bạn sau đó không phải xử lý hai trường hợp cho các biến thể SomeNone: nó sẽ chỉ có một trường hợp cho việc chắc chắn có một giá trị. Mã cố gắng truyền không vào hàm của bạn sẽ thậm chí không biên dịch được, vì vậy hàm của bạn không phải kiểm tra trường hợp đó trong thời gian chạy. Một ví dụ khác là sử dụng kiểu số nguyên không dấu như u32, điều này đảm bảo tham số không bao giờ âm.

Tạo kiểu tùy chỉnh để xác thực

Hãy phát triển ý tưởng sử dụng hệ thống kiểu của Rust để đảm bảo chúng ta có một giá trị hợp lệ thêm một bước nữa và xem xét việc tạo một kiểu tùy chỉnh để xác thực. Hãy nhớ lại trò chơi đoán số ở Chương 2 trong đó mã của chúng ta yêu cầu người dùng đoán một số từ 1 đến 100. Chúng ta chưa bao giờ xác thực rằng số đoán của người dùng nằm giữa các số đó trước khi kiểm tra nó với số bí mật của chúng ta; chúng ta chỉ xác thực rằng số đoán là dương. Trong trường hợp này, hậu quả không quá nghiêm trọng: kết quả "Quá cao" hoặc "Quá thấp" của chúng ta vẫn sẽ chính xác. Nhưng sẽ là một cải tiến hữu ích để hướng dẫn người dùng đưa ra các đoán hợp lệ và có các hành vi khác nhau khi người dùng đoán một số ngoài phạm vi so với khi người dùng nhập, ví dụ, các chữ cái thay vì số.

Một cách để làm điều này là phân tích số đoán dưới dạng i32 thay vì chỉ là u32 để cho phép các số âm tiềm năng, và sau đó thêm một kiểm tra xem số có nằm trong phạm vi hay không, như sau:

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Biểu thức if kiểm tra xem giá trị của chúng ta có ngoài phạm vi không, thông báo cho người dùng về vấn đề, và gọi continue để bắt đầu vòng lặp tiếp theo và yêu cầu một đoán khác. Sau biểu thức if, chúng ta có thể tiếp tục với các so sánh giữa guess và số bí mật, biết rằng guess là từ 1 đến 100.

Tuy nhiên, đây không phải là một giải pháp lý tưởng: nếu điều quan trọng tuyệt đối là chương trình chỉ hoạt động trên các giá trị từ 1 đến 100, và nó có nhiều hàm với yêu cầu này, việc có một kiểm tra như thế này trong mọi hàm sẽ là tẻ nhạt (và có thể ảnh hưởng đến hiệu suất).

Thay vào đó, chúng ta có thể tạo một kiểu mới trong một module chuyên dụng và đặt các xác thực vào một hàm để tạo một thể hiện của kiểu thay vì lặp lại các xác thực ở khắp mọi nơi. Bằng cách này, sẽ an toàn cho các hàm sử dụng kiểu mới trong chữ ký của chúng và tự tin sử dụng các giá trị chúng nhận được. Listing 9-13 cho thấy một cách để định nghĩa một kiểu Guess sẽ chỉ tạo một thể hiện của Guess nếu hàm new nhận một giá trị từ 1 đến 100.

#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

Đầu tiên chúng ta tạo một module mới có tên guessing_game. Tiếp theo chúng ta định nghĩa một cấu trúc trong module đó có tên Guess có một trường có tên value chứa một i32. Đây là nơi số sẽ được lưu trữ.

Sau đó chúng ta thực hiện một hàm liên kết có tên new trên Guess tạo các thể hiện của giá trị Guess. Hàm new được định nghĩa có một tham số có tên value kiểu i32 và trả về một Guess. Mã trong thân hàm new kiểm tra value để đảm bảo nó nằm trong khoảng từ 1 đến 100. Nếu value không vượt qua bài kiểm tra này, chúng ta thực hiện lệnh gọi panic!, điều này sẽ cảnh báo lập trình viên đang viết mã gọi rằng họ có lỗi cần sửa, vì việc tạo một Guess với value nằm ngoài phạm vi này sẽ vi phạm hợp đồng mà Guess::new đang dựa vào. Các điều kiện trong đó Guess::new có thể panic nên được thảo luận trong tài liệu API hướng đến công chúng của nó; chúng ta sẽ đề cập đến các quy ước tài liệu chỉ ra khả năng của một panic! trong tài liệu API mà bạn tạo trong Chương 14. Nếu value vượt qua bài kiểm tra, chúng ta tạo một Guess mới với trường value được thiết lập thành tham số value và trả về Guess.

Tiếp theo, chúng ta thực hiện một phương thức có tên value mượn self, không có bất kỳ tham số nào khác, và trả về một i32. Loại phương thức này đôi khi được gọi là getter vì mục đích của nó là lấy một số dữ liệu từ các trường của nó và trả về nó. Phương thức công khai này là cần thiết vì trường value của cấu trúc Guess là private. Điều quan trọng là trường value phải private để mã sử dụng cấu trúc Guess không được phép đặt value trực tiếp: mã bên ngoài module guessing_game phải sử dụng hàm Guess::new để tạo một thể hiện của Guess, từ đó đảm bảo không có cách nào để Guess có một value chưa được kiểm tra bởi các điều kiện trong hàm Guess::new.

Một hàm có tham số hoặc chỉ trả về số từ 1 đến 100 có thể khai báo trong chữ ký của nó rằng nó nhận hoặc trả về một Guess thay vì một i32 và sẽ không cần thực hiện bất kỳ kiểm tra bổ sung nào trong nội dung của nó.

Tóm tắt

Các tính năng xử lý lỗi của Rust được thiết kế để giúp bạn viết mã mạnh mẽ hơn. Macro panic! báo hiệu rằng chương trình của bạn đang ở trạng thái mà nó không thể xử lý và cho phép bạn yêu cầu quá trình dừng thay vì cố gắng tiếp tục với các giá trị không hợp lệ hoặc không chính xác. Enum Result sử dụng hệ thống kiểu của Rust để chỉ ra rằng các hoạt động có thể thất bại theo cách mà mã của bạn có thể khôi phục từ đó. Bạn có thể sử dụng Result để nói với mã gọi mã của bạn rằng nó cần xử lý khả năng thành công hoặc thất bại. Sử dụng panic!Result trong các tình huống thích hợp sẽ làm cho mã của bạn đáng tin cậy hơn đối mặt với các vấn đề không thể tránh khỏi.

Bây giờ bạn đã thấy những cách hữu ích mà thư viện tiêu chuẩn sử dụng generics với các enum OptionResult, chúng ta sẽ nói về cách thức hoạt động của generics và cách bạn có thể sử dụng chúng trong mã của mình.

Các Kiểu Generic, Traits và Lifetimes

Mọi ngôn ngữ lập trình đều có công cụ để xử lý hiệu quả sự trùng lặp của các khái niệm. Trong Rust, một công cụ như vậy là generics: các đại diện trừu tượng cho các kiểu dữ liệu cụ thể hoặc các thuộc tính khác. Chúng ta có thể biểu đạt hành vi của generics hoặc cách chúng liên quan đến các generics khác mà không cần biết cái gì sẽ ở vị trí của chúng khi biên dịch và chạy mã.

Các hàm có thể nhận tham số của một số kiểu generic, thay vì một kiểu cụ thể như i32 hoặc String, theo cách tương tự như chúng nhận tham số với giá trị không xác định để chạy cùng một mã trên nhiều giá trị cụ thể. Thực tế, chúng ta đã sử dụng generics trong Chương 6 với Option<T>, trong Chương 8 với Vec<T>HashMap<K, V>, và trong Chương 9 với Result<T, E>. Trong chương này, bạn sẽ khám phá cách định nghĩa các kiểu, hàm và phương thức của riêng mình với generics!

Đầu tiên chúng ta sẽ xem xét cách trích xuất một hàm để giảm sự trùng lặp mã. Sau đó chúng ta sẽ sử dụng cùng một kỹ thuật để tạo một hàm generic từ hai hàm mà chỉ khác nhau về kiểu của các tham số của chúng. Chúng ta cũng sẽ giải thích cách sử dụng các kiểu generic trong định nghĩa struct và enum.

Sau đó, bạn sẽ học cách sử dụng traits để định nghĩa hành vi theo cách generic. Bạn có thể kết hợp traits với các kiểu generic để ràng buộc một kiểu generic chỉ chấp nhận những kiểu có một hành vi cụ thể, trái ngược với việc chấp nhận bất kỳ kiểu nào.

Cuối cùng, chúng ta sẽ thảo luận về lifetimes: một loại generics cung cấp cho trình biên dịch thông tin về cách các tham chiếu liên quan đến nhau. Lifetimes cho phép chúng ta cung cấp cho trình biên dịch đủ thông tin về các giá trị được mượn để nó có thể đảm bảo các tham chiếu sẽ hợp lệ trong nhiều tình huống hơn so với khi không có sự trợ giúp của chúng ta.

Loại bỏ sự trùng lặp bằng cách trích xuất một hàm

Generics cho phép chúng ta thay thế các kiểu cụ thể bằng một giữ chỗ đại diện cho nhiều kiểu để loại bỏ sự trùng lặp mã. Trước khi đi sâu vào cú pháp generics, hãy xem trước cách loại bỏ sự trùng lặp theo cách không liên quan đến các kiểu generic bằng cách trích xuất một hàm thay thế các giá trị cụ thể bằng một giữ chỗ đại diện cho nhiều giá trị. Sau đó chúng ta sẽ áp dụng cùng một kỹ thuật để trích xuất một hàm generic! Bằng cách xem xét cách nhận biết mã trùng lặp mà bạn có thể trích xuất vào một hàm, bạn sẽ bắt đầu nhận ra mã trùng lặp có thể sử dụng generics.

Chúng ta sẽ bắt đầu với một chương trình ngắn trong Listing 10-1 tìm số lớn nhất trong một danh sách.

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}

Chúng ta lưu trữ một danh sách các số nguyên trong biến number_list và đặt một tham chiếu tới số đầu tiên trong danh sách vào một biến có tên largest. Sau đó chúng ta lặp qua tất cả các số trong danh sách, và nếu số hiện tại lớn hơn số được lưu trữ trong largest, chúng ta thay thế tham chiếu trong biến đó. Tuy nhiên, nếu số hiện tại nhỏ hơn hoặc bằng số lớn nhất đã thấy cho đến nay, biến không thay đổi, và mã chuyển sang số tiếp theo trong danh sách. Sau khi xem xét tất cả các số trong danh sách, largest sẽ tham chiếu đến số lớn nhất, trong trường hợp này là 100.

Giờ đây chúng ta được giao nhiệm vụ tìm số lớn nhất trong hai danh sách khác nhau của các số. Để làm điều đó, chúng ta có thể chọn sao chép mã trong Listing 10-1 và sử dụng cùng một logic tại hai vị trí khác nhau trong chương trình, như được hiển thị trong Listing 10-2.

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}

Mặc dù mã này hoạt động, việc sao chép mã là tẻ nhạt và dễ gây lỗi. Chúng ta cũng phải nhớ cập nhật mã ở nhiều vị trí khi chúng ta muốn thay đổi nó.

Để loại bỏ sự trùng lặp này, chúng ta sẽ tạo một sự trừu tượng bằng cách định nghĩa một hàm hoạt động trên bất kỳ danh sách số nguyên nào được truyền vào dưới dạng tham số. Giải pháp này làm cho mã của chúng ta rõ ràng hơn và cho phép chúng ta biểu đạt khái niệm tìm số lớn nhất trong một danh sách một cách trừu tượng.

Trong Listing 10-3, chúng ta trích xuất mã tìm số lớn nhất vào một hàm có tên là largest. Sau đó chúng ta gọi hàm để tìm số lớn nhất trong hai danh sách từ Listing 10-2. Chúng ta cũng có thể sử dụng hàm này trên bất kỳ danh sách giá trị i32 nào khác mà chúng ta có thể có trong tương lai.

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}

Hàm largest có một tham số gọi là list, đại diện cho bất kỳ slice cụ thể nào của các giá trị i32 mà chúng ta có thể truyền vào hàm. Kết quả là, khi chúng ta gọi hàm, mã chạy trên các giá trị cụ thể mà chúng ta truyền vào.

Tóm lại, đây là các bước chúng ta đã thực hiện để thay đổi mã từ Listing 10-2 sang Listing 10-3:

  1. Xác định mã trùng lặp.
  2. Trích xuất mã trùng lặp vào thân hàm, và chỉ định các đầu vào và giá trị trả về của mã đó trong chữ ký hàm.
  3. Cập nhật hai trường hợp của mã trùng lặp để gọi hàm thay thế.

Tiếp theo, chúng ta sẽ sử dụng các bước tương tự với generics để giảm sự trùng lặp mã. Cùng cách mà thân hàm có thể hoạt động trên một list trừu tượng thay vì các giá trị cụ thể, generics cho phép mã hoạt động trên các kiểu trừu tượng.

Ví dụ, giả sử chúng ta có hai hàm: một hàm tìm phần tử lớn nhất trong một slice của các giá trị i32 và một hàm tìm phần tử lớn nhất trong một slice của các giá trị char. Làm thế nào để chúng ta loại bỏ sự trùng lặp đó? Hãy tìm hiểu!

Các Kiểu Dữ Liệu Generic

Chúng ta sử dụng generics để tạo các định nghĩa cho các thành phần như chữ ký hàm hoặc structs, mà sau đó chúng ta có thể sử dụng với nhiều kiểu dữ liệu cụ thể khác nhau. Hãy xem trước cách định nghĩa các hàm, structs, enums và phương thức sử dụng generics. Sau đó chúng ta sẽ thảo luận về cách generics ảnh hưởng đến hiệu năng mã.

Trong Định Nghĩa Hàm

Khi định nghĩa một hàm sử dụng generics, chúng ta đặt generics vào chữ ký của hàm nơi mà chúng ta thường xác định kiểu dữ liệu của các tham số và giá trị trả về. Làm như vậy giúp mã của chúng ta linh hoạt hơn và cung cấp nhiều chức năng hơn cho người gọi hàm của chúng ta đồng thời ngăn chặn trùng lặp mã.

Tiếp tục với hàm largest của chúng ta, Listing 10-4 hiển thị hai hàm mà cả hai đều tìm giá trị lớn nhất trong một slice. Sau đó chúng ta sẽ kết hợp chúng thành một hàm duy nhất sử dụng generics.

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}

Hàm largest_i32 là hàm chúng ta đã trích xuất trong Listing 10-3 để tìm giá trị i32 lớn nhất trong một slice. Hàm largest_char tìm giá trị char lớn nhất trong một slice. Phần thân của hai hàm có cùng mã nguồn, vì vậy hãy loại bỏ sự trùng lặp bằng cách giới thiệu một tham số kiểu generic trong một hàm duy nhất.

Để tham số hóa các kiểu trong một hàm đơn lẻ mới, chúng ta cần đặt tên cho tham số kiểu, giống như chúng ta làm cho các tham số giá trị cho một hàm. Bạn có thể sử dụng bất kỳ định danh nào làm tên tham số kiểu. Nhưng chúng ta sẽ sử dụng T vì, theo quy ước, tên tham số kiểu trong Rust thường ngắn, thường chỉ một chữ cái, và quy ước đặt tên kiểu của Rust là CamelCase. Viết tắt của type, T là lựa chọn mặc định của hầu hết lập trình viên Rust.

Khi chúng ta sử dụng một tham số trong thân hàm, chúng ta phải khai báo tên tham số trong chữ ký để trình biên dịch biết tên đó có ý nghĩa gì. Tương tự, khi chúng ta sử dụng tên tham số kiểu trong chữ ký hàm, chúng ta phải khai báo tên tham số kiểu trước khi sử dụng nó. Để định nghĩa hàm generic largest, chúng ta đặt khai báo tên kiểu bên trong dấu ngoặc nhọn, <>, giữa tên của hàm và danh sách tham số, như sau:

fn largest<T>(list: &[T]) -> &T {

Chúng ta đọc định nghĩa này là: hàm largest là generic trên một số kiểu T. Hàm này có một tham số tên là list, là một slice của các giá trị của kiểu T. Hàm largest sẽ trả về một tham chiếu đến một giá trị cùng kiểu T.

Listing 10-5 hiển thị định nghĩa hàm largest kết hợp sử dụng kiểu dữ liệu generic trong chữ ký của nó. Listing này cũng cho thấy cách chúng ta có thể gọi hàm với một slice của các giá trị i32 hoặc giá trị char. Lưu ý rằng mã này sẽ không biên dịch được.

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}

Nếu chúng ta biên dịch mã này ngay bây giờ, chúng ta sẽ nhận được lỗi này:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Văn bản trợ giúp đề cập đến std::cmp::PartialOrd, đó là một trait, và chúng ta sẽ nói về traits trong phần tiếp theo. Hiện tại, hãy biết rằng lỗi này cho biết rằng phần thân của largest sẽ không hoạt động cho tất cả các kiểu có thể của T. Bởi vì chúng ta muốn so sánh các giá trị của kiểu T trong phần thân, chúng ta chỉ có thể sử dụng các kiểu mà các giá trị của nó có thể được sắp xếp theo thứ tự. Để cho phép so sánh, thư viện tiêu chuẩn có trait std::cmp::PartialOrd mà bạn có thể thực hiện trên các kiểu (xem Phụ lục C để biết thêm về trait này). Để sửa mã ví dụ trên, chúng ta sẽ cần phải làm theo gợi ý của văn bản trợ giúp và giới hạn các kiểu hợp lệ cho T chỉ với những kiểu thực hiện PartialOrd. Ví dụ này sau đó sẽ biên dịch, vì thư viện tiêu chuẩn đã thực hiện PartialOrd cho cả i32char.

Trong Định Nghĩa Struct

Chúng ta cũng có thể định nghĩa structs để sử dụng tham số kiểu generic trong một hoặc nhiều trường bằng cách sử dụng cú pháp <>. Listing 10-6 định nghĩa một struct Point<T> để chứa các giá trị tọa độ xy của bất kỳ kiểu nào.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Cú pháp để sử dụng generics trong các định nghĩa struct tương tự với cú pháp được sử dụng trong các định nghĩa hàm. Đầu tiên chúng ta khai báo tên của tham số kiểu bên trong dấu ngoặc nhọn ngay sau tên của struct. Sau đó chúng ta sử dụng kiểu generic trong định nghĩa struct ở những vị trí mà chúng ta muốn chỉ định kiểu dữ liệu cụ thể.

Lưu ý rằng vì chúng ta chỉ sử dụng một kiểu generic để định nghĩa Point<T>, nên định nghĩa này nói rằng struct Point<T> là generic trên một số kiểu T, và các trường xy đều có cùng kiểu đó, bất kể kiểu đó là gì. Nếu chúng ta tạo một thể hiện của Point<T> có giá trị của các kiểu khác nhau, như trong Listing 10-7, mã của chúng ta sẽ không biên dịch.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Trong ví dụ này, khi chúng ta gán giá trị số nguyên 5 cho x, chúng ta cho trình biên dịch biết rằng kiểu generic T sẽ là một số nguyên cho thể hiện này của Point<T>. Sau đó khi chúng ta chỉ định 4.0 cho y, mà chúng ta đã định nghĩa là có cùng kiểu với x, chúng ta sẽ nhận được lỗi không khớp kiểu như sau:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Để định nghĩa một struct Point trong đó xy đều là generics nhưng có thể có các kiểu khác nhau, chúng ta có thể sử dụng nhiều tham số kiểu generic. Ví dụ, trong Listing 10-8, chúng ta thay đổi định nghĩa của Point để generic trên các kiểu TU trong đó x có kiểu Ty có kiểu U.

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Bây giờ tất cả các thể hiện của Point được hiển thị đều được cho phép! Bạn có thể sử dụng nhiều tham số kiểu generic trong một định nghĩa tùy thích, nhưng việc sử dụng quá nhiều sẽ làm cho mã của bạn khó đọc. Nếu bạn thấy mình cần nhiều kiểu generic trong mã của mình, điều đó có thể chỉ ra rằng mã của bạn cần được cấu trúc lại thành các phần nhỏ hơn.

Trong Định Nghĩa Enum

Như chúng ta đã làm với structs, chúng ta có thể định nghĩa enums để giữ các kiểu dữ liệu generic trong các biến thể của chúng. Hãy xem lại enum Option<T> mà thư viện tiêu chuẩn cung cấp, mà chúng ta đã sử dụng trong Chương 6:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Định nghĩa này bây giờ nên có ý nghĩa hơn với bạn. Như bạn có thể thấy, enum Option<T> là generic trên kiểu T và có hai biến thể: Some, mà giữ một giá trị của kiểu T, và một biến thể None không giữ bất kỳ giá trị nào. Bằng cách sử dụng enum Option<T>, chúng ta có thể biểu đạt khái niệm trừu tượng của một giá trị tùy chọn, và vì Option<T> là generic, chúng ta có thể sử dụng sự trừu tượng này bất kể kiểu của giá trị tùy chọn là gì.

Enums cũng có thể sử dụng nhiều kiểu generic. Định nghĩa của enum Result mà chúng ta đã sử dụng trong Chương 9 là một ví dụ:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Enum Result là generic trên hai kiểu, TE, và có hai biến thể: Ok, giữ một giá trị của kiểu T, và Err, giữ một giá trị của kiểu E. Định nghĩa này giúp thuận tiện để sử dụng enum Result ở bất kỳ nơi nào chúng ta có một hoạt động có thể thành công (trả về một giá trị của một số kiểu T) hoặc thất bại (trả về một lỗi của một số kiểu E). Trên thực tế, đây là những gì chúng ta đã sử dụng để mở một tệp trong Listing 9-3, trong đó T được điền với kiểu std::fs::File khi tệp được mở thành công và E được điền với kiểu std::io::Error khi có vấn đề khi mở tệp.

Khi bạn nhận ra các tình huống trong mã của mình với nhiều định nghĩa struct hoặc enum khác nhau chỉ ở kiểu của các giá trị mà chúng chứa, bạn có thể tránh trùng lặp bằng cách sử dụng các kiểu generic thay thế.

Trong Định Nghĩa Phương Thức

Chúng ta có thể triển khai các phương thức trên structs và enums (như chúng ta đã làm trong Chương 5) và sử dụng các kiểu generic trong các định nghĩa của chúng. Listing 10-9 hiển thị struct Point<T> mà chúng ta đã định nghĩa trong Listing 10-6 với một phương thức có tên x được triển khai trên nó.

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Ở đây, chúng ta đã định nghĩa một phương thức có tên x trên Point<T> trả về một tham chiếu đến dữ liệu trong trường x.

Lưu ý rằng chúng ta phải khai báo T ngay sau impl để chúng ta có thể sử dụng T để chỉ định rằng chúng ta đang triển khai các phương thức trên kiểu Point<T>. Bằng cách khai báo T như là một kiểu generic sau impl, Rust có thể xác định rằng kiểu trong dấu ngoặc nhọn trong Point là một kiểu generic chứ không phải là một kiểu cụ thể. Chúng ta có thể đã chọn một tên khác cho tham số generic này so với tham số generic đã khai báo trong định nghĩa struct, nhưng việc sử dụng cùng một tên là quy ước. Nếu bạn viết một phương thức trong một impl khai báo một kiểu generic, phương thức đó sẽ được định nghĩa trên bất kỳ thể hiện nào của kiểu, bất kể kiểu cụ thể nào cuối cùng thay thế cho kiểu generic.

Chúng ta cũng có thể chỉ định các ràng buộc trên các kiểu generic khi định nghĩa các phương thức trên kiểu. Chúng ta có thể, ví dụ, triển khai các phương thức chỉ trên các thể hiện Point<f32> thay vì trên các thể hiện Point<T> với bất kỳ kiểu generic nào. Trong Listing 10-10, chúng ta sử dụng kiểu cụ thể f32, nghĩa là chúng ta không khai báo bất kỳ kiểu nào sau impl.

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Mã này có nghĩa là kiểu Point<f32> sẽ có phương thức distance_from_origin; các thể hiện khác của Point<T>T không phải là kiểu f32 sẽ không có phương thức này được định nghĩa. Phương thức đo lường khoảng cách từ điểm của chúng ta đến điểm có tọa độ (0.0, 0.0) và sử dụng các phép toán toán học là chỉ có sẵn cho các kiểu số thực.

Các tham số kiểu generic trong định nghĩa struct không phải lúc nào cũng giống với các tham số bạn sử dụng trong chữ ký phương thức của cùng một struct đó. Listing 10-11 sử dụng các kiểu generic X1Y1 cho struct PointX2 Y2 cho chữ ký phương thức mixup để làm cho ví dụ rõ ràng hơn. Phương thức tạo một thể hiện Point mới với giá trị x từ Point self (kiểu X1) và giá trị y từ Point được truyền vào (kiểu Y2).

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Trong main, chúng ta đã định nghĩa một Point có một i32 cho x (với giá trị 5) và một f64 cho y (với giá trị 10.4). Biến p2 là một struct Point có một string slice cho x (với giá trị "Hello") và một char cho y (với giá trị c). Gọi mixup trên p1 với đối số p2 cho chúng ta p3, sẽ có một i32 cho xx đến từ p1. Biến p3 sẽ có một char cho yy đến từ p2. Lời gọi macro println! sẽ in ra p3.x = 5, p3.y = c.

Mục đích của ví dụ này là để chứng minh một tình huống trong đó một số tham số generic được khai báo với impl và một số được khai báo với định nghĩa phương thức. Ở đây, các tham số generic X1Y1 được khai báo sau impl vì chúng đi với định nghĩa struct. Các tham số generic X2Y2 được khai báo sau fn mixup vì chúng chỉ liên quan đến phương thức.

Hiệu Năng của Mã Sử Dụng Generics

Bạn có thể tự hỏi liệu có chi phí thời gian chạy khi sử dụng các tham số kiểu generic không. Tin tốt là việc sử dụng các kiểu generic sẽ không làm cho chương trình của bạn chạy chậm hơn so với sử dụng các kiểu cụ thể.

Rust thực hiện điều này bằng cách thực hiện monomorphization của mã sử dụng generics tại thời điểm biên dịch. Monomorphization là quá trình chuyển đổi mã generic thành mã cụ thể bằng cách điền các kiểu cụ thể được sử dụng khi biên dịch. Trong quá trình này, trình biên dịch làm ngược lại các bước chúng ta đã sử dụng để tạo hàm generic trong Listing 10-5: trình biên dịch xem xét tất cả các nơi mã generic được gọi và tạo mã cho các kiểu cụ thể mà mã generic được gọi với.

Hãy xem cách hoạt động của nó bằng cách sử dụng enum generic Option<T> của thư viện tiêu chuẩn:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Khi Rust biên dịch mã này, nó thực hiện monomorphization. Trong quá trình đó, trình biên dịch đọc các giá trị đã được sử dụng trong các thể hiện Option<T> và xác định hai loại Option<T>: một là i32 và loại kia là f64. Như vậy, nó mở rộng định nghĩa generic của Option<T> thành hai định nghĩa chuyên biệt cho i32f64, từ đó thay thế định nghĩa generic bằng các định nghĩa cụ thể.

Phiên bản monomorphized của mã trông tương tự như sau (trình biên dịch sử dụng các tên khác với những gì chúng ta đang sử dụng ở đây để minh họa):

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

Option<T> generic được thay thế bằng các định nghĩa cụ thể được tạo bởi trình biên dịch. Bởi vì Rust biên dịch mã generic thành mã chỉ định kiểu trong mỗi thể hiện, chúng ta không phải trả chi phí thời gian chạy cho việc sử dụng generics. Khi mã chạy, nó hoạt động giống như nếu chúng ta đã sao chép từng định nghĩa bằng tay. Quá trình monomorphization làm cho generics của Rust cực kỳ hiệu quả trong thời gian chạy.

Traits: Định Nghĩa Hành Vi Được Chia Sẻ

Một trait định nghĩa chức năng mà một kiểu cụ thể có và có thể chia sẻ với các kiểu khác. Chúng ta có thể sử dụng traits để định nghĩa hành vi được chia sẻ theo cách trừu tượng. Chúng ta có thể sử dụng trait bounds để chỉ định rằng một kiểu generic có thể là bất kỳ kiểu nào có hành vi nhất định.

Lưu ý: Traits tương tự với một tính năng thường được gọi là interfaces trong các ngôn ngữ khác, mặc dù có một số khác biệt.

Định Nghĩa một Trait

Hành vi của một kiểu bao gồm các phương thức mà chúng ta có thể gọi trên kiểu đó. Các kiểu khác nhau chia sẻ cùng một hành vi nếu chúng ta có thể gọi các phương thức giống nhau trên tất cả các kiểu đó. Định nghĩa trait là một cách để nhóm các chữ ký phương thức lại với nhau để định nghĩa một tập hợp các hành vi cần thiết để hoàn thành một mục đích nào đó.

Ví dụ, giả sử chúng ta có nhiều struct chứa các loại và số lượng văn bản khác nhau: một struct NewsArticle chứa một câu chuyện tin tức được lưu trữ ở một vị trí cụ thể và một SocialPost có thể có, nhiều nhất là 280 ký tự cùng với metadata cho biết liệu đó là một bài viết mới, một bài đăng lại, hoặc một trả lời cho bài viết khác.

Chúng ta muốn tạo một thư viện tổng hợp phương tiện có tên là aggregator có thể hiển thị tóm tắt dữ liệu có thể được lưu trữ trong một thể hiện NewsArticle hoặc SocialPost. Để làm điều này, chúng ta cần một bản tóm tắt từ mỗi loại, và chúng ta sẽ yêu cầu bản tóm tắt đó bằng cách gọi phương thức summarize trên một thể hiện. Listing 10-12 hiển thị định nghĩa của một trait Summary công khai thể hiện hành vi này.

pub trait Summary {
    fn summarize(&self) -> String;
}

Ở đây, chúng ta khai báo một trait bằng cách sử dụng từ khóa trait và sau đó là tên của trait, là Summary trong trường hợp này. Chúng ta cũng khai báo trait là pub để các crate phụ thuộc vào crate này cũng có thể sử dụng trait này, như chúng ta sẽ thấy trong một vài ví dụ. Bên trong dấu ngoặc nhọn, chúng ta khai báo các chữ ký phương thức mô tả các hành vi của các kiểu thực hiện trait này, trong trường hợp này là fn summarize(&self) -> String.

Sau chữ ký phương thức, thay vì cung cấp một triển khai trong dấu ngoặc nhọn, chúng ta sử dụng dấu chấm phẩy. Mỗi kiểu triển khai trait này phải cung cấp hành vi tùy chỉnh riêng của nó cho phần thân của phương thức. Trình biên dịch sẽ áp dụng rằng bất kỳ kiểu nào có trait Summary sẽ phải có phương thức summarize được định nghĩa với chữ ký này một cách chính xác.

Một trait có thể có nhiều phương thức trong thân: các chữ ký phương thức được liệt kê mỗi phương thức một dòng, và mỗi dòng kết thúc bằng dấu chấm phẩy.

Triển Khai một Trait trên một Kiểu

Bây giờ chúng ta đã định nghĩa các chữ ký mong muốn của các phương thức của trait Summary, chúng ta có thể triển khai nó trên các kiểu trong trình tổng hợp phương tiện của chúng ta. Listing 10-13 hiển thị một triển khai của trait Summary trên struct NewsArticle sử dụng tiêu đề, tác giả và vị trí để tạo giá trị trả về của summarize. Đối với struct SocialPost, chúng ta định nghĩa summarize là tên người dùng theo sau là toàn bộ văn bản của bài viết, giả định rằng nội dung bài viết đã giới hạn ở 280 ký tự.

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Triển khai một trait trên một kiểu tương tự như triển khai các phương thức thông thường. Sự khác biệt là sau impl, chúng ta đặt tên trait mà chúng ta muốn triển khai, sau đó sử dụng từ khóa for, và sau đó chỉ định tên của kiểu mà chúng ta muốn triển khai trait cho. Trong khối impl, chúng ta đặt các chữ ký phương thức mà định nghĩa trait đã định nghĩa. Thay vì thêm dấu chấm phẩy sau mỗi chữ ký, chúng ta sử dụng dấu ngoặc nhọn và điền vào phần thân phương thức với hành vi cụ thể mà chúng ta muốn các phương thức của trait có đối với kiểu cụ thể.

Bây giờ thư viện đã triển khai trait Summary trên NewsArticleSocialPost, người dùng của crate có thể gọi các phương thức trait trên các thể hiện của NewsArticleSocialPost giống như cách chúng ta gọi các phương thức thông thường. Sự khác biệt duy nhất là người dùng phải đưa trait vào phạm vi cũng như các kiểu. Đây là một ví dụ về cách một binary crate có thể sử dụng thư viện aggregator của chúng ta:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

Mã này in ra 1 new post: horse_ebooks: of course, as you probably already know, people.

Các crate khác phụ thuộc vào crate aggregator cũng có thể đưa trait Summary vào phạm vi để triển khai Summary trên các kiểu riêng của họ. Một hạn chế cần lưu ý là chúng ta chỉ có thể triển khai một trait trên một kiểu nếu hoặc trait hoặc kiểu, hoặc cả hai, là local đối với crate của chúng ta. Ví dụ, chúng ta có thể triển khai các trait của thư viện tiêu chuẩn như Display trên một kiểu tùy chỉnh như SocialPost như một phần của chức năng crate aggregator của chúng ta vì kiểu SocialPost là local đối với crate aggregator của chúng ta. Chúng ta cũng có thể triển khai Summary trên Vec<T> trong crate aggregator của chúng ta vì trait Summary là local đối với crate aggregator của chúng ta.

Nhưng chúng ta không thể triển khai các trait bên ngoài trên các kiểu bên ngoài. Ví dụ, chúng ta không thể triển khai trait Display trên Vec<T> trong crate aggregator của chúng ta vì DisplayVec<T> đều được định nghĩa trong thư viện tiêu chuẩn và không local đối với crate aggregator của chúng ta. Hạn chế này là một phần của thuộc tính được gọi là coherence, và cụ thể hơn là quy tắc mồ côi, được đặt tên như vậy bởi vì kiểu cha không có mặt. Quy tắc này đảm bảo rằng mã của người khác không thể phá vỡ mã của bạn và ngược lại. Nếu không có quy tắc này, hai crate có thể triển khai cùng một trait cho cùng một kiểu, và Rust sẽ không biết triển khai nào để sử dụng.

Các Triển Khai Mặc Định

Đôi khi việc có hành vi mặc định cho một số hoặc tất cả các phương thức trong một trait thay vì yêu cầu triển khai cho tất cả các phương thức trên mọi kiểu là hữu ích. Sau đó, khi chúng ta triển khai trait trên một kiểu cụ thể, chúng ta có thể giữ nguyên hoặc ghi đè hành vi mặc định của từng phương thức.

Trong Listing 10-14, chúng ta chỉ định một chuỗi mặc định cho phương thức summarize của trait Summary thay vì chỉ định nghĩa chữ ký phương thức, như chúng ta đã làm trong Listing 10-12.

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Để sử dụng một triển khai mặc định để tóm tắt các thể hiện của NewsArticle, chúng ta chỉ định một khối impl trống với impl Summary for NewsArticle {}.

Mặc dù chúng ta không còn định nghĩa phương thức summarize trên NewsArticle trực tiếp, chúng ta đã cung cấp một triển khai mặc định và chỉ định rằng NewsArticle triển khai trait Summary. Kết quả là, chúng ta vẫn có thể gọi phương thức summarize trên một thể hiện của NewsArticle, như thế này:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

Mã này in ra New article available! (Read more...).

Việc tạo một triển khai mặc định không yêu cầu chúng ta thay đổi bất cứ điều gì về việc triển khai Summary trên SocialPost trong Listing 10-13. Lý do là cú pháp để ghi đè một triển khai mặc định giống với cú pháp để triển khai một phương thức trait không có triển khai mặc định.

Các triển khai mặc định có thể gọi các phương thức khác trong cùng trait, ngay cả khi những phương thức khác đó không có triển khai mặc định. Bằng cách này, một trait có thể cung cấp nhiều chức năng hữu ích và chỉ yêu cầu những người triển khai chỉ định một phần nhỏ của nó. Ví dụ, chúng ta có thể định nghĩa trait Summary có một phương thức summarize_author mà triển khai của nó là bắt buộc, và sau đó định nghĩa một phương thức summarize có triển khai mặc định gọi phương thức summarize_author:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Để sử dụng phiên bản này của Summary, chúng ta chỉ cần định nghĩa summarize_author khi triển khai trait trên một kiểu:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

Sau khi chúng ta định nghĩa summarize_author, chúng ta có thể gọi summarize trên các thể hiện của struct SocialPost, và triển khai mặc định của summarize sẽ gọi định nghĩa của summarize_author mà chúng ta đã cung cấp. Bởi vì chúng ta đã triển khai summarize_author, trait Summary đã cung cấp cho chúng ta hành vi của phương thức summarize mà không yêu cầu chúng ta viết thêm bất kỳ mã nào. Đây là cách nó hoạt động:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

Mã này in ra 1 new post: (Read more from @horse_ebooks...).

Lưu ý rằng không thể gọi triển khai mặc định từ một triển khai ghi đè của cùng một phương thức.

Traits như Tham Số

Bây giờ bạn đã biết cách định nghĩa và triển khai traits, chúng ta có thể khám phá cách sử dụng traits để định nghĩa các hàm chấp nhận nhiều kiểu khác nhau. Chúng ta sẽ sử dụng trait Summary mà chúng ta đã triển khai trên các kiểu NewsArticleSocialPost trong Listing 10-13 để định nghĩa một hàm notify gọi phương thức summarize trên tham số item của nó, là một kiểu nào đó triển khai trait Summary. Để làm điều này, chúng ta sử dụng cú pháp impl Trait, như thế này:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

Thay vì một kiểu cụ thể cho tham số item, chúng ta chỉ định từ khóa impl và tên trait. Tham số này chấp nhận bất kỳ kiểu nào triển khai trait được chỉ định. Trong thân của notify, chúng ta có thể gọi bất kỳ phương thức nào trên item mà đến từ trait Summary, chẳng hạn như summarize. Chúng ta có thể gọi notify và truyền vào bất kỳ thể hiện nào của NewsArticle hoặc SocialPost. Mã gọi hàm với bất kỳ kiểu nào khác, chẳng hạn như String hoặc i32, sẽ không biên dịch vì các kiểu đó không triển khai Summary.

Cú Pháp Trait Bound

Cú pháp impl Trait hoạt động cho các trường hợp đơn giản nhưng thực sự là cú pháp đường tắt cho một hình thức dài hơn được gọi là trait bound; nó trông như thế này:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

Hình thức dài hơn này tương đương với ví dụ trong phần trước nhưng dài dòng hơn. Chúng ta đặt các trait bound với khai báo của tham số kiểu generic sau dấu hai chấm và bên trong dấu ngoặc nhọn.

Cú pháp impl Trait thuận tiện và tạo ra mã ngắn gọn hơn trong các trường hợp đơn giản, trong khi cú pháp trait bound đầy đủ hơn có thể biểu đạt phức tạp hơn trong các trường hợp khác. Ví dụ, chúng ta có thể có hai tham số triển khai Summary. Thực hiện điều này với cú pháp impl Trait trông như thế này:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

Sử dụng impl Trait là phù hợp nếu chúng ta muốn hàm này cho phép item1item2 có các kiểu khác nhau (miễn là cả hai kiểu đều triển khai Summary). Nếu chúng ta muốn buộc cả hai tham số phải có cùng kiểu, tuy nhiên, chúng ta phải sử dụng một trait bound, như thế này:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

Kiểu generic T được chỉ định làm kiểu của các tham số item1item2 ràng buộc hàm sao cho kiểu cụ thể của giá trị được truyền dưới dạng đối số cho item1item2 phải giống nhau.

Chỉ Định Nhiều Trait Bounds với Cú Pháp +

Chúng ta cũng có thể chỉ định nhiều hơn một trait bound. Giả sử chúng ta muốn notify sử dụng định dạng hiển thị cũng như summarize trên item: chúng ta chỉ định trong định nghĩa notify rằng item phải triển khai cả DisplaySummary. Chúng ta có thể làm như vậy bằng cách sử dụng cú pháp +:

pub fn notify(item: &(impl Summary + Display)) {

Cú pháp + cũng hợp lệ với trait bounds trên các kiểu generic:

pub fn notify<T: Summary + Display>(item: &T) {

Với hai trait bounds đã được chỉ định, thân của notify có thể gọi summarize và sử dụng {} để định dạng item.

Trait Bounds Rõ Ràng Hơn với Mệnh Đề where

Việc sử dụng quá nhiều trait bounds có nhược điểm. Mỗi generic có trait bound riêng của nó, vì vậy các hàm với nhiều tham số kiểu generic có thể chứa rất nhiều thông tin trait bound giữa tên của hàm và danh sách tham số của nó, làm cho chữ ký hàm khó đọc. Vì lý do này, Rust có cú pháp thay thế để chỉ định trait bounds bên trong một mệnh đề where sau chữ ký hàm. Vì vậy, thay vì viết như thế này:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

chúng ta có thể sử dụng một mệnh đề where, như thế này:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

Chữ ký của hàm này ít rối hơn: tên hàm, danh sách tham số, và kiểu trả về ở gần nhau, tương tự như một hàm không có nhiều trait bounds.

Trả Về các Kiểu Triển Khai Traits

Chúng ta cũng có thể sử dụng cú pháp impl Trait ở vị trí trả về để trả về một giá trị của một số kiểu triển khai một trait, như được hiển thị ở đây:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

Bằng cách sử dụng impl Summary cho kiểu trả về, chúng ta chỉ định rằng hàm returns_summarizable trả về một số kiểu triển khai trait Summary mà không cần đặt tên kiểu cụ thể. Trong trường hợp này, returns_summarizable trả về một SocialPost, nhưng mã gọi hàm này không cần biết điều đó.

Khả năng chỉ định một kiểu trả về chỉ bằng trait mà nó triển khai đặc biệt hữu ích trong ngữ cảnh của closures và iterators, mà chúng ta sẽ đề cập trong Chương 13. Closures và iterators tạo ra các kiểu mà chỉ trình biên dịch biết hoặc các kiểu rất dài để chỉ định. Cú pháp impl Trait cho phép bạn ngắn gọn chỉ định rằng một hàm trả về một số kiểu triển khai trait Iterator mà không cần viết ra một kiểu rất dài.

Tuy nhiên, bạn chỉ có thể sử dụng impl Trait nếu bạn đang trả về một kiểu duy nhất. Ví dụ, mã này trả về một NewsArticle hoặc một SocialPost với kiểu trả về được chỉ định là impl Summary sẽ không hoạt động:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

Việc trả về một NewsArticle hoặc một SocialPost không được phép do các hạn chế xung quanh cách cú pháp impl Trait được triển khai trong trình biên dịch. Chúng ta sẽ đề cập đến cách viết một hàm với hành vi này trong phần "Sử dụng Trait Objects Cho Phép các Giá Trị của Các Kiểu Khác Nhau" của Chương 18.

Sử Dụng Trait Bounds để Triển Khai Các Phương Thức Có Điều Kiện

Bằng cách sử dụng một trait bound với một khối impl sử dụng các tham số kiểu generic, chúng ta có thể triển khai các phương thức có điều kiện cho các kiểu triển khai các traits được chỉ định. Ví dụ, kiểu Pair<T> trong Listing 10-15 luôn triển khai hàm new để trả về một thể hiện mới của Pair<T> (nhớ lại từ phần "Định Nghĩa Phương Thức" của Chương 5 rằng Self là một bí danh kiểu cho kiểu của khối impl, trong trường hợp này là Pair<T>). Nhưng trong khối impl tiếp theo, Pair<T> chỉ triển khai phương thức cmp_display nếu kiểu bên trong T của nó triển khai trait PartialOrd cho phép so sánh trait Display cho phép in.

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

Chúng ta cũng có thể triển khai có điều kiện một trait cho bất kỳ kiểu nào triển khai một trait khác. Việc triển khai một trait trên bất kỳ kiểu nào thỏa mãn các trait bounds được gọi là blanket implementations và được sử dụng rộng rãi trong thư viện tiêu chuẩn Rust. Ví dụ, thư viện tiêu chuẩn triển khai trait ToString trên bất kỳ kiểu nào triển khai trait Display. Khối impl trong thư viện tiêu chuẩn trông tương tự như mã này:

impl<T: Display> ToString for T {
    // --snip--
}

Bởi vì thư viện tiêu chuẩn có blanket implementation này, chúng ta có thể gọi phương thức to_string được định nghĩa bởi trait ToString trên bất kỳ kiểu nào triển khai trait Display. Ví dụ, chúng ta có thể chuyển các số nguyên thành các giá trị String tương ứng của chúng như thế này vì số nguyên triển khai Display:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Các blanket implementation xuất hiện trong tài liệu của trait trong phần "Implementors".

Traits và trait bounds cho phép chúng ta viết mã sử dụng các tham số kiểu generic để giảm sự trùng lặp nhưng cũng chỉ định cho trình biên dịch rằng chúng ta muốn kiểu generic có hành vi cụ thể. Trình biên dịch sau đó có thể sử dụng thông tin trait bound để kiểm tra rằng tất cả các kiểu cụ thể được sử dụng với mã của chúng ta cung cấp hành vi chính xác. Trong các ngôn ngữ kiểu động, chúng ta sẽ gặp lỗi tại thời điểm chạy nếu chúng ta gọi một phương thức trên một kiểu không định nghĩa phương thức đó. Nhưng Rust chuyển các lỗi này sang thời điểm biên dịch để chúng ta buộc phải sửa các vấn đề trước khi mã của chúng ta thậm chí có thể chạy. Ngoài ra, chúng ta không phải viết mã kiểm tra hành vi tại thời điểm chạy vì chúng ta đã kiểm tra tại thời điểm biên dịch. Làm như vậy cải thiện hiệu suất mà không phải từ bỏ tính linh hoạt của generics.

Xác thực tham chiếu với Lifetimes

Lifetimes (thời gian sống) là một loại generic khác mà chúng ta đã sử dụng. Thay vì đảm bảo rằng một kiểu có hành vi chúng ta mong muốn, lifetimes đảm bảo rằng các tham chiếu hợp lệ miễn là chúng ta cần chúng.

Một chi tiết chúng ta chưa thảo luận trong phần "Tham chiếu và Mượn" ở Chương 4 là mỗi tham chiếu trong Rust có một lifetime, là phạm vi mà tham chiếu đó có hiệu lực. Hầu hết thời gian, lifetimes được ngầm định và suy luận, giống như hầu hết thời gian, các kiểu được suy luận. Chúng ta chỉ phải chú thích kiểu khi nhiều kiểu có thể áp dụng. Tương tự, chúng ta phải chú thích lifetimes khi lifetimes của các tham chiếu có thể liên quan theo một số cách khác nhau. Rust yêu cầu chúng ta chú thích các mối quan hệ bằng các tham số lifetime generic để đảm bảo các tham chiếu thực tế được sử dụng trong thời gian chạy chắc chắn sẽ hợp lệ.

Việc chú thích lifetimes thậm chí không phải là một khái niệm mà hầu hết các ngôn ngữ lập trình khác có, vì vậy điều này sẽ cảm thấy không quen thuộc. Mặc dù chúng ta sẽ không bao quát lifetimes một cách đầy đủ trong chương này, chúng ta sẽ thảo luận về các cách phổ biến mà bạn có thể gặp cú pháp lifetime để bạn có thể làm quen với khái niệm này.

Ngăn chặn Dangling References (Tham chiếu treo) với Lifetimes

Mục đích chính của lifetimes là ngăn chặn dangling references, gây ra cho chương trình tham chiếu đến dữ liệu khác với dữ liệu mà nó dự định tham chiếu. Hãy xem xét chương trình trong Listing 10-16, có một phạm vi ngoài và một phạm vi trong.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}

Lưu ý: Các ví dụ trong Listings 10-16, 10-17, và 10-23 khai báo các biến mà không cung cấp giá trị ban đầu, vì vậy tên biến tồn tại trong phạm vi ngoài. Thoạt nhìn, điều này có thể xuất hiện mâu thuẫn với việc Rust không có giá trị null. Tuy nhiên, nếu chúng ta cố gắng sử dụng một biến trước khi gán giá trị cho nó, chúng ta sẽ nhận được lỗi biên dịch, điều này cho thấy rằng Rust thực sự không cho phép giá trị null.

Phạm vi ngoài khai báo một biến tên r không có giá trị ban đầu, và phạm vi trong khai báo một biến tên x với giá trị ban đầu là 5. Bên trong phạm vi trong, chúng ta cố gắng đặt giá trị của r là một tham chiếu đến x. Sau đó phạm vi trong kết thúc, và chúng ta cố gắng in giá trị trong r. Mã này sẽ không biên dịch vì giá trị mà r đang tham chiếu đã ra khỏi phạm vi trước khi chúng ta cố gắng sử dụng nó. Đây là thông báo lỗi:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                  --- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Thông báo lỗi nói rằng biến x "không sống đủ lâu." Lý do là x sẽ ra khỏi phạm vi khi phạm vi trong kết thúc ở dòng 7. Nhưng r vẫn hợp lệ cho phạm vi ngoài; vì phạm vi của nó lớn hơn, chúng ta nói rằng nó "sống lâu hơn." Nếu Rust cho phép mã này hoạt động, r sẽ tham chiếu đến bộ nhớ đã bị giải phóng khi x ra khỏi phạm vi, và bất cứ điều gì chúng ta cố gắng làm với r sẽ không hoạt động đúng. Vậy làm thế nào Rust xác định rằng mã này không hợp lệ? Nó sử dụng một borrow checker.

Borrow Checker (Kiểm tra Mượn)

Trình biên dịch Rust có một borrow checker so sánh phạm vi để xác định liệu tất cả các mượn có hợp lệ hay không. Listing 10-17 hiển thị cùng một mã như Listing 10-16 nhưng có chú thích hiển thị lifetimes của các biến.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+

Ở đây, chúng ta đã chú thích lifetime của r với 'a và lifetime của x với 'b. Như bạn có thể thấy, khối 'b bên trong nhỏ hơn nhiều so với khối lifetime 'a bên ngoài. Tại thời điểm biên dịch, Rust so sánh kích thước của hai lifetimes và thấy rằng r có lifetime là 'a nhưng nó tham chiếu đến bộ nhớ với lifetime là 'b. Chương trình bị từ chối vì 'b ngắn hơn 'a: chủ thể của tham chiếu không sống lâu bằng tham chiếu.

Listing 10-18 sửa mã để nó không có dangling reference và nó biên dịch mà không có bất kỳ lỗi nào.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+

Ở đây, x có lifetime 'b, trong trường hợp này lớn hơn 'a. Điều này có nghĩa là r có thể tham chiếu x vì Rust biết rằng tham chiếu trong r sẽ luôn hợp lệ trong khi x hợp lệ.

Bây giờ bạn đã biết lifetimes của tham chiếu ở đâu và cách Rust phân tích lifetimes để đảm bảo tham chiếu sẽ luôn hợp lệ, hãy khám phá generic lifetimes của tham số và giá trị trả về trong bối cảnh của các hàm.

Generic Lifetimes trong Hàm

Chúng ta sẽ viết một hàm trả về slice chuỗi dài hơn trong hai slice chuỗi. Hàm này sẽ nhận hai slice chuỗi và trả về một slice chuỗi. Sau khi chúng ta đã triển khai hàm longest, mã trong Listing 10-19 sẽ in ra The longest string is abcd.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

Lưu ý rằng chúng ta muốn hàm nhận slice chuỗi, là tham chiếu, thay vì chuỗi, vì chúng ta không muốn hàm longest lấy quyền sở hữu các tham số của nó. Tham khảo "String Slices as Parameters" trong Chương 4 để biết thêm thảo luận về lý do tại sao các tham số chúng ta sử dụng trong Listing 10-19 là những tham số mà chúng ta muốn.

Nếu chúng ta cố gắng triển khai hàm longest như trong Listing 10-20, nó sẽ không biên dịch.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

Thay vào đó, chúng ta nhận được lỗi sau nói về lifetimes:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Văn bản trợ giúp tiết lộ rằng kiểu trả về cần một tham số lifetime generic vì Rust không thể biết liệu tham chiếu được trả về tham chiếu đến x hay y. Thực tế, chúng ta cũng không biết, vì khối if trong thân của hàm này trả về một tham chiếu đến x và khối else trả về một tham chiếu đến y!

Khi chúng ta định nghĩa hàm này, chúng ta không biết các giá trị cụ thể sẽ được truyền vào hàm này, vì vậy chúng ta không biết liệu trường hợp if hay trường hợp else sẽ được thực thi. Chúng ta cũng không biết lifetimes cụ thể của các tham chiếu sẽ được truyền vào, vì vậy chúng ta không thể xem xét phạm vi như chúng ta đã làm trong Listing 10-17 và 10-18 để xác định liệu tham chiếu chúng ta trả về sẽ luôn hợp lệ hay không. Borrow checker cũng không thể xác định điều này, bởi vì nó không biết lifetimes của xy liên quan đến lifetime của giá trị trả về như thế nào. Để sửa lỗi này, chúng ta sẽ thêm tham số lifetime generic xác định mối quan hệ giữa các tham chiếu để borrow checker có thể thực hiện phân tích của nó.

Cú pháp Chú thích Lifetime

Chú thích lifetime không thay đổi thời gian tồn tại của bất kỳ tham chiếu nào. Thay vào đó, chúng mô tả mối quan hệ của lifetimes của nhiều tham chiếu với nhau mà không ảnh hưởng đến lifetimes. Giống như các hàm có thể chấp nhận bất kỳ kiểu nào khi chữ ký chỉ định một tham số kiểu generic, các hàm có thể chấp nhận tham chiếu với bất kỳ lifetime nào bằng cách chỉ định một tham số lifetime generic.

Chú thích lifetime có cú pháp hơi khác thường: tên của tham số lifetime phải bắt đầu bằng dấu nháy đơn (') và thường đều viết thường và rất ngắn, giống như các kiểu generic. Hầu hết mọi người sử dụng tên 'a cho chú thích lifetime đầu tiên. Chúng ta đặt chú thích tham số lifetime sau dấu & của tham chiếu, sử dụng khoảng cách để phân tách chú thích khỏi kiểu của tham chiếu.

Đây là một số ví dụ: một tham chiếu đến i32 không có tham số lifetime, một tham chiếu đến i32 có tham số lifetime tên 'a, và một tham chiếu có thể thay đổi đến i32 cũng có lifetime 'a.

&i32        // một tham chiếu
&'a i32     // một tham chiếu với lifetime rõ ràng
&'a mut i32 // một tham chiếu có thể thay đổi với lifetime rõ ràng

Một chú thích lifetime tự nó không có nhiều ý nghĩa vì các chú thích được dùng để nói với Rust cách các tham số lifetime generic của nhiều tham chiếu liên quan đến nhau. Hãy xem xét cách chú thích lifetime liên quan đến nhau trong bối cảnh của hàm longest.

Chú thích Lifetime trong Chữ ký Hàm

Để sử dụng chú thích lifetime trong chữ ký hàm, chúng ta cần khai báo các tham số lifetime generic bên trong dấu ngoặc nhọn giữa tên hàm và danh sách tham số, giống như chúng ta đã làm với tham số kiểu generic.

Chúng ta muốn chữ ký thể hiện ràng buộc sau: tham chiếu trả về sẽ hợp lệ miễn là cả hai tham số đều hợp lệ. Đây là mối quan hệ giữa lifetimes của tham số và giá trị trả về. Chúng ta sẽ đặt tên lifetime là 'a và sau đó thêm nó vào mỗi tham chiếu, như trong Listing 10-21.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Mã này nên biên dịch và tạo ra kết quả chúng ta muốn khi chúng ta sử dụng nó với hàm main trong Listing 10-19.

Chữ ký hàm bây giờ nói với Rust rằng đối với một lifetime 'a nào đó, hàm nhận hai tham số, cả hai đều là slice chuỗi sống ít nhất là lifetime 'a. Chữ ký hàm cũng nói với Rust rằng slice chuỗi được trả về từ hàm sẽ sống ít nhất là lifetime 'a. Trong thực tế, điều này có nghĩa là lifetime của tham chiếu được trả về bởi hàm longest giống như lifetime nhỏ hơn của các giá trị được tham chiếu bởi các đối số hàm. Đây là những mối quan hệ mà chúng ta muốn Rust sử dụng khi phân tích mã này.

Hãy nhớ rằng, khi chúng ta chỉ định tham số lifetime trong chữ ký hàm này, chúng ta không thay đổi lifetimes của bất kỳ giá trị nào được truyền vào hoặc trả về. Thay vào đó, chúng ta đang chỉ định rằng borrow checker nên từ chối bất kỳ giá trị nào không tuân theo các ràng buộc này. Lưu ý rằng hàm longest không cần biết chính xác xy sẽ sống bao lâu, chỉ cần một phạm vi có thể được thay thế cho 'a sẽ thỏa mãn chữ ký này.

Khi chú thích lifetimes trong hàm, các chú thích được đặt trong chữ ký hàm, không phải trong thân hàm. Các chú thích lifetime trở thành một phần của hợp đồng của hàm, giống như các kiểu trong chữ ký. Việc có chữ ký hàm chứa hợp đồng lifetime có nghĩa là việc phân tích mà trình biên dịch Rust thực hiện có thể đơn giản hơn. Nếu có vấn đề với cách một hàm được chú thích hoặc cách nó được gọi, lỗi của trình biên dịch có thể chỉ ra phần mã của chúng ta và các ràng buộc chính xác hơn. Nếu, thay vào đó, trình biên dịch Rust đưa ra nhiều suy luận hơn về những gì chúng ta dự định mối quan hệ của lifetimes sẽ như thế nào, trình biên dịch có thể chỉ có khả năng chỉ ra việc sử dụng mã của chúng ta nhiều bước cách xa nguyên nhân của vấn đề.

Khi chúng ta truyền các tham chiếu cụ thể cho longest, lifetime cụ thể được thay thế cho 'a là phần của phạm vi của x trùng với phạm vi của y. Nói cách khác, lifetime generic 'a sẽ nhận lifetime cụ thể bằng với lifetime nhỏ hơn của xy. Bởi vì chúng ta đã chú thích tham chiếu được trả về với cùng tham số lifetime 'a, tham chiếu được trả về cũng sẽ hợp lệ cho độ dài của lifetime nhỏ hơn của xy.

Hãy xem cách các chú thích lifetime hạn chế hàm longest bằng cách truyền vào các tham chiếu có lifetimes cụ thể khác nhau. Listing 10-22 là một ví dụ đơn giản.

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Trong ví dụ này, string1 hợp lệ cho đến khi kết thúc phạm vi ngoài, string2 hợp lệ cho đến khi kết thúc phạm vi trong, và result tham chiếu đến một thứ hợp lệ cho đến khi kết thúc phạm vi trong. Chạy mã này và bạn sẽ thấy rằng borrow checker chấp thuận; nó sẽ biên dịch và in The longest string is long string is long.

Tiếp theo, hãy thử một ví dụ cho thấy rằng lifetime của tham chiếu trong result phải là lifetime nhỏ hơn của hai đối số. Chúng ta sẽ di chuyển khai báo biến result ra ngoài phạm vi trong nhưng để việc gán giá trị cho biến result bên trong phạm vi với string2. Sau đó, chúng ta sẽ di chuyển println! sử dụng result ra ngoài phạm vi trong, sau khi phạm vi trong đã kết thúc. Mã trong Listing 10-23 sẽ không biên dịch.

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Khi chúng ta cố gắng biên dịch mã này, chúng ta nhận được lỗi này:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let string2 = String::from("xyz");
  |             ------- binding `string2` declared here
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {result}");
  |                                     -------- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Lỗi cho thấy rằng để result hợp lệ cho câu lệnh println!, string2 cần hợp lệ cho đến khi kết thúc phạm vi ngoài. Rust biết điều này vì chúng ta đã chú thích lifetimes của tham số hàm và giá trị trả về bằng cùng một tham số lifetime 'a.

Với tư cách là con người, chúng ta có thể nhìn vào mã này và thấy rằng string1 dài hơn string2, và do đó result sẽ chứa một tham chiếu đến string1. Bởi vì string1 chưa ra khỏi phạm vi, một tham chiếu đến string1 vẫn sẽ hợp lệ cho câu lệnh println!. Tuy nhiên, trình biên dịch không thể thấy rằng tham chiếu hợp lệ trong trường hợp này. Chúng ta đã nói với Rust rằng lifetime của tham chiếu được trả về bởi hàm longest giống như lifetime nhỏ hơn của các tham chiếu được truyền vào. Do đó, borrow checker không cho phép mã trong Listing 10-23 vì nó có thể có một tham chiếu không hợp lệ.

Hãy thử thiết kế thêm các thử nghiệm thay đổi giá trị và lifetimes của các tham chiếu được truyền vào hàm longest và cách tham chiếu trả về được sử dụng. Đưa ra các giả thuyết về việc các thử nghiệm của bạn sẽ vượt qua borrow checker trước khi bạn biên dịch; sau đó kiểm tra xem bạn có đúng không!

Suy nghĩ theo Lifetimes

Cách bạn cần chỉ định tham số lifetime phụ thuộc vào những gì hàm của bạn đang làm. Ví dụ, nếu chúng ta thay đổi triển khai của hàm longest để luôn trả về tham số đầu tiên thay vì slice chuỗi dài nhất, chúng ta sẽ không cần chỉ định một lifetime cho tham số y. Mã sau sẽ biên dịch:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

Chúng ta đã chỉ định một tham số lifetime 'a cho tham số x và kiểu trả về, nhưng không cho tham số y, bởi vì lifetime của y không có bất kỳ mối quan hệ nào với lifetime của x hoặc giá trị trả về.

Khi trả về một tham chiếu từ một hàm, tham số lifetime cho kiểu trả về cần phải khớp với tham số lifetime cho một trong các tham số. Nếu tham chiếu được trả về không tham chiếu đến một trong các tham số, thì nó phải tham chiếu đến một giá trị được tạo bên trong hàm này. Tuy nhiên, đây sẽ là một dangling reference vì giá trị sẽ ra khỏi phạm vi khi kết thúc hàm. Hãy xem xét nỗ lực triển khai hàm longest sau đây mà không biên dịch:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

Ở đây, mặc dù chúng ta đã chỉ định một tham số lifetime 'a cho kiểu trả về, nhưng triển khai này sẽ không biên dịch vì lifetime của giá trị trả về không liên quan gì đến lifetime của các tham số. Đây là thông báo lỗi chúng ta nhận được:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

Vấn đề là result ra khỏi phạm vi và bị dọn dẹp vào cuối hàm longest. Chúng ta cũng đang cố gắng trả về một tham chiếu đến result từ hàm. Không có cách nào chúng ta có thể chỉ định tham số lifetime để thay đổi dangling reference, và Rust sẽ không cho phép chúng ta tạo một dangling reference. Trong trường hợp này, giải pháp tốt nhất là trả về một kiểu dữ liệu sở hữu thay vì một tham chiếu để hàm gọi sau đó có trách nhiệm dọn dẹp giá trị.

Cuối cùng, cú pháp lifetime là về việc kết nối lifetimes của các tham số và giá trị trả về của các hàm. Một khi chúng được kết nối, Rust có đủ thông tin để cho phép các hoạt động an toàn bộ nhớ và ngăn chặn các hoạt động có thể tạo ra con trỏ treo hoặc vi phạm an toàn bộ nhớ.

Chú thích Lifetime trong Định nghĩa Struct

Cho đến nay, các struct chúng ta đã định nghĩa đều chứa các kiểu sở hữu. Chúng ta có thể định nghĩa các struct để giữ các tham chiếu, nhưng trong trường hợp đó chúng ta cần thêm một chú thích lifetime trên mỗi tham chiếu trong định nghĩa struct. Listing 10-24 có một struct được gọi là ImportantExcerpt giữ một slice chuỗi.

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Struct này có trường duy nhất part giữ một slice chuỗi, là một tham chiếu. Cũng giống như với các kiểu dữ liệu generic, chúng ta khai báo tên của tham số lifetime generic bên trong dấu ngoặc nhọn sau tên của struct để chúng ta có thể sử dụng tham số lifetime trong thân định nghĩa struct. Chú thích này có nghĩa là một thể hiện của ImportantExcerpt không thể tồn tại lâu hơn tham chiếu mà nó giữ trong trường part.

Hàm main ở đây tạo một thể hiện của struct ImportantExcerpt giữ một tham chiếu đến câu đầu tiên của String được sở hữu bởi biến novel. Dữ liệu trong novel tồn tại trước khi thể hiện ImportantExcerpt được tạo. Ngoài ra, novel không ra khỏi phạm vi cho đến sau khi thể hiện ImportantExcerpt ra khỏi phạm vi, vì vậy tham chiếu trong thể hiện ImportantExcerpt hợp lệ.

Lifetime Elision (Loại bỏ Lifetime)

Bạn đã học rằng mỗi tham chiếu có một lifetime và bạn cần chỉ định tham số lifetime cho các hàm hoặc struct sử dụng tham chiếu. Tuy nhiên, chúng ta đã có một hàm trong Listing 4-9, được hiển thị lại trong Listing 10-25, đã biên dịch mà không có chú thích lifetime.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

Lý do hàm này biên dịch mà không có chú thích lifetime là lịch sử: trong các phiên bản sớm (trước 1.0) của Rust, mã này sẽ không biên dịch vì mỗi tham chiếu cần một lifetime rõ ràng. Vào thời điểm đó, chữ ký hàm sẽ được viết như sau:

fn first_word<'a>(s: &'a str) -> &'a str {

Sau khi viết nhiều mã Rust, nhóm Rust thấy rằng các lập trình viên Rust đang nhập các chú thích lifetime giống nhau lặp đi lặp lại trong các tình huống cụ thể. Các tình huống này có thể dự đoán được và tuân theo một số mẫu xác định. Các nhà phát triển đã lập trình các mẫu này vào mã của trình biên dịch để borrow checker có thể suy ra lifetimes trong các tình huống này và không cần các chú thích rõ ràng.

Phần lịch sử Rust này có liên quan vì có khả năng nhiều mẫu xác định hơn sẽ xuất hiện và được thêm vào trình biên dịch. Trong tương lai, thậm chí có thể cần ít chú thích lifetime hơn.

Các mẫu được lập trình vào phân tích tham chiếu của Rust được gọi là luật loại bỏ lifetime. Đây không phải là luật cho lập trình viên tuân theo; chúng là một tập hợp các trường hợp cụ thể mà trình biên dịch sẽ xem xét, và nếu mã của bạn phù hợp với các trường hợp này, bạn không cần viết lifetimes một cách rõ ràng.

Các luật loại bỏ không cung cấp suy luận hoàn toàn. Nếu vẫn còn sự không rõ ràng về lifetimes mà các tham chiếu có sau khi Rust áp dụng các luật, thì trình biên dịch sẽ không đoán xem lifetime của các tham chiếu còn lại là gì. Thay vì đoán, trình biên dịch sẽ đưa ra một lỗi mà bạn có thể giải quyết bằng cách thêm các chú thích lifetime.

Lifetimes trên tham số hàm hoặc phương thức được gọi là lifetimes đầu vào, và lifetimes trên giá trị trả về được gọi là lifetimes đầu ra.

Trình biên dịch sử dụng ba luật để xác định lifetimes của các tham chiếu khi không có chú thích rõ ràng. Luật đầu tiên áp dụng cho lifetimes đầu vào, và luật thứ hai và thứ ba áp dụng cho lifetimes đầu ra. Nếu trình biên dịch đến cuối ba luật và vẫn còn tham chiếu mà nó không thể xác định lifetimes, trình biên dịch sẽ dừng lại với một lỗi. Các luật này áp dụng cho các định nghĩa fn cũng như các khối impl.

Luật đầu tiên là trình biên dịch gán một tham số lifetime cho mỗi tham số là một tham chiếu. Nói cách khác, một hàm với một tham số nhận một tham số lifetime: fn foo<'a>(x: &'a i32); một hàm với hai tham số nhận hai tham số lifetime riêng biệt: fn foo<'a, 'b>(x: &'a i32, y: &'b i32); và vân vân.

Luật thứ hai là, nếu chỉ có một tham số lifetime đầu vào, thì lifetime đó được gán cho tất cả các tham số lifetime đầu ra: fn foo<'a>(x: &'a i32) -> &'a i32.

Luật thứ ba là, nếu có nhiều tham số lifetime đầu vào, nhưng một trong số đó là &self hoặc &mut self vì đây là một phương thức, thì lifetime của self được gán cho tất cả các tham số lifetime đầu ra. Luật thứ ba này làm cho các phương thức dễ đọc và viết hơn nhiều vì cần ít ký hiệu hơn.

Hãy giả vờ chúng ta là trình biên dịch. Chúng ta sẽ áp dụng các luật này để tìm ra lifetimes của các tham chiếu trong chữ ký của hàm first_word trong Listing 10-25. Chữ ký bắt đầu mà không có bất kỳ lifetime nào được liên kết với các tham chiếu:

fn first_word(s: &str) -> &str {

Sau đó trình biên dịch áp dụng luật đầu tiên, chỉ định rằng mỗi tham số nhận lifetime của riêng nó. Chúng ta sẽ gọi nó là 'a như thường lệ, vì vậy bây giờ chữ ký là như sau:

fn first_word<'a>(s: &'a str) -> &str {

Luật thứ hai áp dụng vì có chính xác một lifetime đầu vào. Luật thứ hai chỉ định rằng lifetime của tham số đầu vào duy nhất được gán cho lifetime đầu ra, vì vậy chữ ký bây giờ là như sau:

fn first_word<'a>(s: &'a str) -> &'a str {

Bây giờ tất cả các tham chiếu trong chữ ký hàm này có lifetimes, và trình biên dịch có thể tiếp tục phân tích mà không cần lập trình viên chú thích lifetimes trong chữ ký hàm này.

Hãy xem một ví dụ khác, lần này sử dụng hàm longest đã không có tham số lifetime khi chúng ta bắt đầu làm việc với nó trong Listing 10-20:

fn longest(x: &str, y: &str) -> &str {

Hãy áp dụng luật đầu tiên: mỗi tham số nhận lifetime của riêng nó. Lần này chúng ta có hai tham số thay vì một, vì vậy chúng ta có hai lifetimes:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

Bạn có thể thấy rằng luật thứ hai không áp dụng vì có nhiều hơn một lifetime đầu vào. Luật thứ ba cũng không áp dụng, vì longest là một hàm chứ không phải là một phương thức, vì vậy không có tham số nào là self. Sau khi làm việc qua tất cả ba luật, chúng ta vẫn chưa tìm ra lifetime của kiểu trả về là gì. Đây là lý do tại sao chúng ta nhận được lỗi khi cố gắng biên dịch mã trong Listing 10-20: trình biên dịch đã làm việc thông qua các luật loại bỏ lifetime nhưng vẫn không thể tìm ra tất cả lifetimes của các tham chiếu trong chữ ký.

Bởi vì luật thứ ba thực sự chỉ áp dụng trong chữ ký phương thức, chúng ta sẽ xem xét lifetimes trong ngữ cảnh đó tiếp theo để thấy tại sao luật thứ ba có nghĩa là chúng ta không phải chú thích lifetimes trong chữ ký phương thức thường xuyên.

Chú thích Lifetime trong Định nghĩa Phương thức

Khi chúng ta triển khai các phương thức trên một struct với lifetimes, chúng ta sử dụng cùng cú pháp như của tham số kiểu generic, như được hiển thị trong Listing 10-11. Nơi chúng ta khai báo và sử dụng các tham số lifetime phụ thuộc vào việc chúng có liên quan đến các trường của struct hay các tham số phương thức và giá trị trả về.

Tên lifetime cho các trường struct luôn cần được khai báo sau từ khóa impl và sau đó được sử dụng sau tên của struct vì những lifetimes đó là một phần của kiểu của struct.

Trong chữ ký phương thức bên trong khối impl, các tham chiếu có thể bị ràng buộc với lifetime của các tham chiếu trong các trường của struct, hoặc chúng có thể độc lập. Ngoài ra, các luật loại bỏ lifetime thường làm cho chú thích lifetime không cần thiết trong chữ ký phương thức. Hãy xem một số ví dụ sử dụng struct có tên ImportantExcerpt mà chúng ta đã định nghĩa trong Listing 10-24.

Đầu tiên, chúng ta sẽ sử dụng một phương thức có tên level có tham số duy nhất là tham chiếu đến self và giá trị trả về là i32, không phải là tham chiếu đến bất kỳ thứ gì:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Khai báo tham số lifetime sau impl và việc sử dụng nó sau tên kiểu là cần thiết, nhưng chúng ta không bắt buộc phải chú thích lifetime của tham chiếu đến self vì luật loại bỏ đầu tiên.

Đây là một ví dụ nơi luật loại bỏ lifetime thứ ba áp dụng:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

Có hai lifetime đầu vào, vì vậy Rust áp dụng luật loại bỏ lifetime đầu tiên và gán cho cả &selfannouncement lifetimes riêng của chúng. Sau đó, bởi vì một trong các tham số là &self, kiểu trả về nhận lifetime của &self, và tất cả lifetimes đã được tính đến.

Static Lifetime

Một lifetime đặc biệt mà chúng ta cần thảo luận là 'static, nó biểu thị rằng tham chiếu bị ảnh hưởng có thể tồn tại trong toàn bộ thời gian của chương trình. Tất cả các literal chuỗi có lifetime 'static, mà chúng ta có thể chú thích như sau:

#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

Văn bản của chuỗi này được lưu trữ trực tiếp trong tệp nhị phân của chương trình, mà luôn có sẵn. Do đó, lifetime của tất cả các literal chuỗi là 'static.

Bạn có thể thấy các gợi ý trong thông báo lỗi để sử dụng lifetime 'static. Nhưng trước khi chỉ định 'static làm lifetime cho một tham chiếu, hãy suy nghĩ về việc liệu tham chiếu bạn có thực sự tồn tại trong toàn bộ lifetime của chương trình hay không, và liệu bạn có muốn nó như vậy hay không. Hầu hết thời gian, một thông báo lỗi gợi ý lifetime 'static là kết quả của việc cố gắng tạo một dangling reference hoặc không khớp của các lifetimes có sẵn. Trong những trường hợp như vậy, giải pháp là sửa những vấn đề đó, không phải chỉ định lifetime 'static.

Tham số kiểu Generic, Trait Bounds và Lifetimes cùng nhau

Hãy xem qua cú pháp của việc chỉ định tham số kiểu generic, trait bounds và lifetimes tất cả trong một hàm!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

Đây là hàm longest từ Listing 10-21 trả về cái dài hơn trong hai slice chuỗi. Nhưng bây giờ nó có một tham số bổ sung có tên ann của kiểu generic T, có thể được điền bởi bất kỳ kiểu nào triển khai trait Display như được chỉ định bởi mệnh đề where. Tham số bổ sung này sẽ được in bằng {}, đó là lý do tại sao ràng buộc trait Display là cần thiết. Bởi vì lifetimes là một loại generic, khai báo của tham số lifetime 'a và tham số kiểu generic T được đặt trong cùng một danh sách bên trong dấu ngoặc nhọn sau tên hàm.

Tóm tắt

Chúng ta đã bao quát nhiều trong chương này! Bây giờ bạn đã biết về tham số kiểu generic, traits và trait bounds, và tham số lifetime generic, bạn đã sẵn sàng để viết mã không trùng lặp hoạt động trong nhiều tình huống khác nhau. Tham số kiểu generic cho phép bạn áp dụng mã cho các kiểu khác nhau. Traits và trait bounds đảm bảo rằng mặc dù các kiểu là generic, chúng sẽ có hành vi mà mã cần. Bạn đã học cách sử dụng chú thích lifetime để đảm bảo rằng mã linh hoạt này sẽ không có bất kỳ dangling reference nào. Và tất cả những phân tích này xảy ra tại thời điểm biên dịch, không ảnh hưởng đến hiệu suất thời gian chạy!

Tin hay không, có còn nhiều điều để học về các chủ đề chúng ta đã thảo luận trong chương này: Chương 18 thảo luận về trait objects, là một cách khác để sử dụng traits. Cũng có các kịch bản phức tạp hơn liên quan đến chú thích lifetime mà bạn sẽ chỉ cần trong các kịch bản rất nâng cao; cho những điều đó, bạn nên đọc Rust Reference. Nhưng tiếp theo, bạn sẽ học cách viết tests trong Rust để đảm bảo mã của bạn hoạt động theo cách mà nó nên làm.

Viết Kiểm Thử Tự Động

Trong bài luận năm 1972 "The Humble Programmer", Edsger W. Dijkstra đã nói rằng "kiểm thử chương trình có thể là một cách rất hiệu quả để phát hiện lỗi, nhưng nó hoàn toàn không đủ để chứng minh sự vắng mặt của chúng." Điều đó không có nghĩa là chúng ta không nên cố gắng kiểm thử nhiều nhất có thể!

Tính đúng đắn trong chương trình của chúng ta là mức độ mà code của chúng ta thực hiện đúng những gì chúng ta muốn. Rust được thiết kế với sự quan tâm lớn đến tính đúng đắn của chương trình, nhưng tính đúng đắn là phức tạp và không dễ để chứng minh. Hệ thống kiểu của Rust gánh vác phần lớn gánh nặng này, nhưng hệ thống kiểu không thể bắt được tất cả. Do đó, Rust bao gồm hỗ trợ để viết các bài kiểm thử phần mềm tự động.

Giả sử chúng ta viết một hàm add_two cộng thêm 2 vào bất kỳ số nào được truyền vào nó. Chữ ký của hàm này chấp nhận một số nguyên làm tham số và trả về một số nguyên làm kết quả. Khi chúng ta triển khai và biên dịch hàm đó, Rust thực hiện tất cả các kiểm tra kiểu và kiểm tra mượn mà bạn đã học cho đến nay để đảm bảo rằng, chẳng hạn, chúng ta không truyền giá trị String hoặc tham chiếu không hợp lệ cho hàm này. Nhưng Rust không thể kiểm tra rằng hàm này sẽ thực hiện chính xác những gì chúng ta dự định, đó là trả về tham số cộng thêm 2 thay vì, ví dụ, tham số cộng thêm 10 hoặc tham số trừ đi 50! Đó là lúc các bài kiểm thử phát huy tác dụng.

Chúng ta có thể viết các bài kiểm thử khẳng định, ví dụ, khi chúng ta truyền 3 vào hàm add_two, giá trị trả về là 5. Chúng ta có thể chạy các bài kiểm thử này bất cứ khi nào chúng ta thay đổi code của mình để đảm bảo rằng bất kỳ hành vi đúng đắn hiện có nào cũng không bị thay đổi.

Kiểm thử là một kỹ năng phức tạp: mặc dù chúng ta không thể đề cập đến mọi chi tiết về cách viết các bài kiểm thử tốt trong một chương, nhưng trong chương này chúng ta sẽ thảo luận về cơ chế của các tiện ích kiểm thử của Rust. Chúng ta sẽ nói về các chú thích và macro có sẵn cho bạn khi viết các bài kiểm thử, hành vi mặc định và các tùy chọn được cung cấp để chạy các bài kiểm thử của bạn, và cách tổ chức các bài kiểm thử thành kiểm thử đơn vị và kiểm thử tích hợp.

Cách Viết Các Bài Kiểm Thử

Các bài kiểm thử trong Rust là các hàm xác minh rằng code không phải kiểm thử đang hoạt động theo cách mong đợi. Thân hàm của các bài kiểm thử thường thực hiện ba hành động sau:

  • Thiết lập dữ liệu hoặc trạng thái cần thiết.
  • Chạy code bạn muốn kiểm thử.
  • Khẳng định rằng kết quả là những gì bạn mong đợi.

Hãy xem xét các tính năng Rust cung cấp đặc biệt để viết các bài kiểm thử thực hiện các hành động này, bao gồm thuộc tính test, một số macro và thuộc tính should_panic.

Cấu Trúc của Một Hàm Kiểm Thử

Ở dạng đơn giản nhất, một bài kiểm thử trong Rust là một hàm được chú thích với thuộc tính test. Các thuộc tính là siêu dữ liệu về các phần code Rust; một ví dụ là thuộc tính derive mà chúng ta đã sử dụng với các cấu trúc trong Chương 5. Để biến một hàm thành hàm kiểm thử, thêm #[test] vào dòng trước fn. Khi bạn chạy các bài kiểm thử với lệnh cargo test, Rust xây dựng một tệp thực thi chạy kiểm thử để chạy các hàm được chú thích và báo cáo xem từng hàm kiểm thử có thành công hay không.

Bất cứ khi nào chúng ta tạo một dự án thư viện mới với Cargo, một module kiểm thử với một hàm kiểm thử trong đó được tự động tạo ra cho chúng ta. Module này cung cấp cho bạn một mẫu để viết các bài kiểm thử của bạn, vì vậy bạn không phải tìm kiếm cấu trúc và cú pháp chính xác mỗi khi bạn bắt đầu một dự án mới. Bạn có thể thêm bất kỳ hàm kiểm thử bổ sung nào và bất kỳ module kiểm thử nào mà bạn muốn!

Chúng ta sẽ khám phá một số khía cạnh về cách các bài kiểm thử hoạt động bằng cách thử nghiệm với mẫu kiểm thử trước khi chúng ta thực sự kiểm thử bất kỳ mã nào. Sau đó, chúng ta sẽ viết một số bài kiểm thử thực tế gọi một số code mà chúng ta đã viết và khẳng định rằng hành vi của nó là chính xác.

Hãy tạo một dự án thư viện mới có tên adder sẽ cộng hai số:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

Nội dung của tệp src/lib.rs trong thư viện adder của bạn sẽ trông giống như Listing 11-1.

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Tệp bắt đầu với một ví dụ hàm add, để chúng ta có thứ gì đó để kiểm thử.

Bây giờ, chúng ta hãy tập trung duy nhất vào hàm it_works. Lưu ý chú thích #[test]: thuộc tính này chỉ ra rằng đây là một hàm kiểm thử, vì vậy trình chạy kiểm thử biết xử lý hàm này như một bài kiểm thử. Chúng ta cũng có thể có các hàm không phải kiểm thử trong module tests để giúp thiết lập các kịch bản chung hoặc thực hiện các thao tác chung, vì vậy chúng ta luôn cần chỉ ra những hàm nào là kiểm thử.

Thân hàm ví dụ sử dụng vĩ lệnh assert_eq! để khẳng định rằng result, là kết quả của việc gọi hàm add với 2 và 2, bằng 4. Khẳng định này đóng vai trò như một ví dụ về định dạng cho một bài kiểm thử điển hình. Hãy chạy nó để xem bài kiểm thử này có thành công hay không.

Lệnh cargo test chạy tất cả các bài kiểm thử trong dự án của chúng ta, như được hiển thị trong Listing 11-2.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo đã biên dịch và chạy bài kiểm thử. Chúng ta thấy dòng running 1 test. Dòng tiếp theo hiển thị tên của hàm kiểm thử được tạo ra, gọi là tests::it_works, và kết quả chạy bài kiểm thử đó là ok. Tóm lược tổng thể test result: ok. có nghĩa là tất cả các bài kiểm thử đều thành công, và phần đọc 1 passed; 0 failed tổng hợp số bài kiểm thử đã thành công hoặc thất bại.

Có thể đánh dấu một bài kiểm thử là bị bỏ qua để nó không chạy trong một trường hợp cụ thể; chúng ta sẽ đề cập điều này trong phần "Bỏ Qua Một Số Bài Kiểm Thử Trừ Khi Có Yêu Cầu Cụ Thể" sau này trong chương này. Vì chúng ta chưa làm điều đó ở đây, tóm lược hiển thị 0 ignored. Chúng ta cũng có thể truyền một đối số cho lệnh cargo test để chỉ chạy các bài kiểm thử có tên khớp với một chuỗi; điều này được gọi là lọc, và chúng ta sẽ đề cập đến nó trong phần "Chạy Một Tập Con của Các Bài Kiểm Thử theo Tên". Ở đây, chúng ta chưa lọc các bài kiểm thử đang chạy, vì vậy phần cuối của tóm lược hiển thị 0 filtered out.

Chỉ số 0 measured là dành cho các bài kiểm thử chuẩn đo lường hiệu suất. Các bài kiểm thử chuẩn, tại thời điểm viết bài này, chỉ có sẵn trong Rust nightly. Xem tài liệu về các bài kiểm thử chuẩn để tìm hiểu thêm.

Phần tiếp theo của kết quả kiểm thử bắt đầu từ Doc-tests adder là dành cho kết quả của bất kỳ bài kiểm thử tài liệu nào. Chúng ta chưa có bài kiểm thử tài liệu nào, nhưng Rust có thể biên dịch bất kỳ ví dụ code nào xuất hiện trong tài liệu API của chúng ta. Tính năng này giúp duy trì tài liệu và code của bạn đồng bộ! Chúng ta sẽ thảo luận về cách viết các bài kiểm thử tài liệu trong phần "Bình Luận Tài Liệu như Các Bài Kiểm Thử" của Chương 14. Hiện tại, chúng ta sẽ bỏ qua kết quả Doc-tests.

Hãy bắt đầu tùy chỉnh bài kiểm thử theo nhu cầu của riêng mình. Đầu tiên, thay đổi tên của hàm it_works thành một cái tên khác, chẳng hạn như exploration, như sau:

Tên tệp: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Sau đó chạy cargo test lần nữa. Kết quả bây giờ hiển thị exploration thay vì it_works:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Bây giờ chúng ta sẽ thêm một bài kiểm thử khác, nhưng lần này chúng ta sẽ tạo một bài kiểm thử thất bại! Các bài kiểm thử thất bại khi có gì đó trong hàm kiểm thử hoảng loạn (panic). Mỗi bài kiểm thử được chạy trong một thread mới, và khi thread chính thấy rằng một thread kiểm thử đã chết, bài kiểm thử được đánh dấu là đã thất bại. Trong Chương 9, chúng ta đã nói về cách đơn giản nhất để hoảng loạn là gọi vĩ lệnh panic!. Hãy nhập bài kiểm thử mới dưới dạng một hàm có tên là another, để tệp src/lib.rs của bạn trông giống như Listing 11-3.

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

Chạy các bài kiểm thử một lần nữa bằng cargo test. Kết quả sẽ trông giống như Listing 11-4, cho thấy bài kiểm thử exploration của chúng ta đã thành công và bài kiểm thử another đã thất bại.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Thay vì ok, dòng test tests::another hiển thị FAILED. Hai phần mới xuất hiện giữa các kết quả cá nhân và tóm lược: phần đầu tiên hiển thị lý do chi tiết cho mỗi bài kiểm thử thất bại. Trong trường hợp này, chúng ta nhận được chi tiết rằng another đã thất bại vì nó panicked at 'Make this test fail' trên dòng 17 trong tệp src/lib.rs. Phần tiếp theo chỉ liệt kê tên của tất cả các bài kiểm thử thất bại, điều này hữu ích khi có nhiều bài kiểm thử và nhiều kết quả kiểm thử thất bại chi tiết. Chúng ta có thể sử dụng tên của bài kiểm thử thất bại để chỉ chạy bài kiểm thử đó để gỡ lỗi dễ dàng hơn; chúng ta sẽ nói thêm về cách chạy các bài kiểm thử trong phần "Kiểm Soát Cách Chạy Các Bài Kiểm Thử".

Dòng tóm lược hiển thị ở cuối: nhìn chung, kết quả kiểm thử của chúng ta là FAILED. Chúng ta có một bài kiểm thử thành công và một bài kiểm thử thất bại.

Bây giờ bạn đã thấy kết quả kiểm thử trông như thế nào trong các tình huống khác nhau, hãy xem các vĩ lệnh khác ngoài panic! hữu ích trong các bài kiểm thử.

Kiểm Tra Kết Quả với Vĩ Lệnh assert!

Vĩ lệnh assert!, được cung cấp bởi thư viện chuẩn, hữu ích khi bạn muốn đảm bảo rằng một số điều kiện trong bài kiểm thử được đánh giá là true. Chúng ta cung cấp cho vĩ lệnh assert! một đối số được đánh giá thành một giá trị Boolean. Nếu giá trị là true, không có gì xảy ra và bài kiểm thử thành công. Nếu giá trị là false, vĩ lệnh assert! gọi panic! để khiến bài kiểm thử thất bại. Việc sử dụng vĩ lệnh assert! giúp chúng ta kiểm tra xem code của mình có hoạt động theo cách chúng ta dự định hay không.

Trong Chương 5, Listing 5-15, chúng ta đã sử dụng cấu trúc Rectangle và phương thức can_hold, được lặp lại ở đây trong Listing 11-5. Hãy đặt code này vào tệp src/lib.rs, sau đó viết một số bài kiểm thử cho nó bằng vĩ lệnh assert!.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

Phương thức can_hold trả về một giá trị Boolean, điều này có nghĩa là nó là một trường hợp sử dụng hoàn hảo cho vĩ lệnh assert!. Trong Listing 11-6, chúng ta viết một bài kiểm thử thực hiện phương thức can_hold bằng cách tạo một instance Rectangle có chiều rộng là 8 và chiều cao là 7 và khẳng định rằng nó có thể chứa một instance Rectangle khác có chiều rộng là 5 và chiều cao là 1.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

Lưu ý dòng use super::*; bên trong module tests. Module tests là một module bình thường tuân theo các quy tắc khả kiến thông thường mà chúng ta đã đề cập trong Chương 7 ở phần "Đường Dẫn cho Việc Tham Chiếu đến Một Mục trong Cây Module". Vì module tests là một module bên trong, chúng ta cần đưa code đang được kiểm thử trong module bên ngoài vào phạm vi của module bên trong. Chúng ta sử dụng glob ở đây, vì vậy bất cứ thứ gì chúng ta xác định trong module bên ngoài đều có sẵn cho module tests này.

Chúng ta đã đặt tên cho bài kiểm thử của mình là larger_can_hold_smaller, và chúng ta đã tạo hai instance Rectangle mà chúng ta cần. Sau đó, chúng ta gọi vĩ lệnh assert! và truyền cho nó kết quả của việc gọi larger.can_hold(&smaller). Biểu thức này được cho là trả về true, vì vậy bài kiểm thử của chúng ta sẽ thành công. Hãy tìm hiểu!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Nó đã thành công! Hãy thêm một bài kiểm thử khác, lần này khẳng định rằng một hình chữ nhật nhỏ hơn không thể chứa một hình chữ nhật lớn hơn:

Tên tệp: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Vì kết quả đúng của hàm can_hold trong trường hợp này là false, chúng ta cần phủ định kết quả đó trước khi truyền nó cho vĩ lệnh assert!. Kết quả là, bài kiểm thử của chúng ta sẽ thành công nếu can_hold trả về false:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Hai bài kiểm thử đều thành công! Bây giờ, hãy xem điều gì xảy ra với kết quả kiểm thử của chúng ta khi chúng ta đưa vào một lỗi trong code. Chúng ta sẽ thay đổi triển khai của phương thức can_hold bằng cách thay thế dấu lớn hơn bằng dấu nhỏ hơn khi nó so sánh chiều rộng:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

Chạy các bài kiểm thử bây giờ sẽ cho kết quả sau:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Các bài kiểm thử của chúng ta đã phát hiện ra lỗi! Bởi vì larger.width8smaller.width5, phép so sánh chiều rộng trong can_hold bây giờ trả về false: 8 không nhỏ hơn 5.

Kiểm Tra Sự Bằng Nhau với Các Vĩ Lệnh assert_eq!assert_ne!

Một cách phổ biến để xác minh chức năng là kiểm tra sự bằng nhau giữa kết quả của code đang được kiểm thử và giá trị bạn mong đợi code trả về. Bạn có thể làm điều này bằng cách sử dụng vĩ lệnh assert! và truyền cho nó một biểu thức sử dụng toán tử ==. Tuy nhiên, đây là một bài kiểm thử rất phổ biến mà thư viện chuẩn cung cấp một cặp vĩ lệnh — assert_eq!assert_ne!—để thực hiện bài kiểm thử này một cách thuận tiện hơn. Các vĩ lệnh này so sánh hai đối số xem có bằng nhau hoặc không bằng nhau, tương ứng. Chúng cũng sẽ in ra hai giá trị nếu khẳng định thất bại, điều này giúp dễ dàng thấy tại sao bài kiểm thử thất bại; ngược lại, vĩ lệnh assert! chỉ chỉ ra rằng nó nhận được giá trị false cho biểu thức ==, mà không in ra các giá trị dẫn đến giá trị false.

Trong Listing 11-7, chúng ta viết một hàm có tên add_two cộng thêm 2 vào tham số của nó, sau đó chúng ta kiểm thử hàm này bằng vĩ lệnh assert_eq!.

pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

Hãy kiểm tra xem nó có thành công không!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Chúng ta tạo một biến có tên result lưu trữ kết quả của việc gọi add_two(2). Sau đó, chúng ta truyền result4 làm đối số cho assert_eq!. Dòng kết quả cho bài kiểm thử này là test tests::it_adds_two ... ok, và văn bản ok chỉ ra rằng bài kiểm thử của chúng ta đã thành công!

Hãy đưa một lỗi vào code của chúng ta để xem assert_eq! trông như thế nào khi nó thất bại. Thay đổi triển khai của hàm add_two để cộng thêm 3 thay vì 2:

pub fn add_two(a: usize) -> usize {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

Chạy các bài kiểm thử một lần nữa:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Bài kiểm thử của chúng ta đã phát hiện lỗi! Bài kiểm thử it_adds_two đã thất bại, và thông báo cho chúng ta biết rằng khẳng định thất bại là assertion `left == right` failed và giá trị leftright là gì. Thông báo này giúp chúng ta bắt đầu gỡ lỗi: đối số left, nơi chúng ta có kết quả của việc gọi add_two(2), là 5 nhưng đối số right4. Bạn có thể tưởng tượng điều này sẽ đặc biệt hữu ích khi chúng ta có nhiều bài kiểm thử đang diễn ra.

Lưu ý rằng trong một số ngôn ngữ và framework kiểm thử, các tham số cho hàm khẳng định về sự bằng nhau được gọi là expectedactual, và thứ tự mà chúng ta chỉ định các đối số có vấn đề. Tuy nhiên, trong Rust, chúng được gọi là leftright, và thứ tự mà chúng ta chỉ định giá trị mà chúng ta mong đợi và giá trị mà code tạo ra không quan trọng. Chúng ta có thể viết khẳng định trong bài kiểm thử này là assert_eq!(add_two(2), result), điều này sẽ dẫn đến thông báo lỗi tương tự hiển thị assertion failed: `(left == right)`.

Vĩ lệnh assert_ne! sẽ thành công nếu hai giá trị chúng ta cung cấp cho nó không bằng nhau và thất bại nếu chúng bằng nhau. Vĩ lệnh này hữu ích nhất cho các trường hợp khi chúng ta không chắc giá trị sẽ là gì, nhưng chúng ta biết giá trị đó chắc chắn không nên là gì. Ví dụ, nếu chúng ta đang kiểm thử một hàm được đảm bảo sẽ thay đổi đầu vào của nó theo cách nào đó, nhưng cách thức đầu vào được thay đổi phụ thuộc vào ngày trong tuần mà chúng ta chạy các bài kiểm thử, điều tốt nhất để khẳng định có thể là kết quả của hàm không bằng đầu vào.

Dưới bề mặt, các vĩ lệnh assert_eq!assert_ne! sử dụng các toán tử ==!=, tương ứng. Khi các khẳng định thất bại, các vĩ lệnh này in ra các đối số của chúng bằng định dạng debug, điều này có nghĩa là các giá trị đang được so sánh phải triển khai các trait PartialEqDebug. Tất cả các kiểu nguyên thủy và hầu hết các kiểu thư viện chuẩn đều triển khai các trait này. Đối với các cấu trúc và enum mà bạn tự định nghĩa, bạn cần triển khai PartialEq để khẳng định bằng nhau cho các kiểu này. Bạn cũng cần triển khai Debug để in ra các giá trị khi khẳng định thất bại. Bởi vì cả hai trait đều là các trait có thể dẫn xuất, như đã đề cập trong Listing 5-12 của Chương 5, điều này thường đơn giản như việc thêm chú thích #[derive(PartialEq, Debug)] vào định nghĩa cấu trúc hoặc enum của bạn. Xem Phụ lục C, "Các Trait Có Thể Dẫn Xuất," để biết thêm chi tiết về các trait này và các trait có thể dẫn xuất khác.

Thêm Thông Báo Thất Bại Tùy Chỉnh

Bạn cũng có thể thêm một thông báo tùy chỉnh để hiển thị với thông báo thất bại làm đối số tùy chọn cho các vĩ lệnh assert!, assert_eq!assert_ne!. Bất kỳ đối số nào được chỉ định sau các đối số bắt buộc đều được chuyển đến vĩ lệnh format! (được thảo luận trong "Nối Chuỗi với Toán Tử + hoặc Vĩ Lệnh format!" ở Chương 8), vì vậy bạn có thể truyền một chuỗi định dạng chứa các placeholder {} và các giá trị để điền vào các placeholder đó. Các thông báo tùy chỉnh hữu ích để ghi lại ý nghĩa của khẳng định; khi một bài kiểm thử thất bại, bạn sẽ có ý tưởng tốt hơn về vấn đề trong code.

Ví dụ, giả sử chúng ta có một hàm chào hỏi mọi người theo tên và chúng ta muốn kiểm tra rằng tên mà chúng ta truyền vào hàm xuất hiện trong kết quả:

Tên tệp: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Các yêu cầu cho chương trình này chưa được thống nhất, và chúng ta khá chắc chắn rằng văn bản Hello ở đầu lời chào sẽ thay đổi. Chúng ta quyết định không muốn phải cập nhật bài kiểm thử khi các yêu cầu thay đổi, vì vậy thay vì kiểm tra sự bằng nhau chính xác với giá trị trả về từ hàm greeting, chúng ta sẽ chỉ khẳng định rằng kết quả chứa văn bản của tham số đầu vào.

Bây giờ hãy đưa một lỗi vào code này bằng cách thay đổi greeting để loại bỏ name để xem lỗi kiểm thử mặc định trông như thế nào:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

Chạy bài kiểm thử này sẽ cho kết quả sau:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Kết quả này chỉ chỉ ra rằng khẳng định đã thất bại và dòng nào khẳng định đó. Một thông báo lỗi hữu ích hơn sẽ in ra giá trị từ hàm greeting. Hãy thêm một thông báo lỗi tùy chỉnh bao gồm một chuỗi định dạng với một placeholder được điền bằng giá trị thực tế mà chúng ta nhận được từ hàm greeting:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

Bây giờ khi chúng ta chạy bài kiểm thử, chúng ta sẽ nhận được một thông báo lỗi thông tin hơn:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Chúng ta có thể thấy giá trị mà chúng ta thực sự nhận được trong kết quả kiểm thử, điều này sẽ giúp chúng ta gỡ lỗi những gì đã xảy ra thay vì những gì chúng ta mong đợi sẽ xảy ra.

Kiểm Tra Hoảng Loạn với should_panic

Ngoài việc kiểm tra giá trị trả về, điều quan trọng là phải kiểm tra rằng code của chúng ta xử lý các tình huống lỗi như mong đợi. Ví dụ, hãy xem xét kiểu Guess mà chúng ta đã tạo trong Chương 9, Listing 9-13. Các code khác sử dụng Guess phụ thuộc vào sự đảm bảo rằng các instance Guess sẽ chỉ chứa các giá trị từ 1 đến 100. Chúng ta có thể viết một bài kiểm thử để đảm bảo rằng việc cố gắng tạo một instance Guess với một giá trị nằm ngoài phạm vi đó sẽ hoảng loạn.

Chúng ta làm điều này bằng cách thêm thuộc tính should_panic vào hàm kiểm thử của mình. Bài kiểm thử thành công nếu code bên trong hàm hoảng loạn; bài kiểm thử thất bại nếu code bên trong hàm không hoảng loạn.

Listing 11-8 cho thấy một bài kiểm thử kiểm tra rằng các điều kiện lỗi của Guess::new xảy ra khi chúng ta mong đợi.

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Chúng ta đặt thuộc tính #[should_panic] sau thuộc tính #[test] và trước hàm kiểm thử mà nó áp dụng. Hãy xem kết quả khi bài kiểm thử này thành công:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Trông tốt! Bây giờ hãy đưa một lỗi vào code của chúng ta bằng cách loại bỏ điều kiện mà hàm new sẽ hoảng loạn nếu giá trị lớn hơn 100:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Khi chúng ta chạy bài kiểm thử trong Listing 11-8, nó sẽ thất bại:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Chúng ta không nhận được thông báo rất hữu ích trong trường hợp này, nhưng khi chúng ta nhìn vào hàm kiểm thử, chúng ta thấy nó được chú thích với #[should_panic]. Lỗi mà chúng ta nhận được có nghĩa là code trong hàm kiểm thử không gây ra hoảng loạn.

Các bài kiểm thử sử dụng should_panic có thể không chính xác. Một bài kiểm thử should_panic sẽ thành công ngay cả khi bài kiểm thử hoảng loạn vì một lý do khác với lý do chúng ta mong đợi. Để làm cho các bài kiểm thử should_panic chính xác hơn, chúng ta có thể thêm một tham số expected tùy chọn vào thuộc tính should_panic. Công cụ kiểm thử sẽ đảm bảo rằng thông báo lỗi chứa văn bản được cung cấp. Ví dụ, hãy xem xét code đã sửa đổi cho Guess trong Listing 11-9, trong đó hàm new hoảng loạn với các thông báo khác nhau tùy thuộc vào việc giá trị quá nhỏ hay quá lớn.

pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Bài kiểm thử này sẽ thành công vì giá trị mà chúng ta đặt trong tham số expected của thuộc tính should_panic là một chuỗi con của thông báo mà hàm Guess::new hoảng loạn. Chúng ta có thể đã chỉ định toàn bộ thông báo hoảng loạn mà chúng ta mong đợi, trong trường hợp này sẽ là Guess value must be less than or equal to 100, got 200. Những gì bạn chọn để chỉ định phụ thuộc vào phần nào của thông báo hoảng loạn là duy nhất hoặc động và mức độ chính xác bạn muốn cho bài kiểm thử của mình. Trong trường hợp này, một chuỗi con của thông báo hoảng loạn là đủ để đảm bảo rằng code trong hàm kiểm thử thực thi trường hợp else if value > 100.

Để xem điều gì xảy ra khi một bài kiểm thử should_panic với một thông báo expected thất bại, hãy một lần nữa đưa một lỗi vào code của chúng ta bằng cách hoán đổi các thân của khối if value < 1else if value > 100:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Lần này khi chúng ta chạy bài kiểm thử should_panic, nó sẽ thất bại:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Thông báo lỗi cho biết rằng bài kiểm thử này thực sự hoảng loạn như chúng ta mong đợi, nhưng thông báo hoảng loạn không chứa chuỗi mong đợi less than or equal to 100. Thông báo hoảng loạn mà chúng ta nhận được trong trường hợp này là Guess value must be greater than or equal to 1, got 200. Bây giờ chúng ta có thể bắt đầu tìm ra lỗi ở đâu!

Sử Dụng Result<T, E> trong Các Bài Kiểm Thử

Cho đến nay, các bài kiểm thử của chúng ta đều hoảng loạn khi chúng thất bại. Chúng ta cũng có thể viết các bài kiểm thử sử dụng Result<T, E>! Đây là bài kiểm thử từ Listing 11-1, được viết lại để sử dụng Result<T, E> và trả về một Err thay vì hoảng loạn:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

Hàm it_works bây giờ có kiểu trả về là Result<(), String>. Trong thân hàm, thay vì gọi vĩ lệnh assert_eq!, chúng ta trả về Ok(()) khi bài kiểm thử thành công và một Err với một String bên trong khi bài kiểm thử thất bại.

Viết các bài kiểm thử sao cho chúng trả về Result<T, E> cho phép bạn sử dụng toán tử dấu hỏi trong thân các bài kiểm thử, điều này có thể là một cách thuận tiện để viết các bài kiểm thử nên thất bại nếu bất kỳ thao tác nào trong chúng trả về biến thể Err.

Bạn không thể sử dụng chú thích #[should_panic] trên các bài kiểm thử sử dụng Result<T, E>. Để khẳng định rằng một thao tác trả về biến thể Err, đừng sử dụng toán tử dấu hỏi trên giá trị Result<T, E>. Thay vào đó, hãy sử dụng assert!(value.is_err()).

Bây giờ bạn đã biết một số cách để viết các bài kiểm thử, hãy xem những gì xảy ra khi chúng ta chạy các bài kiểm thử của mình và khám phá các tùy chọn khác nhau mà chúng ta có thể sử dụng với cargo test.

Kiểm Soát Cách Chạy Các Bài Kiểm Thử

Giống như cargo run biên dịch code của bạn và sau đó chạy tệp nhị phân kết quả, cargo test biên dịch code của bạn ở chế độ kiểm thử và chạy tệp nhị phân kiểm thử kết quả. Hành vi mặc định của tệp nhị phân được tạo ra bởi cargo test là chạy tất cả các bài kiểm thử song song và thu thập kết quả được tạo ra trong quá trình chạy kiểm thử, ngăn chặn kết quả được hiển thị và làm cho việc đọc kết quả liên quan đến kết quả kiểm thử dễ dàng hơn. Tuy nhiên, bạn có thể chỉ định các tùy chọn dòng lệnh để thay đổi hành vi mặc định này.

Một số tùy chọn dòng lệnh được chuyển tới cargo test, và một số được chuyển tới tệp nhị phân kiểm thử kết quả. Để phân tách hai loại đối số này, bạn liệt kê các đối số chuyển tới cargo test theo sau là dấu phân tách -- và sau đó là các đối số chuyển tới tệp nhị phân kiểm thử. Chạy cargo test --help hiển thị các tùy chọn bạn có thể sử dụng với cargo test, và chạy cargo test -- --help hiển thị các tùy chọn bạn có thể sử dụng sau dấu phân tách. Những tùy chọn đó cũng được ghi lại trong phần "Tests" của sách rustc.

Chạy Các Bài Kiểm Thử Song Song hoặc Tuần Tự

Khi bạn chạy nhiều bài kiểm thử, theo mặc định chúng chạy song song sử dụng các thread, có nghĩa là chúng chạy xong nhanh hơn và bạn nhận được phản hồi nhanh hơn. Vì các bài kiểm thử đang chạy cùng lúc, bạn phải đảm bảo rằng các bài kiểm thử của bạn không phụ thuộc vào nhau hoặc vào bất kỳ trạng thái chung nào, bao gồm cả môi trường chung, chẳng hạn như thư mục làm việc hiện tại hoặc biến môi trường.

Ví dụ, giả sử mỗi bài kiểm thử của bạn chạy một số code tạo ra một tệp trên đĩa có tên test-output.txt và viết một số dữ liệu vào tệp đó. Sau đó, mỗi bài kiểm thử đọc dữ liệu trong tệp đó và khẳng định rằng tệp chứa một giá trị cụ thể, khác nhau trong mỗi bài kiểm thử. Vì các bài kiểm thử chạy cùng lúc, một bài kiểm thử có thể ghi đè lên tệp trong khoảng thời gian giữa một bài kiểm thử khác đang viết và đọc tệp. Bài kiểm thử thứ hai sẽ thất bại, không phải vì code không đúng mà vì các bài kiểm thử đã can thiệp vào nhau trong quá trình chạy song song. Một giải pháp là đảm bảo mỗi bài kiểm thử ghi vào một tệp khác nhau; giải pháp khác là chạy các bài kiểm thử từng cái một.

Nếu bạn không muốn chạy các bài kiểm thử song song hoặc nếu bạn muốn kiểm soát chi tiết hơn về số lượng thread được sử dụng, bạn có thể gửi cờ --test-threads và số thread bạn muốn sử dụng cho tệp nhị phân kiểm thử. Hãy xem ví dụ sau:

$ cargo test -- --test-threads=1

Chúng ta đặt số thread kiểm thử thành 1, báo cho chương trình không sử dụng bất kỳ tính năng song song nào. Chạy các bài kiểm thử bằng một thread sẽ mất nhiều thời gian hơn so với chạy chúng song song, nhưng các bài kiểm thử sẽ không can thiệp vào nhau nếu chúng chia sẻ trạng thái.

Hiển Thị Kết Quả Hàm

Theo mặc định, nếu một bài kiểm thử thành công, thư viện kiểm thử của Rust sẽ thu thập bất kỳ thứ gì được in ra đầu ra tiêu chuẩn. Ví dụ, nếu chúng ta gọi println! trong một bài kiểm thử và bài kiểm thử thành công, chúng ta sẽ không thấy kết quả println! trong terminal; chúng ta sẽ chỉ thấy dòng cho biết bài kiểm thử đã thành công. Nếu một bài kiểm thử thất bại, chúng ta sẽ thấy bất cứ thứ gì đã được in ra đầu ra tiêu chuẩn cùng với phần còn lại của thông báo lỗi.

Ví dụ, Listing 11-10 có một hàm ngớ ngẩn in ra giá trị của tham số và trả về 10, cũng như một bài kiểm thử thành công và một bài kiểm thử thất bại.

fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}

Khi chúng ta chạy các bài kiểm thử này với cargo test, chúng ta sẽ thấy kết quả sau:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Lưu ý rằng không có chỗ nào trong kết quả này chúng ta thấy I got the value 4, được in ra khi bài kiểm thử thành công chạy. Kết quả đó đã bị thu thập. Kết quả từ bài kiểm thử thất bại, I got the value 8, xuất hiện trong phần tóm tắt kết quả kiểm thử, phần này cũng hiển thị nguyên nhân của việc thất bại kiểm thử.

Nếu chúng ta muốn thấy các giá trị được in ra cho các bài kiểm thử thành công, chúng ta có thể báo cho Rust hiển thị kết quả của các bài kiểm thử thành công bằng --show-output:

$ cargo test -- --show-output

Khi chúng ta chạy lại các bài kiểm thử trong Listing 11-10 với cờ --show-output, chúng ta thấy kết quả sau:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Chạy Một Tập Con Các Bài Kiểm Thử Theo Tên

Đôi khi, việc chạy một bộ kiểm thử đầy đủ có thể mất nhiều thời gian. Nếu bạn đang làm việc trên code trong một khu vực cụ thể, bạn có thể muốn chỉ chạy các bài kiểm thử liên quan đến code đó. Bạn có thể chọn các bài kiểm thử để chạy bằng cách truyền cho cargo test tên hoặc các tên của (các) bài kiểm thử bạn muốn chạy làm đối số.

Để minh họa cách chạy một tập con các bài kiểm thử, trước tiên chúng ta sẽ tạo ba bài kiểm thử cho hàm add_two của chúng ta, như được hiển thị trong Listing 11-11, và chọn những bài nào để chạy.

pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}

Nếu chúng ta chạy các bài kiểm thử mà không truyền bất kỳ đối số nào, như chúng ta đã thấy trước đây, tất cả các bài kiểm thử sẽ chạy song song:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Chạy Các Bài Kiểm Thử Đơn Lẻ

Chúng ta có thể truyền tên của bất kỳ hàm kiểm thử nào cho cargo test để chỉ chạy bài kiểm thử đó:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

Chỉ có bài kiểm thử có tên one_hundred được chạy; hai bài kiểm thử còn lại không khớp với tên đó. Kết quả kiểm thử cho chúng ta biết rằng chúng ta có nhiều bài kiểm thử không được chạy bằng cách hiển thị 2 filtered out ở cuối.

Chúng ta không thể chỉ định tên của nhiều bài kiểm thử theo cách này; chỉ có giá trị đầu tiên được cung cấp cho cargo test sẽ được sử dụng. Nhưng có một cách để chạy nhiều bài kiểm thử.

Lọc Để Chạy Nhiều Bài Kiểm Thử

Chúng ta có thể chỉ định một phần của tên bài kiểm thử, và bất kỳ bài kiểm thử nào có tên khớp với giá trị đó sẽ được chạy. Ví dụ, vì hai trong số các bài kiểm thử của chúng ta có tên chứa add, chúng ta có thể chạy hai bài đó bằng cách chạy cargo test add:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Lệnh này đã chạy tất cả các bài kiểm thử có add trong tên và lọc ra bài kiểm thử có tên one_hundred. Cũng lưu ý rằng module mà bài kiểm thử xuất hiện trở thành một phần của tên bài kiểm thử, vì vậy chúng ta có thể chạy tất cả các bài kiểm thử trong một module bằng cách lọc theo tên của module.

Bỏ Qua Một Số Bài Kiểm Thử Trừ Khi Có Yêu Cầu Cụ Thể

Đôi khi một số bài kiểm thử cụ thể có thể tốn rất nhiều thời gian để thực hiện, vì vậy bạn có thể muốn loại trừ chúng trong hầu hết các lần chạy cargo test. Thay vì liệt kê làm đối số tất cả các bài kiểm thử bạn muốn chạy, bạn có thể thay vào đó chú thích các bài kiểm thử tốn thời gian bằng thuộc tính ignore để loại trừ chúng, như được hiển thị ở đây:

Tên tệp: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

Sau #[test], chúng ta thêm dòng #[ignore] cho bài kiểm thử mà chúng ta muốn loại trừ. Bây giờ khi chúng ta chạy các bài kiểm thử, it_works chạy, nhưng expensive_test thì không:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Hàm expensive_test được liệt kê là ignored (bị bỏ qua). Nếu chúng ta chỉ muốn chạy các bài kiểm thử bị bỏ qua, chúng ta có thể sử dụng cargo test -- --ignored:

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Bằng cách kiểm soát các bài kiểm thử được chạy, bạn có thể đảm bảo rằng kết quả cargo test của bạn sẽ được trả về nhanh chóng. Khi bạn ở một điểm mà việc kiểm tra kết quả của các bài kiểm thử ignored có ý nghĩa và bạn có thời gian để chờ đợi kết quả, bạn có thể chạy cargo test -- --ignored thay thế. Nếu bạn muốn chạy tất cả các bài kiểm thử cho dù chúng có bị bỏ qua hay không, bạn có thể chạy cargo test -- --include-ignored.

Tổ Chức Kiểm Thử

Như đã đề cập ở đầu chương, kiểm thử là một lĩnh vực phức tạp, và nhiều người sử dụng thuật ngữ và tổ chức khác nhau. Cộng đồng Rust nghĩ về kiểm thử theo hai loại chính: kiểm thử đơn vị và kiểm thử tích hợp. Kiểm thử đơn vị nhỏ và tập trung hơn, kiểm thử một module riêng biệt tại một thời điểm, và có thể kiểm thử các giao diện riêng tư. Kiểm thử tích hợp hoàn toàn bên ngoài thư viện của bạn và sử dụng code của bạn theo cách mà bất kỳ mã bên ngoài nào khác sẽ sử dụng, chỉ sử dụng giao diện công cộng và có thể thực hiện nhiều module trong mỗi bài kiểm thử.

Viết cả hai loại kiểm thử là quan trọng để đảm bảo rằng các thành phần của thư viện của bạn đang hoạt động như bạn mong đợi, cả riêng biệt và cùng nhau.

Kiểm Thử Đơn Vị

Mục đích của kiểm thử đơn vị là kiểm thử từng đơn vị code riêng biệt với phần còn lại của code để nhanh chóng xác định nơi code đang hoạt động và không hoạt động như mong đợi. Bạn sẽ đặt các kiểm thử đơn vị trong thư mục src trong mỗi tệp với code mà chúng đang kiểm thử. Quy ước là tạo một module có tên tests trong mỗi tệp để chứa các hàm kiểm thử và chú thích module bằng cfg(test).

Module Tests và #[cfg(test)]

Chú thích #[cfg(test)] trên module tests nói với Rust biên dịch và chạy code kiểm thử chỉ khi bạn chạy cargo test, không phải khi bạn chạy cargo build. Điều này tiết kiệm thời gian biên dịch khi bạn chỉ muốn xây dựng thư viện và tiết kiệm không gian trong kết quả biên dịch vì các kiểm thử không được bao gồm. Bạn sẽ thấy rằng vì kiểm thử tích hợp nằm trong một thư mục khác, chúng không cần chú thích #[cfg(test)]. Tuy nhiên, vì kiểm thử đơn vị nằm trong cùng tệp với mã, bạn sẽ sử dụng #[cfg(test)] để chỉ định rằng chúng không nên được bao gồm trong kết quả biên dịch.

Nhớ lại rằng khi chúng ta tạo dự án mới adder trong phần đầu tiên của chương này, Cargo đã tạo mã này cho chúng ta:

Tên tệp: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Trên module tests được tự động tạo ra, thuộc tính cfg là viết tắt của cấu hình và nói với Rust rằng mục tiếp theo chỉ nên được bao gồm khi có một tùy chọn cấu hình nhất định. Trong trường hợp này, tùy chọn cấu hình là test, được Rust cung cấp để biên dịch và chạy các bài kiểm thử. Bằng cách sử dụng thuộc tính cfg, Cargo chỉ biên dịch code kiểm thử của chúng ta nếu chúng ta chủ động chạy các bài kiểm thử với cargo test. Điều này bao gồm bất kỳ hàm trợ giúp nào có thể nằm trong module này, ngoài các hàm được chú thích với #[test].

Kiểm Thử Các Hàm Riêng Tư

Có tranh luận trong cộng đồng kiểm thử về việc liệu các hàm riêng tư có nên được kiểm thử trực tiếp hay không, và các ngôn ngữ khác làm cho việc kiểm thử các hàm riêng tư trở nên khó khăn hoặc không thể. Bất kể bạn tuân theo ý thức hệ kiểm thử nào, các quy tắc riêng tư của Rust cho phép bạn kiểm thử các hàm riêng tư. Hãy xem xét mã trong Listing 11-12 với hàm riêng tư internal_adder.

pub fn add_two(a: usize) -> usize {
    internal_adder(a, 2)
}

fn internal_adder(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}

Lưu ý rằng hàm internal_adder không được đánh dấu là pub. Các bài kiểm thử chỉ là mã Rust, và module tests chỉ là một module khác. Như chúng ta đã thảo luận trong "Đường Dẫn cho Việc Tham Chiếu đến Một Mục trong Cây Module", các mục trong module con có thể sử dụng các mục trong module tổ tiên của chúng. Trong bài kiểm thử này, chúng ta đưa tất cả các mục của module cha của module tests vào phạm vi với use super::*, và sau đó bài kiểm thử có thể gọi internal_adder. Nếu bạn không nghĩ rằng các hàm riêng tư nên được kiểm thử, không có gì trong Rust buộc bạn phải làm như vậy.

Kiểm Thử Tích Hợp

Trong Rust, kiểm thử tích hợp hoàn toàn bên ngoài thư viện của bạn. Chúng sử dụng thư viện của bạn theo cách mà bất kỳ mã nào khác sẽ sử dụng, có nghĩa là chúng chỉ có thể gọi các hàm là một phần của API công khai của thư viện của bạn. Mục đích của chúng là kiểm tra liệu nhiều phần của thư viện của bạn có hoạt động cùng nhau một cách chính xác hay không. Các đơn vị mã hoạt động chính xác độc lập có thể gặp vấn đề khi được tích hợp, vì vậy việc kiểm thử bao phủ mã tích hợp cũng rất quan trọng. Để tạo kiểm thử tích hợp, trước tiên bạn cần một thư mục tests.

Thư Mục tests

Chúng ta tạo một thư mục tests ở cấp trên cùng của thư mục dự án, bên cạnh src. Cargo biết cách tìm các tệp kiểm thử tích hợp trong thư mục này. Sau đó, chúng ta có thể tạo nhiều tệp kiểm thử tùy thích, và Cargo sẽ biên dịch mỗi tệp dưới dạng một crate riêng biệt.

Hãy tạo một kiểm thử tích hợp. Với mã trong Listing 11-12 vẫn nằm trong tệp src/lib.rs, hãy tạo một thư mục tests, và tạo một tệp mới có tên tests/integration_test.rs. Cấu trúc thư mục của bạn nên trông như thế này:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

Nhập mã trong Listing 11-13 vào tệp tests/integration_test.rs.

use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}

Mỗi tệp trong thư mục tests là một crate riêng biệt, vì vậy chúng ta cần đưa thư viện của mình vào phạm vi của mỗi crate kiểm thử. Vì lý do đó, chúng ta thêm use adder::add_two; ở đầu mã, điều mà chúng ta không cần trong các kiểm thử đơn vị.

Chúng ta không cần chú thích bất kỳ mã nào trong tests/integration_test.rs với #[cfg(test)]. Cargo xử lý thư mục tests đặc biệt và chỉ biên dịch các tệp trong thư mục này khi chúng ta chạy cargo test. Chạy cargo test ngay bây giờ:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Ba phần của kết quả bao gồm các kiểm thử đơn vị, kiểm thử tích hợp và kiểm thử tài liệu. Lưu ý rằng nếu bất kỳ bài kiểm thử nào trong một phần không thành công, các phần tiếp theo sẽ không được chạy. Ví dụ, nếu một kiểm thử đơn vị không thành công, sẽ không có kết quả nào cho kiểm thử tích hợp và kiểm thử tài liệu vì những kiểm thử đó chỉ được chạy nếu tất cả các kiểm thử đơn vị đều thành công.

Phần đầu tiên cho các kiểm thử đơn vị giống như những gì chúng ta đã thấy: một dòng cho mỗi kiểm thử đơn vị (một có tên internal mà chúng ta đã thêm trong Listing 11-12) và sau đó là một dòng tóm tắt cho các kiểm thử đơn vị.

Phần kiểm thử tích hợp bắt đầu bằng dòng Running tests/integration_test.rs. Tiếp theo, có một dòng cho mỗi hàm kiểm thử trong kiểm thử tích hợp đó và một dòng tóm tắt cho kết quả của kiểm thử tích hợp ngay trước khi phần Doc-tests adder bắt đầu.

Mỗi tệp kiểm thử tích hợp có phần riêng của nó, vì vậy nếu chúng ta thêm nhiều tệp hơn trong thư mục tests, sẽ có nhiều phần kiểm thử tích hợp hơn.

Chúng ta vẫn có thể chạy một hàm kiểm thử tích hợp cụ thể bằng cách chỉ định tên của hàm kiểm thử làm đối số cho cargo test. Để chạy tất cả các kiểm thử trong một tệp kiểm thử tích hợp cụ thể, hãy sử dụng đối số --test của cargo test theo sau là tên của tệp:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Lệnh này chỉ chạy các kiểm thử trong tệp tests/integration_test.rs.

Các Module Con trong Kiểm Thử Tích Hợp

Khi bạn thêm nhiều kiểm thử tích hợp, bạn có thể muốn tạo nhiều tệp trong thư mục tests để giúp tổ chức chúng; ví dụ, bạn có thể nhóm các hàm kiểm thử theo chức năng mà chúng đang kiểm thử. Như đã đề cập trước đây, mỗi tệp trong thư mục tests được biên dịch như là crate riêng biệt của nó, điều này hữu ích để tạo các phạm vi riêng biệt để bắt chước chặt chẽ hơn cách người dùng cuối sẽ sử dụng crate của bạn. Tuy nhiên, điều này có nghĩa là các tệp trong thư mục tests không chia sẻ cùng một hành vi như các tệp trong src, như bạn đã học trong Chương 7 về cách tách mã thành các module và tệp.

Hành vi khác nhau của các tệp thư mục tests là rõ ràng nhất khi bạn có một tập hợp các hàm trợ giúp để sử dụng trong nhiều tệp kiểm thử tích hợp và bạn cố gắng làm theo các bước trong phần "Tách Module thành Các Tệp Khác Nhau" của Chương 7 để trích xuất chúng vào một module chung. Ví dụ, nếu chúng ta tạo tests/common.rs và đặt một hàm có tên setup trong đó, chúng ta có thể thêm một số mã vào setup mà chúng ta muốn gọi từ nhiều hàm kiểm thử trong nhiều tệp kiểm thử:

Tên tệp: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

Khi chúng ta chạy các bài kiểm thử một lần nữa, chúng ta sẽ thấy một phần mới trong kết quả kiểm thử cho tệp common.rs, ngay cả khi tệp này không chứa bất kỳ hàm kiểm thử nào và chúng ta cũng không gọi hàm setup từ bất cứ đâu:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Việc common xuất hiện trong kết quả kiểm thử với running 0 tests hiển thị cho nó không phải là điều chúng ta muốn. Chúng ta chỉ muốn chia sẻ một số mã với các tệp kiểm thử tích hợp khác. Để tránh làm cho common xuất hiện trong kết quả kiểm thử, thay vì tạo tests/common.rs, chúng ta sẽ tạo tests/common/mod.rs. Thư mục dự án bây giờ trông như thế này:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

Đây là quy ước đặt tên cũ mà Rust cũng hiểu mà chúng ta đã đề cập trong "Đường Dẫn Tệp Thay Thế" ở Chương 7. Đặt tên tệp theo cách này nói với Rust không xử lý module common như một tệp kiểm thử tích hợp. Khi chúng ta di chuyển mã hàm setup vào tests/common/mod.rs và xóa tệp tests/common.rs, phần trong kết quả kiểm thử sẽ không còn xuất hiện nữa. Các tệp trong thư mục con của thư mục tests không được biên dịch như các crate riêng biệt hoặc có phần trong kết quả kiểm thử.

Sau khi chúng ta đã tạo tests/common/mod.rs, chúng ta có thể sử dụng nó từ bất kỳ tệp kiểm thử tích hợp nào như một module. Đây là một ví dụ về việc gọi hàm setup từ kiểm thử it_adds_two trong tests/integration_test.rs:

Tên tệp: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

Lưu ý rằng khai báo mod common; giống như khai báo module mà chúng ta đã minh họa trong Listing 7-21. Sau đó, trong hàm kiểm thử, chúng ta có thể gọi hàm common::setup().

Kiểm Thử Tích Hợp cho Các Crate Nhị Phân

Nếu dự án của chúng ta là một crate nhị phân chỉ chứa một tệp src/main.rs và không có tệp src/lib.rs, chúng ta không thể tạo kiểm thử tích hợp trong thư mục tests và đưa các hàm được định nghĩa trong tệp src/main.rs vào phạm vi với một câu lệnh use. Chỉ có các crate thư viện mới để lộ các hàm mà các crate khác có thể sử dụng; các crate nhị phân được thiết kế để chạy độc lập.

Đây là một trong những lý do các dự án Rust cung cấp một tệp nhị phân có một tệp src/main.rs đơn giản gọi logic nằm trong tệp src/lib.rs. Sử dụng cấu trúc đó, kiểm thử tích hợp có thể kiểm thử crate thư viện với use để làm cho chức năng quan trọng có sẵn. Nếu chức năng quan trọng hoạt động, lượng mã nhỏ trong tệp src/main.rs cũng sẽ hoạt động, và lượng mã nhỏ đó không cần phải được kiểm thử.

Tóm Tắt

Các tính năng kiểm thử của Rust cung cấp một cách để chỉ định cách mã nên hoạt động để đảm bảo nó tiếp tục hoạt động như bạn mong đợi, ngay cả khi bạn thực hiện các thay đổi. Kiểm thử đơn vị kiểm tra các phần khác nhau của thư viện một cách riêng biệt và có thể kiểm tra các chi tiết triển khai riêng tư. Kiểm thử tích hợp kiểm tra xem nhiều phần của thư viện có hoạt động cùng nhau một cách chính xác hay không, và chúng sử dụng API công khai của thư viện để kiểm tra mã theo cùng cách mà mã bên ngoài sẽ sử dụng nó. Mặc dù hệ thống kiểu và quy tắc sở hữu của Rust giúp ngăn chặn một số loại lỗi, các kiểm thử vẫn quan trọng để giảm các lỗi logic liên quan đến cách mã của bạn được mong đợi sẽ hoạt động.

Hãy kết hợp kiến thức bạn đã học trong chương này và trong các chương trước để làm việc trên một dự án!

Dự án I/O: Xây dựng chương trình dòng lệnh

Chương này là phần tổng hợp về nhiều kỹ năng mà bạn đã học được và một khám phá thêm một số tính năng thư viện chuẩn. Chúng ta sẽ xây dựng một công cụ dòng lệnh tương tác với tệp và đầu vào/đầu ra dòng lệnh để thực hành các khái niệm Rust mà bạn đã nắm vững.

Tốc độ, tính an toàn, đầu ra nhị phân đơn và hỗ trợ đa nền tảng của Rust làm cho nó trở thành ngôn ngữ lý tưởng để tạo công cụ dòng lệnh, vì vậy cho dự án của chúng ta, chúng ta sẽ tạo phiên bản riêng của công cụ tìm kiếm dòng lệnh cổ điển grep (globally search a regular expression and print - tìm kiếm biểu thức chính quy toàn cục và in). Trong trường hợp sử dụng đơn giản nhất, grep tìm kiếm một chuỗi cụ thể trong một tệp cụ thể. Để làm được điều này, grep nhận đường dẫn tệp và một chuỗi làm đối số. Sau đó nó đọc tệp, tìm các dòng trong tệp đó có chứa chuỗi đối số, và in các dòng đó.

Trong quá trình này, chúng ta sẽ chỉ ra cách làm cho công cụ dòng lệnh của chúng ta sử dụng các tính năng terminal mà nhiều công cụ dòng lệnh khác sử dụng. Chúng ta sẽ đọc giá trị của một biến môi trường để cho phép người dùng cấu hình hành vi của công cụ của chúng ta. Chúng ta cũng sẽ in thông báo lỗi đến luồng console lỗi tiêu chuẩn (stderr) thay vì đầu ra tiêu chuẩn (stdout) để, ví dụ, người dùng có thể chuyển hướng đầu ra thành công đến một tệp trong khi vẫn nhìn thấy thông báo lỗi trên màn hình.

Một thành viên cộng đồng Rust, Andrew Gallant, đã tạo ra một phiên bản grep đầy đủ tính năng, rất nhanh, gọi là ripgrep. So với phiên bản của anh ấy, phiên bản của chúng ta sẽ khá đơn giản, nhưng chương này sẽ cung cấp cho bạn một số kiến thức nền tảng cần thiết để hiểu một dự án trong thực tế như ripgrep.

Dự án grep của chúng ta sẽ kết hợp một số khái niệm mà bạn đã học được:

Chúng ta cũng sẽ giới thiệu ngắn gọn về closures, iterators và trait objects, mà Chương 13Chương 18 sẽ đề cập chi tiết.

Chấp nhận đối số dòng lệnh

Hãy tạo một dự án mới với, như thường lệ, cargo new. Chúng ta sẽ gọi dự án của mình là minigrep để phân biệt nó với công cụ grep mà có thể bạn đã có trên hệ thống của mình.

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

Nhiệm vụ đầu tiên là làm cho minigrep chấp nhận hai đối số dòng lệnh của nó: đường dẫn tệp và một chuỗi để tìm kiếm. Nghĩa là, chúng ta muốn có thể chạy chương trình của mình với cargo run, hai dấu gạch ngang để chỉ ra rằng các đối số tiếp theo là dành cho chương trình của chúng ta chứ không phải cho cargo, một chuỗi để tìm kiếm, và một đường dẫn đến một tệp để tìm kiếm, như sau:

$ cargo run -- searchstring example-filename.txt

Hiện tại, chương trình được tạo ra bởi cargo new không thể xử lý các đối số mà chúng ta cung cấp. Một số thư viện hiện có trên crates.io có thể giúp viết một chương trình chấp nhận đối số dòng lệnh, nhưng bởi vì bạn đang học khái niệm này, hãy tự triển khai khả năng này.

Đọc giá trị đối số

Để cho phép minigrep đọc các giá trị của đối số dòng lệnh mà chúng ta truyền vào nó, chúng ta sẽ cần hàm std::env::args được cung cấp trong thư viện chuẩn của Rust. Hàm này trả về một iterator của các đối số dòng lệnh được truyền vào minigrep. Chúng ta sẽ thảo luận đầy đủ về iterator trong Chương 13. Hiện tại, bạn chỉ cần biết hai chi tiết về iterator: iterator tạo ra một chuỗi giá trị, và chúng ta có thể gọi phương thức collect trên một iterator để chuyển nó thành một bộ sưu tập, chẳng hạn như một vector, chứa tất cả các phần tử mà iterator tạo ra.

Mã trong Listing 12-1 cho phép chương trình minigrep của bạn đọc bất kỳ đối số dòng lệnh nào được truyền vào nó, và sau đó thu thập các giá trị vào một vector.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}

Đầu tiên chúng ta đưa mô-đun std::env vào phạm vi với câu lệnh use để chúng ta có thể sử dụng hàm args của nó. Lưu ý rằng hàm std::env::args được lồng trong hai cấp độ mô-đun. Như chúng ta đã thảo luận trong Chương 7, trong trường hợp mà hàm mong muốn được lồng trong nhiều hơn một mô-đun, chúng ta đã chọn đưa mô-đun cha vào phạm vi thay vì hàm. Bằng cách này, chúng ta có thể dễ dàng sử dụng các hàm khác từ std::env. Nó cũng ít gây nhầm lẫn hơn việc thêm use std::env::args và sau đó gọi hàm chỉ với args, bởi vì args có thể dễ dàng bị nhầm lẫn với một hàm được định nghĩa trong mô-đun hiện tại.

Hàm args và Unicode không hợp lệ

Lưu ý rằng std::env::args sẽ panic nếu bất kỳ đối số nào chứa Unicode không hợp lệ. Nếu chương trình của bạn cần chấp nhận các đối số chứa Unicode không hợp lệ, hãy sử dụng std::env::args_os thay thế. Hàm đó trả về một iterator tạo ra giá trị OsString thay vì giá trị String. Chúng ta đã chọn sử dụng std::env::args ở đây để đơn giản hóa bởi vì giá trị OsString khác nhau trên mỗi nền tảng và phức tạp hơn để làm việc so với giá trị String.

Ở dòng đầu tiên của main, chúng ta gọi env::args, và ngay lập tức sử dụng collect để chuyển iterator thành một vector chứa tất cả các giá trị được tạo ra bởi iterator. Chúng ta có thể sử dụng hàm collect để tạo nhiều loại bộ sưu tập khác nhau, vì vậy chúng ta chú thích rõ ràng kiểu của args để chỉ định rằng chúng ta muốn một vector của các chuỗi. Mặc dù bạn rất hiếm khi cần phải chú thích kiểu trong Rust, collect là một hàm mà bạn thường cần phải chú thích bởi vì Rust không thể suy luận được loại bộ sưu tập mà bạn muốn.

Cuối cùng, chúng ta in vector sử dụng macro debug. Hãy thử chạy mã này đầu tiên không có đối số và sau đó với hai đối số:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

Lưu ý rằng giá trị đầu tiên trong vector là "target/debug/minigrep", đó là tên của binary của chúng ta. Điều này phù hợp với hành vi của danh sách đối số trong C, cho phép các chương trình sử dụng tên mà chúng được gọi trong quá trình thực thi. Thường rất thuận tiện để có quyền truy cập vào tên chương trình trong trường hợp bạn muốn in nó trong các thông báo hoặc thay đổi hành vi của chương trình dựa trên alias dòng lệnh nào được sử dụng để gọi chương trình. Nhưng cho mục đích của chương này, chúng ta sẽ bỏ qua nó và chỉ lưu hai đối số mà chúng ta cần.

Lưu giá trị đối số trong các biến

Chương trình hiện tại có thể truy cập các giá trị được chỉ định làm đối số dòng lệnh. Bây giờ chúng ta cần lưu giá trị của hai đối số trong các biến để chúng ta có thể sử dụng các giá trị đó trong phần còn lại của chương trình. Chúng ta làm điều đó trong Listing 12-2.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}

Như chúng ta đã thấy khi in vector, tên của chương trình chiếm vị trí giá trị đầu tiên trong vector tại args[0], vì vậy chúng ta bắt đầu đối số từ chỉ mục 1. Đối số đầu tiên mà minigrep nhận là chuỗi chúng ta đang tìm kiếm, vì vậy chúng ta đặt một tham chiếu đến đối số đầu tiên trong biến query. Đối số thứ hai sẽ là đường dẫn tệp, vì vậy chúng ta đặt một tham chiếu đến đối số thứ hai trong biến file_path.

Chúng ta tạm thời in các giá trị của những biến này để chứng minh rằng mã đang hoạt động như chúng ta mong muốn. Hãy chạy chương trình này một lần nữa với các đối số testsample.txt:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

Tuyệt vời, chương trình đang hoạt động! Giá trị của các đối số mà chúng ta cần đang được lưu vào các biến đúng. Sau này, chúng ta sẽ thêm xử lý lỗi để đối phó với một số tình huống lỗi tiềm ẩn, chẳng hạn như khi người dùng không cung cấp đối số; hiện tại, chúng ta sẽ bỏ qua tình huống đó và làm việc để thêm khả năng đọc tệp thay vào đó.

Đọc một tệp

Bây giờ chúng ta sẽ thêm chức năng để đọc tệp được chỉ định trong đối số file_path. Đầu tiên chúng ta cần một tệp mẫu để kiểm tra: chúng ta sẽ sử dụng một tệp với một lượng nhỏ văn bản trên nhiều dòng với một số từ được lặp lại. Listing 12-3 có một bài thơ của Emily Dickinson sẽ phù hợp! Tạo một tệp tên là poem.txt ở cấp gốc của dự án của bạn, và nhập bài thơ "I'm Nobody! Who are you?"

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!

Với văn bản đã sẵn sàng, chỉnh sửa src/main.rs và thêm mã để đọc tệp, như đã hiển thị trong Listing 12-4.

use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    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}");
}

Đầu tiên, chúng ta đưa một phần liên quan của thư viện chuẩn vào với một câu lệnh use: chúng ta cần std::fs để xử lý tệp.

Trong main, câu lệnh mới fs::read_to_string nhận file_path, mở tệp đó và trả về một giá trị kiểu std::io::Result<String> chứa nội dung tệp.

Sau đó, chúng ta lại thêm một câu lệnh println! tạm thời để in giá trị của contents sau khi tệp được đọc, để chúng ta có thể kiểm tra xem chương trình đang hoạt động tốt đến đâu.

Hãy chạy mã này với bất kỳ chuỗi nào làm đối số dòng lệnh đầu tiên (vì chúng ta chưa triển khai phần tìm kiếm) và tệp poem.txt làm đối số thứ hai:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     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!

Tuyệt vời! Mã đã đọc và sau đó in nội dung của tệp. Nhưng mã có một vài khuyết điểm. Hiện tại, hàm main có nhiều trách nhiệm: nói chung, các hàm rõ ràng và dễ bảo trì hơn nếu mỗi hàm chỉ chịu trách nhiệm cho một ý tưởng duy nhất. Vấn đề khác là chúng ta không xử lý lỗi tốt như chúng ta có thể. Chương trình vẫn còn nhỏ, nên những khuyết điểm này không phải là vấn đề lớn, nhưng khi chương trình phát triển, sẽ khó khăn hơn để sửa chúng một cách gọn gàng. Đó là một thực hành tốt để bắt đầu tái cấu trúc sớm khi phát triển một chương trình vì việc tái cấu trúc lượng mã nhỏ hơn dễ dàng hơn nhiều. Chúng ta sẽ làm điều đó tiếp theo.

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ù queryfile_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 queryfile_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à queryfile_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ọi clone 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 queryfile_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 queryfile_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ị queryfile_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 queryfile_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'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() 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!

Phát triển chức năng của thư viện với phát triển hướng kiểm thử

Giờ đây khi chúng ta đã trích xuất logic vào src/lib.rs và để lại phần thu thập đối số và xử lý lỗi trong src/main.rs, việc viết các bài kiểm thử cho chức năng cốt lõi của mã của chúng ta trở nên dễ dàng hơn nhiều. Chúng ta có thể gọi các hàm trực tiếp với các đối số khác nhau và kiểm tra giá trị trả về mà không cần phải gọi nhị phân của chúng ta từ dòng lệnh.

Trong phần này, chúng ta sẽ thêm logic tìm kiếm vào chương trình minigrep bằng cách sử dụng quy trình phát triển hướng kiểm thử (TDD) với các bước sau:

  1. Viết một bài kiểm thử thất bại và chạy nó để đảm bảo rằng nó thất bại vì lý do bạn mong đợi.
  2. Viết hoặc chỉnh sửa đủ mã để làm cho bài kiểm thử mới vượt qua.
  3. Cải tiến mã bạn vừa thêm hoặc thay đổi và đảm bảo các bài kiểm thử tiếp tục vượt qua.
  4. Lặp lại từ bước 1!

Mặc dù đây chỉ là một trong nhiều cách để viết phần mềm, TDD có thể giúp định hướng thiết kế mã. Viết bài kiểm thử trước khi bạn viết mã làm cho bài kiểm thử vượt qua giúp duy trì độ phủ kiểm thử cao trong suốt quá trình.

Chúng ta sẽ kiểm tra-hướng việc triển khai chức năng thực sự tìm kiếm chuỗi truy vấn trong nội dung tệp và tạo ra danh sách các dòng phù hợp với truy vấn. Chúng ta sẽ thêm chức năng này trong một hàm gọi là search.

Viết một bài kiểm thử thất bại

Bởi vì chúng ta không còn cần chúng nữa, hãy xóa các câu lệnh println! từ src/lib.rssrc/main.rs mà chúng ta đã sử dụng để kiểm tra hành vi của chương trình. Sau đó, trong src/lib.rs, chúng ta sẽ thêm một mô-đun tests với một hàm kiểm thử, như chúng ta đã làm trong Chương 11. Hàm kiểm thử chỉ định hành vi mà chúng ta muốn hàm search có: nó sẽ lấy một truy vấn và văn bản để tìm kiếm, và nó sẽ chỉ trả về các dòng từ văn bản mà chứa truy vấn. Listing 12-15 hiển thị bài kiểm thử này, mà chưa thể biên dịch được.

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Bài kiểm thử này tìm kiếm chuỗi "duct". Văn bản chúng ta đang tìm kiếm có ba dòng, chỉ có một dòng chứa "duct" (lưu ý rằng dấu gạch chéo ngược sau dấu ngoặc kép mở báo với Rust không đặt ký tự xuống dòng vào đầu nội dung của chuỗi chữ này). Chúng ta khẳng định rằng giá trị trả về từ hàm search chỉ chứa dòng mà chúng ta mong đợi.

Chúng ta chưa thể chạy bài kiểm thử này và xem nó thất bại vì bài kiểm thử thậm chí không biên dịch được: hàm search chưa tồn tại! Theo các nguyên tắc TDD, chúng ta sẽ thêm đủ mã để bài kiểm thử biên dịch và chạy bằng cách thêm một định nghĩa của hàm search luôn trả về một vector rỗng, như được hiển thị trong Listing 12-16. Sau đó bài kiểm thử sẽ biên dịch và thất bại vì một vector rỗng không khớp với vector chứa dòng "safe, fast, productive."

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Lưu ý rằng chúng ta cần định nghĩa một lifetime rõ ràng 'a trong chữ ký của search và sử dụng lifetime đó với đối số contents và giá trị trả về. Nhớ lại trong Chương 10 rằng tham số lifetime chỉ định đối số lifetime nào được kết nối với lifetime của giá trị trả về. Trong trường hợp này, chúng ta chỉ ra rằng vector được trả về sẽ chứa slice chuỗi tham chiếu đến các slice của đối số contents (thay vì đối số query).

Nói cách khác, chúng ta nói với Rust rằng dữ liệu được trả về bởi hàm search sẽ tồn tại lâu như dữ liệu được truyền vào hàm search trong đối số contents. Điều này quan trọng! Dữ liệu được tham chiếu bởi một slice cần phải hợp lệ để tham chiếu hợp lệ; nếu trình biên dịch giả định chúng ta đang tạo slice chuỗi của query thay vì contents, nó sẽ thực hiện kiểm tra an toàn của nó không chính xác.

Nếu chúng ta quên chú thích lifetime và cố gắng biên dịch hàm này, chúng ta sẽ gặp lỗi này:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
   |                      ----            ----         ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust không thể biết được chúng ta cần đối số nào trong hai đối số, vì vậy chúng ta cần phải cho nó biết rõ ràng. Bởi vì contents là đối số chứa tất cả văn bản của chúng ta và chúng ta muốn trả về các phần của văn bản đó phù hợp, chúng ta biết contents là đối số nên được kết nối với giá trị trả về bằng cách sử dụng cú pháp lifetime.

Các ngôn ngữ lập trình khác không yêu cầu bạn kết nối các đối số với giá trị trả về trong chữ ký, nhưng thực hành này sẽ trở nên dễ dàng hơn theo thời gian. Bạn có thể muốn so sánh ví dụ này với các ví dụ trong phần "Xác thực tham chiếu với Lifetime" trong Chương 10.

Bây giờ hãy chạy bài kiểm thử:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----

thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Tuyệt, bài kiểm thử thất bại, chính xác như chúng ta mong đợi. Hãy làm cho bài kiểm thử vượt qua!

Viết mã để vượt qua bài kiểm thử

Hiện tại, bài kiểm thử của chúng ta đang thất bại vì chúng ta luôn trả về một vector rỗng. Để sửa điều đó và triển khai search, chương trình của chúng ta cần tuân theo các bước sau:

  1. Lặp qua từng dòng của nội dung.
  2. Kiểm tra xem dòng đó có chứa chuỗi truy vấn của chúng ta hay không.
  3. Nếu có, thêm nó vào danh sách các giá trị mà chúng ta đang trả về.
  4. Nếu không, không làm gì cả.
  5. Trả về danh sách các kết quả phù hợp.

Hãy làm việc qua từng bước, bắt đầu với việc lặp qua các dòng.

Lặp qua các dòng với phương thức lines

Rust có một phương thức hữu ích để xử lý lặp từng dòng của chuỗi, tiện lợi được đặt tên là lines, hoạt động như được hiển thị trong Listing 12-17. Lưu ý rằng điều này chưa thể biên dịch được.

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Phương thức lines trả về một iterator. Chúng ta sẽ nói về iterator một cách chi tiết trong Chương 13, nhưng hãy nhớ lại rằng bạn đã thấy cách sử dụng iterator này trong Listing 3-5, nơi chúng ta đã sử dụng vòng lặp for với một iterator để chạy một đoạn mã trên mỗi phần tử trong một bộ sưu tập.

Tìm kiếm từng dòng cho truy vấn

Tiếp theo, chúng ta sẽ kiểm tra xem dòng hiện tại có chứa chuỗi truy vấn của chúng ta hay không. May mắn thay, chuỗi có một phương thức hữu ích có tên là contains làm điều này cho chúng ta! Thêm một lệnh gọi đến phương thức contains trong hàm search, như được hiển thị trong Listing 12-18. Lưu ý rằng điều này vẫn chưa biên dịch được.

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Hiện tại, chúng ta đang xây dựng chức năng. Để mã biên dịch, chúng ta cần trả về một giá trị từ thân hàm như chúng ta đã chỉ ra trong chữ ký hàm.

Lưu trữ các dòng phù hợp

Để hoàn thành hàm này, chúng ta cần một cách để lưu trữ các dòng phù hợp mà chúng ta muốn trả về. Để làm điều đó, chúng ta có thể tạo một vector có thể thay đổi trước vòng lặp for và gọi phương thức push để lưu trữ một line trong vector. Sau vòng lặp for, chúng ta trả về vector, như được hiển thị trong Listing 12-19.

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Bây giờ hàm search chỉ nên trả về các dòng chứa query, và bài kiểm thử của chúng ta sẽ vượt qua. Hãy chạy bài kiểm thử:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Bài kiểm thử của chúng ta đã vượt qua, vì vậy chúng ta biết nó hoạt động!

Tại thời điểm này, chúng ta có thể xem xét các cơ hội để cải tiến việc triển khai hàm tìm kiếm trong khi giữ cho các bài kiểm thử vượt qua để duy trì chức năng tương tự. Mã trong hàm tìm kiếm không quá tệ, nhưng nó không tận dụng một số tính năng hữu ích của iterator. Chúng ta sẽ quay lại ví dụ này trong Chương 13, nơi chúng ta sẽ khám phá iterator một cách chi tiết và xem xét cách cải thiện nó.

Sử dụng hàm search trong hàm run

Bây giờ hàm search đang hoạt động và được kiểm thử, chúng ta cần gọi search từ hàm run của chúng ta. Chúng ta cần truyền giá trị config.querycontentsrun đọc từ tệp cho hàm search. Sau đó run sẽ in mỗi dòng được trả về từ search:

Tên tệp: src/lib.rs

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Chúng ta vẫn đang sử dụng vòng lặp for để trả về mỗi dòng từ search và in nó.

Bây giờ toàn bộ chương trình sẽ hoạt động! Hãy thử nó, đầu tiên với một từ nên trả về chính xác một dòng từ bài thơ của Emily Dickinson: frog.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

Tuyệt! Bây giờ hãy thử một từ sẽ khớp với nhiều dòng, như body:

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

Và cuối cùng, hãy đảm bảo rằng chúng ta không nhận được bất kỳ dòng nào khi chúng ta tìm kiếm một từ không có ở bất kỳ đâu trong bài thơ, ví dụ như monomorphization:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

Xuất sắc! Chúng ta đã xây dựng phiên bản mini của riêng mình cho một công cụ cổ điển và đã học được rất nhiều về cách cấu trúc ứng dụng. Chúng ta cũng đã học được một chút về đầu vào và đầu ra tệp, lifetime, kiểm thử và phân tích dòng lệnh.

Để hoàn thành dự án này, chúng ta sẽ trình bày ngắn gọn cách làm việc với biến môi trường và cách in ra lỗi tiêu chuẩn, cả hai đều hữu ích khi bạn đang viết các chương trình dòng lệnh.

Làm việc với Biến Môi trường

Chúng ta sẽ cải thiện minigrep bằng cách thêm một tính năng bổ sung: một tùy chọn cho tìm kiếm không phân biệt chữ hoa chữ thường mà người dùng có thể bật thông qua một biến môi trường. Chúng ta có thể biến tính năng này thành một tùy chọn dòng lệnh và yêu cầu người dùng nhập nó mỗi khi họ muốn áp dụng, nhưng thay vào đó, bằng cách sử dụng biến môi trường, chúng ta cho phép người dùng đặt biến môi trường một lần và thực hiện tất cả các tìm kiếm của họ không phân biệt chữ hoa chữ thường trong phiên terminal đó.

Viết một Test Thất bại cho Hàm search Không Phân biệt Chữ hoa Chữ thường

Đầu tiên chúng ta thêm một hàm search_case_insensitive mới sẽ được gọi khi biến môi trường có giá trị. Chúng ta sẽ tiếp tục tuân theo quy trình TDD, vì vậy bước đầu tiên vẫn là viết một test thất bại. Chúng ta sẽ thêm một test mới cho hàm search_case_insensitive mới và đổi tên test cũ từ one_result thành case_sensitive để làm rõ sự khác biệt giữa hai test, như được hiển thị trong Listing 12-20.

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Lưu ý rằng chúng ta cũng đã chỉnh sửa contents của test cũ. Chúng ta đã thêm một dòng mới với nội dung "Duct tape." sử dụng chữ D viết hoa, nó không nên khớp với truy vấn "duct" khi chúng ta tìm kiếm phân biệt chữ hoa chữ thường. Việc thay đổi test cũ theo cách này giúp đảm bảo chúng ta không vô tình phá vỡ chức năng tìm kiếm phân biệt chữ hoa chữ thường mà chúng ta đã triển khai. Test này bây giờ nên pass và nên tiếp tục pass khi chúng ta làm việc trên tìm kiếm không phân biệt chữ hoa chữ thường.

Test mới cho tìm kiếm không phân biệt chữ hoa chữ thường sử dụng "rUsT" làm truy vấn. Trong hàm search_case_insensitive mà chúng ta sắp thêm, truy vấn "rUsT" nên khớp với dòng chứa "Rust:" với chữ R viết hoa và khớp với dòng "Trust me." mặc dù cả hai đều có cách viết hoa thường khác với truy vấn. Đây là test thất bại của chúng ta, và nó sẽ không biên dịch được vì chúng ta chưa định nghĩa hàm search_case_insensitive. Hãy thoải mái thêm một cài đặt khung luôn trả về một vector rỗng, tương tự như cách chúng ta đã làm cho hàm search trong Listing 12-16 để thấy test biên dịch và thất bại.

Triển khai Hàm search_case_insensitive

Hàm search_case_insensitive, được hiển thị trong Listing 12-21, sẽ gần giống với hàm search. Sự khác biệt duy nhất là chúng ta sẽ chuyển đổi query và mỗi line thành chữ thường để dù đầu vào có chữ hoa hay chữ thường như thế nào, chúng sẽ giống nhau khi chúng ta kiểm tra xem dòng có chứa truy vấn hay không.

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Đầu tiên, chúng ta chuyển đổi chuỗi query thành chữ thường và lưu trữ nó trong một biến mới có cùng tên, ghi đè biến query gốc. Gọi to_lowercase trên truy vấn là cần thiết để dù truy vấn của người dùng là "rust", "RUST", "Rust", hay "rUsT", chúng ta sẽ xử lý truy vấn như thể nó là "rust" và không phân biệt chữ hoa chữ thường. Mặc dù to_lowercase sẽ xử lý Unicode cơ bản, nó sẽ không chính xác 100%. Nếu chúng ta đang viết một ứng dụng thực, chúng ta sẽ muốn làm thêm một chút công việc ở đây, nhưng phần này là về biến môi trường, không phải về Unicode, vì vậy chúng ta sẽ để nó như vậy ở đây.

Lưu ý rằng query bây giờ là một String thay vì một string slice vì gọi to_lowercase tạo ra dữ liệu mới thay vì tham chiếu đến dữ liệu hiện có. Ví dụ, nếu truy vấn là "rUsT": string slice đó không chứa chữ u hoặc t viết thường để chúng ta sử dụng, vì vậy chúng ta phải cấp phát một String mới chứa "rust". Khi chúng ta truyền query làm đối số cho phương thức contains bây giờ, chúng ta cần thêm dấu và (ampersand) vì chữ ký của contains được định nghĩa để nhận một string slice.

Tiếp theo, chúng ta thêm một lệnh gọi to_lowercase cho mỗi line để chuyển tất cả các ký tự thành chữ thường. Bây giờ sau khi chúng ta đã chuyển đổi linequery thành chữ thường, chúng ta sẽ tìm thấy các kết quả phù hợp bất kể truy vấn có chữ hoa hay chữ thường.

Hãy xem liệu cài đặt này có vượt qua các bài kiểm tra không:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Tuyệt vời! Chúng đều đã pass. Bây giờ, chúng ta hãy gọi hàm search_case_insensitive mới từ hàm run. Đầu tiên, chúng ta sẽ thêm một tùy chọn cấu hình vào struct Config để chuyển đổi giữa tìm kiếm phân biệt chữ hoa chữ thường và không phân biệt chữ hoa chữ thường. Việc thêm trường này sẽ gây ra lỗi biên dịch vì chúng ta chưa khởi tạo trường này ở bất kỳ đâu:

Filename: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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 })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Chúng ta đã thêm trường ignore_case chứa một giá trị Boolean. Tiếp theo, chúng ta cần hàm run kiểm tra giá trị của trường ignore_case và sử dụng nó để quyết định gọi hàm search hay hàm search_case_insensitive, như được hiển thị trong Listing 12-22. Điều này vẫn chưa thể biên dịch.

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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 })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Cuối cùng, chúng ta cần kiểm tra biến môi trường. Các hàm để làm việc với biến môi trường nằm trong module env trong thư viện tiêu chuẩn, vì vậy chúng ta đưa module đó vào phạm vi ở đầu src/lib.rs. Sau đó, chúng ta sẽ sử dụng hàm var từ module env để kiểm tra xem có giá trị nào được đặt cho biến môi trường có tên IGNORE_CASE không, như được hiển thị trong Listing 12-23.

use std::env;
// --snip--

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Ở đây, chúng ta tạo một biến mới, ignore_case. Để đặt giá trị của nó, chúng ta gọi hàm env::var và truyền tên của biến môi trường IGNORE_CASE. Hàm env::var trả về một Result sẽ là biến thể Ok thành công chứa giá trị của biến môi trường nếu biến môi trường được đặt thành bất kỳ giá trị nào. Nó sẽ trả về biến thể Err nếu biến môi trường không được đặt.

Chúng ta đang sử dụng phương thức is_ok trên Result để kiểm tra xem biến môi trường có được đặt hay không, nghĩa là chương trình nên thực hiện tìm kiếm không phân biệt chữ hoa chữ thường. Nếu biến môi trường IGNORE_CASE không được đặt thành bất kỳ giá trị nào, is_ok sẽ trả về false và chương trình sẽ thực hiện tìm kiếm phân biệt chữ hoa chữ thường. Chúng ta không quan tâm đến giá trị của biến môi trường, chỉ quan tâm xem nó được đặt hay không, vì vậy chúng ta đang kiểm tra is_ok thay vì sử dụng unwrap, expect, hoặc bất kỳ phương thức nào khác chúng ta đã thấy trên Result.

Chúng ta truyền giá trị trong biến ignore_case cho đối tượng Config để hàm run có thể đọc giá trị đó và quyết định gọi search_case_insensitive hay search, như chúng ta đã triển khai trong Listing 12-22.

Hãy thử nó! Đầu tiên, chúng ta sẽ chạy chương trình mà không đặt biến môi trường và với truy vấn to, sẽ khớp với bất kỳ dòng nào chứa từ to viết thường:

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

Có vẻ như điều đó vẫn hoạt động! Bây giờ chúng ta hãy chạy chương trình với IGNORE_CASE được đặt thành 1 nhưng với cùng truy vấn to:

$ IGNORE_CASE=1 cargo run -- to poem.txt

Nếu bạn đang sử dụng PowerShell, bạn sẽ cần đặt biến môi trường và chạy chương trình như các lệnh riêng biệt:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

Điều này sẽ làm cho IGNORE_CASE tồn tại trong phiên shell còn lại của bạn. Có thể bỏ đặt nó bằng lệnh Remove-Item:

PS> Remove-Item Env:IGNORE_CASE

Chúng ta nên nhận được các dòng chứa to có thể có chữ viết hoa:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

Tuyệt vời, chúng ta cũng nhận được các dòng chứa To! Chương trình minigrep của chúng ta bây giờ có thể thực hiện tìm kiếm không phân biệt chữ hoa chữ thường được điều khiển bởi một biến môi trường. Bây giờ bạn biết cách quản lý các tùy chọn được đặt bằng cả đối số dòng lệnh hoặc biến môi trường.

Một số chương trình cho phép đối số biến môi trường cho cùng một cấu hình. Trong những trường hợp đó, các chương trình quyết định rằng một trong hai có quyền ưu tiên. Đối với một bài tập khác tự thực hiện, hãy thử kiểm soát độ nhạy chữ hoa chữ thường thông qua một đối số dòng lệnh hoặc một biến môi trường. Quyết định xem đối số dòng lệnh hay biến môi trường nên có quyền ưu tiên nếu chương trình được chạy với một cái được đặt thành phân biệt chữ hoa chữ thường và một cái được đặt để bỏ qua chữ hoa chữ thường.

Module std::env chứa nhiều tính năng hữu ích hơn để xử lý biến môi trường: hãy xem tài liệu của nó để biết những gì có sẵn.

Viết Thông Báo Lỗi vào Standard Error Thay vì Standard Output

Hiện tại, chúng ta đang viết tất cả đầu ra của mình vào terminal bằng cách sử dụng macro println!. Trong hầu hết các terminal, có hai loại đầu ra: standard output (stdout) cho thông tin chung và standard error (stderr) cho thông báo lỗi. Sự phân biệt này cho phép người dùng lựa chọn chuyển hướng đầu ra thành công của chương trình vào một file nhưng vẫn in thông báo lỗi ra màn hình.

Macro println! chỉ có khả năng in vào standard output, vì vậy chúng ta phải sử dụng một cách khác để in vào standard error.

Kiểm tra Nơi Lỗi Được Ghi

Đầu tiên, hãy quan sát cách nội dung được in bởi minigrep hiện đang được ghi vào standard output, bao gồm cả các thông báo lỗi mà chúng ta muốn viết vào standard error thay thế. Chúng ta sẽ làm điều đó bằng cách chuyển hướng luồng standard output vào một file trong khi chủ ý gây ra lỗi. Chúng ta sẽ không chuyển hướng luồng standard error, vì vậy bất kỳ nội dung nào được gửi đến standard error sẽ tiếp tục hiển thị trên màn hình.

Các chương trình dòng lệnh được mong đợi sẽ gửi thông báo lỗi đến standard error stream để chúng ta vẫn có thể thấy thông báo lỗi trên màn hình ngay cả khi chúng ta chuyển hướng standard output stream vào một tệp. Chương trình của chúng ta hiện không hoạt động tốt: chúng ta sắp thấy rằng nó lưu đầu ra thông báo lỗi vào một file thay vì màn hình!

Để chứng minh hành vi này, chúng ta sẽ chạy chương trình với > và đường dẫn file, output.txt, nơi chúng ta muốn chuyển hướng luồng standard output đến. Chúng ta sẽ không truyền bất kỳ đối số nào, điều đó sẽ gây ra lỗi:

$ cargo run > output.txt

Cú pháp > nói với shell rằng hãy ghi nội dung của standard output vào output.txt thay vì màn hình. Chúng ta không thấy thông báo lỗi mà chúng ta đã mong đợi in trên màn hình, vì vậy điều đó có nghĩa là nó hẳn đã xuất hiện trong file. Đây là nội dung của output.txt:

Problem parsing arguments: not enough arguments

Đúng vậy, thông báo lỗi của chúng ta đang được in vào standard output. Sẽ hữu ích hơn nhiều nếu các thông báo lỗi như thế này được in ra standard error để chỉ dữ liệu từ một lần chạy thành công mới xuất hiện trong file. Chúng ta sẽ thay đổi điều đó.

In Lỗi vào Standard Error

Chúng ta sẽ sử dụng mã trong Listing 12-24 để thay đổi cách thông báo lỗi được in. Vì việc cấu trúc lại mã mà chúng ta đã làm trước đó trong chương này, tất cả mã in thông báo lỗi nằm trong một hàm, main. Thư viện chuẩn cung cấp macro eprintln! để in vào standard error stream, vì vậy hãy thay đổi hai nơi chúng ta đã gọi println! để in lỗi bằng cách sử dụng eprintln! thay thế.

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

Bây giờ hãy chạy chương trình một lần nữa theo cùng một cách, không có đối số nào và chuyển hướng standard output với >:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

Bây giờ chúng ta thấy lỗi trên màn hình và output.txt không chứa gì cả, đó là hành vi mà chúng ta mong đợi từ các chương trình dòng lệnh.

Hãy chạy chương trình một lần nữa với các đối số không gây ra lỗi nhưng vẫn chuyển hướng standard output vào một file, như sau:

$ cargo run -- to poem.txt > output.txt

Chúng ta sẽ không thấy bất kỳ đầu ra nào trên terminal, và output.txt sẽ chứa các kết quả của chúng ta:

Filename: output.txt

Are you nobody, too?
How dreary to be somebody!

Điều này chứng minh rằng chúng ta hiện đang sử dụng standard output cho đầu ra thành công và standard error cho đầu ra lỗi một cách thích hợp.

Tóm tắt

Chương này đã nhắc lại một số khái niệm chính mà bạn đã học cho đến nay và đã đề cập đến cách thực hiện các hoạt động I/O thông thường trong Rust. Bằng cách sử dụng đối số dòng lệnh, files, biến môi trường, và macro eprintln! để in lỗi, bạn đã sẵn sàng để viết các ứng dụng dòng lệnh. Kết hợp với các khái niệm trong các chương trước, mã của bạn sẽ được tổ chức tốt, lưu trữ dữ liệu hiệu quả trong các cấu trúc dữ liệu phù hợp, xử lý lỗi một cách tốt và được kiểm tra kỹ lưỡng.

Tiếp theo, chúng ta sẽ khám phá một số tính năng của Rust bị ảnh hưởng bởi các ngôn ngữ lập trình hàm: closures và iterators.

Tính năng Ngôn ngữ Hàm: Iterators và Closures

Thiết kế của Rust đã lấy cảm hứng từ nhiều ngôn ngữ và kỹ thuật hiện có, và một ảnh hưởng đáng kể là lập trình hàm. Lập trình theo phong cách hàm thường bao gồm việc sử dụng hàm như là giá trị bằng cách truyền chúng vào đối số, trả về chúng từ các hàm khác, gán chúng cho biến để thực thi sau này, và nhiều điều tương tự.

Trong chương này, chúng ta sẽ không tranh luận về vấn đề lập trình hàm là gì hoặc không phải là gì, mà thay vào đó sẽ thảo luận về một số tính năng của Rust tương tự với các tính năng trong nhiều ngôn ngữ thường được gọi là hàm.

Cụ thể hơn, chúng ta sẽ đề cập đến:

  • Closures, một cấu trúc giống hàm mà bạn có thể lưu trữ trong một biến
  • Iterators, một cách để xử lý một chuỗi các phần tử
  • Cách sử dụng closures và iterators để cải thiện dự án I/O trong Chương 12
  • Hiệu suất của closures và iterators (tiết lộ: chúng nhanh hơn bạn nghĩ!)

Chúng ta đã đề cập đến một số tính năng Rust khác, như pattern matching và enums, cũng bị ảnh hưởng bởi phong cách lập trình hàm. Vì việc thành thạo closures và iterators là một phần quan trọng của việc viết mã Rust thông thường, nhanh chóng, chúng ta sẽ dành toàn bộ chương này cho chúng.

Closures: Các Hàm Ẩn Danh Có Thể Capture Môi Trường Của Chúng

Closures trong Rust là các hàm ẩn danh mà bạn có thể lưu trong một biến hoặc truyền như đối số cho các hàm khác. Bạn có thể tạo closure tại một nơi và sau đó gọi closure ở nơi khác để đánh giá nó trong một ngữ cảnh khác. Không giống như các hàm thông thường, closures có thể capture (nắm bắt) các giá trị từ phạm vi mà chúng được định nghĩa. Chúng ta sẽ chứng minh cách các tính năng của closure cho phép tái sử dụng mã và tùy chỉnh hành vi.

Capture Môi Trường với Closures

Đầu tiên, chúng ta sẽ xem xét cách chúng ta có thể sử dụng closures để capture các giá trị từ môi trường mà chúng được định nghĩa để sử dụng sau này. Đây là kịch bản: Thỉnh thoảng, công ty áo thun của chúng ta tặng một chiếc áo độc quyền, phiên bản giới hạn cho ai đó trong danh sách gửi thư của chúng ta như một khuyến mãi. Những người trong danh sách gửi thư có thể tùy chọn thêm màu yêu thích của họ vào hồ sơ của họ. Nếu người được chọn để nhận áo miễn phí đã đặt màu yêu thích của họ, họ sẽ nhận được áo có màu đó. Nếu người đó chưa chỉ định màu yêu thích, họ sẽ nhận được bất kỳ màu nào mà công ty hiện có nhiều nhất.

Có nhiều cách để thực hiện điều này. Đối với ví dụ này, chúng ta sẽ sử dụng một enum gọi là ShirtColor có các biến thể RedBlue (giới hạn số lượng màu có sẵn để đơn giản hóa). Chúng ta biểu diễn kho hàng của công ty bằng một struct Inventory có một trường tên là shirts chứa một Vec<ShirtColor> đại diện cho các màu áo hiện có trong kho. Phương thức giveaway được định nghĩa trên Inventory lấy tùy chọn màu áo ưa thích của người thắng áo miễn phí và trả về màu áo mà người đó sẽ nhận được. Thiết lập này được hiển thị trong Listing 13-1:

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}

store được định nghĩa trong main có hai áo màu xanh và một áo màu đỏ còn lại để phân phối cho khuyến mãi phiên bản giới hạn này. Chúng ta gọi phương thức giveaway cho một người dùng có sở thích cho áo màu đỏ và một người dùng không có bất kỳ sở thích nào.

Một lần nữa, mã này có thể được triển khai theo nhiều cách, và ở đây, để tập trung vào closures, chúng ta đã gắn bó với các khái niệm bạn đã học, ngoại trừ phần thân của phương thức giveaway sử dụng một closure. Trong phương thức giveaway, chúng ta nhận sở thích của người dùng như một tham số kiểu Option<ShirtColor> và gọi phương thức unwrap_or_else trên user_preference. Phương thức unwrap_or_else trên Option<T> được định nghĩa bởi thư viện tiêu chuẩn. Nó lấy một đối số: một closure không có đối số nào trả về một giá trị T (cùng kiểu được lưu trữ trong biến thể Some của Option<T>, trong trường hợp này là ShirtColor). Nếu Option<T> là biến thể Some, unwrap_or_else trả về giá trị từ bên trong Some. Nếu Option<T> là biến thể None, unwrap_or_else gọi closure và trả về giá trị được trả về bởi closure.

Chúng ta chỉ định biểu thức closure || self.most_stocked() làm đối số cho unwrap_or_else. Đây là một closure không có tham số (nếu closure có tham số, chúng sẽ xuất hiện giữa hai dấu gạch đứng). Phần thân của closure gọi self.most_stocked(). Chúng ta đang định nghĩa closure ở đây, và việc triển khai của unwrap_or_else sẽ đánh giá closure sau này nếu kết quả là cần thiết.

Chạy mã này in ra:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

Một khía cạnh thú vị ở đây là chúng ta đã truyền một closure gọi self.most_stocked() trên phiên bản Inventory hiện tại. Thư viện tiêu chuẩn không cần biết bất cứ điều gì về các kiểu Inventory hoặc ShirtColor mà chúng ta đã định nghĩa, hoặc logic mà chúng ta muốn sử dụng trong kịch bản này. Closure capture một tham chiếu bất biến đến phiên bản self Inventory và truyền nó cùng với mã mà chúng ta chỉ định cho phương thức unwrap_or_else. Ngược lại, các hàm không thể capture môi trường của chúng theo cách này.

Suy Luận Kiểu và Chú Thích Closure

Có nhiều sự khác biệt giữa các hàm và closures. Closures thường không yêu cầu bạn phải chú thích kiểu của các tham số hoặc giá trị trả về như các hàm fn làm. Chú thích kiểu được yêu cầu trên các hàm vì các kiểu là một phần của giao diện rõ ràng được hiển thị cho người dùng của bạn. Định nghĩa giao diện này một cách nghiêm ngặt là quan trọng để đảm bảo rằng mọi người đồng ý về các kiểu giá trị mà một hàm sử dụng và trả về. Ngược lại, closures không được sử dụng trong một giao diện hiển thị như thế này: chúng được lưu trữ trong các biến và được sử dụng mà không cần đặt tên chúng và hiển thị chúng cho người dùng của thư viện chúng ta.

Closures thường ngắn gọn và chỉ liên quan trong một ngữ cảnh hẹp thay vì trong bất kỳ tình huống tùy ý nào. Trong các ngữ cảnh hạn chế này, trình biên dịch có thể suy ra các kiểu của tham số và kiểu trả về, tương tự như cách nó có thể suy ra kiểu của hầu hết các biến (có những trường hợp hiếm khi trình biên dịch cần chú thích kiểu closure).

Giống như với các biến, chúng ta có thể thêm chú thích kiểu nếu chúng ta muốn tăng sự rõ ràng và rõ ràng với chi phí là dài dòng hơn mức cần thiết. Chú thích các kiểu cho một closure sẽ trông giống như định nghĩa được hiển thị trong Listing 13-2. Trong ví dụ này, chúng ta đang định nghĩa một closure và lưu trữ nó trong một biến thay vì định nghĩa closure ở vị trí chúng ta truyền nó làm một đối số như chúng ta đã làm trong Listing 13-1.

use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}

Với chú thích kiểu được thêm vào, cú pháp của closures trông giống hơn với cú pháp của các hàm. Ở đây chúng ta định nghĩa một hàm thêm 1 vào tham số của nó và một closure có cùng hành vi, để so sánh. Chúng ta đã thêm một số khoảng trắng để căn chỉnh các phần liên quan. Điều này minh họa cách cú pháp closure tương tự với cú pháp hàm ngoại trừ việc sử dụng dấu gạch đứng và lượng cú pháp là tùy chọn:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

Dòng đầu tiên hiển thị định nghĩa hàm, và dòng thứ hai hiển thị một định nghĩa closure được chú thích đầy đủ. Trong dòng thứ ba, chúng ta loại bỏ các chú thích kiểu từ định nghĩa closure. Trong dòng thứ tư, chúng ta loại bỏ các dấu ngoặc nhọn, là tùy chọn vì thân closure chỉ có một biểu thức. Tất cả đều là các định nghĩa hợp lệ sẽ tạo ra cùng một hành vi khi chúng được gọi. Các dòng add_one_v3add_one_v4 yêu cầu các closure phải được đánh giá để có thể biên dịch vì các kiểu sẽ được suy ra từ việc sử dụng chúng. Điều này tương tự như let v = Vec::new(); cần chú thích kiểu hoặc giá trị của một số kiểu được chèn vào Vec để Rust có thể suy ra kiểu.

Đối với các định nghĩa closure, trình biên dịch sẽ suy ra một kiểu cụ thể cho mỗi tham số và cho giá trị trả về của chúng. Ví dụ, Listing 13-3 hiển thị định nghĩa của một closure ngắn chỉ trả về giá trị mà nó nhận được như một tham số. Closure này không hữu ích lắm ngoại trừ mục đích của ví dụ này. Lưu ý rằng chúng ta chưa thêm bất kỳ chú thích kiểu nào cho định nghĩa. Vì không có chú thích kiểu, chúng ta có thể gọi closure với bất kỳ kiểu nào, điều mà chúng ta đã làm ở đây với String lần đầu tiên. Nếu sau đó chúng ta thử gọi example_closure với một số nguyên, chúng ta sẽ gặp lỗi.

fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}

Trình biên dịch đưa ra lỗi này:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^- help: try using a conversion method: `.to_string()`
  |             |               |
  |             |               expected `String`, found integer
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error

Lần đầu tiên chúng ta gọi example_closure với giá trị String, trình biên dịch suy ra kiểu của x và kiểu trả về của closure là String. Những kiểu đó sau đó được khóa vào closure trong example_closure, và chúng ta nhận được một lỗi kiểu khi chúng ta tiếp theo cố gắng sử dụng một kiểu khác với cùng một closure.

Capture Tham Chiếu hoặc Di Chuyển Quyền Sở Hữu

Closures có thể capture các giá trị từ môi trường của chúng theo ba cách, điều này trực tiếp ánh xạ đến ba cách mà một hàm có thể lấy một tham số: mượn bất biến, mượn khả biến và lấy quyền sở hữu. Closure sẽ quyết định cái nào trong số này để sử dụng dựa trên những gì phần thân của hàm làm với các giá trị được capture.

Trong Listing 13-4, chúng ta định nghĩa một closure capture một tham chiếu bất biến đến vector có tên list vì nó chỉ cần một tham chiếu bất biến để in giá trị:

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}

Ví dụ này cũng minh họa rằng một biến có thể liên kết với một định nghĩa closure, và sau đó chúng ta có thể gọi closure bằng cách sử dụng tên biến và dấu ngoặc đơn như thể tên biến là tên hàm.

Bởi vì chúng ta có thể có nhiều tham chiếu bất biến đến list cùng một lúc, list vẫn có thể truy cập được từ mã trước khi định nghĩa closure, sau khi định nghĩa closure nhưng trước khi closure được gọi, và sau khi closure được gọi. Mã này biên dịch, chạy và in:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

Tiếp theo, trong Listing 13-5, chúng ta thay đổi phần thân closure để nó thêm một phần tử vào vector list. Closure bây giờ capture một tham chiếu khả biến:

fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}

Mã này biên dịch, chạy và in:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

Lưu ý rằng không còn println! giữa định nghĩa và lệnh gọi của closure borrows_mutably: khi borrows_mutably được định nghĩa, nó capture một tham chiếu khả biến đến list. Chúng ta không sử dụng closure nữa sau khi closure được gọi, vì vậy việc mượn khả biến kết thúc. Giữa định nghĩa closure và lệnh gọi closure, một tham chiếu bất biến để in không được phép vì không có tham chiếu khác nào được phép khi có một tham chiếu khả biến. Thử thêm một println! ở đó để xem bạn nhận được thông báo lỗi gì!

Nếu bạn muốn buộc closure lấy quyền sở hữu của các giá trị mà nó sử dụng trong môi trường mặc dù phần thân của closure không cần thiết phải có quyền sở hữu, bạn có thể sử dụng từ khóa move trước danh sách tham số.

Kỹ thuật này chủ yếu hữu ích khi truyền một closure cho một luồng mới để di chuyển dữ liệu để nó được sở hữu bởi luồng mới. Chúng ta sẽ thảo luận về luồng và tại sao bạn muốn sử dụng chúng chi tiết trong Chương 16 khi chúng ta nói về đồng thời, nhưng bây giờ, hãy khám phá nhanh việc tạo một luồng mới bằng cách sử dụng một closure cần từ khóa move. Listing 13-6 hiển thị Listing 13-4 đã được sửa đổi để in vector trong một luồng mới thay vì trong luồng chính:

use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}

Chúng ta tạo ra một luồng mới, cung cấp cho luồng một closure để chạy như một đối số. Phần thân closure in ra danh sách. Trong Listing 13-4, closure chỉ capture list bằng cách sử dụng một tham chiếu bất biến vì đó là lượng truy cập tối thiểu vào list cần thiết để in nó. Trong ví dụ này, mặc dù phần thân closure vẫn chỉ cần một tham chiếu bất biến, chúng ta cần chỉ định rằng list nên được di chuyển vào closure bằng cách đặt từ khóa move ở đầu định nghĩa closure. Luồng mới có thể kết thúc trước phần còn lại của luồng chính kết thúc, hoặc luồng chính có thể kết thúc trước. Nếu luồng chính duy trì quyền sở hữu của list nhưng kết thúc trước khi luồng mới kết thúc và loại bỏ list, tham chiếu bất biến trong luồng sẽ không hợp lệ. Do đó, trình biên dịch yêu cầu rằng list phải được di chuyển vào closure được cung cấp cho luồng mới để tham chiếu sẽ hợp lệ. Hãy thử loại bỏ từ khóa move hoặc sử dụng list trong luồng chính sau khi closure được định nghĩa để xem bạn nhận được lỗi biên dịch gì!

Di Chuyển Các Giá Trị Đã Capture Ra Khỏi Closures và Các Trait Fn

Một khi closure đã capture một tham chiếu hoặc capture quyền sở hữu của một giá trị từ môi trường nơi closure được định nghĩa (do đó ảnh hưởng đến việc gì, nếu có, được di chuyển vào closure), mã trong phần thân của closure định nghĩa những gì xảy ra với các tham chiếu hoặc giá trị khi closure được đánh giá sau đó (do đó ảnh hưởng đến việc gì, nếu có, được di chuyển ra khỏi closure). Phần thân closure có thể thực hiện bất kỳ điều nào sau đây: di chuyển một giá trị đã capture ra khỏi closure, thay đổi giá trị đã capture, không di chuyển cũng không thay đổi giá trị, hoặc không capture gì từ môi trường từ ban đầu.

Cách mà một closure capture và xử lý các giá trị từ môi trường ảnh hưởng đến các trait mà closure thực hiện, và trait là cách mà các hàm và struct có thể chỉ định loại closures nào chúng có thể sử dụng. Closures sẽ tự động thực hiện một, hai hoặc cả ba trait Fn này, theo cách cộng dồn, tùy thuộc vào cách mà phần thân closure xử lý các giá trị:

  1. FnOnce áp dụng cho các closure có thể được gọi một lần. Tất cả các closure thực hiện ít nhất trait này vì tất cả các closure có thể được gọi. Một closure di chuyển các giá trị đã capture ra khỏi phần thân của nó sẽ chỉ thực hiện FnOnce và không phải các trait Fn khác, vì nó chỉ có thể được gọi một lần.
  2. FnMut áp dụng cho các closure không di chuyển các giá trị đã capture ra khỏi phần thân của chúng, nhưng có thể thay đổi các giá trị đã capture. Các closure này có thể được gọi nhiều lần.
  3. Fn áp dụng cho các closure không di chuyển các giá trị đã capture ra khỏi phần thân của chúng và không thay đổi các giá trị đã capture, cũng như các closure không capture gì từ môi trường của chúng. Các closure này có thể được gọi nhiều lần mà không thay đổi môi trường của chúng, điều này quan trọng trong các trường hợp như gọi một closure nhiều lần đồng thời.

Hãy xem xét định nghĩa của phương thức unwrap_or_else trên Option<T> mà chúng ta đã sử dụng trong Listing 13-1:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

Nhớ lại rằng T là kiểu generic đại diện cho kiểu của giá trị trong biến thể Some của một Option. Kiểu T đó cũng là kiểu trả về của hàm unwrap_or_else: mã gọi unwrap_or_else trên một Option<String>, ví dụ, sẽ nhận được một String.

Tiếp theo, hãy lưu ý rằng hàm unwrap_or_else có tham số kiểu generic bổ sung F. Kiểu F là kiểu của tham số có tên f, đó là closure mà chúng ta cung cấp khi gọi unwrap_or_else.

Ràng buộc trait được chỉ định trên kiểu generic FFnOnce() -> T, có nghĩa là F phải có khả năng được gọi một lần, không nhận tham số, và trả về một T. Sử dụng FnOnce trong ràng buộc trait thể hiện ràng buộc rằng unwrap_or_else chỉ sẽ gọi f nhiều nhất một lần. Trong phần thân unwrap_or_else, chúng ta có thể thấy rằng nếu OptionSome, f sẽ không được gọi. Nếu OptionNone, f sẽ được gọi một lần. Bởi vì tất cả các closure đều thực hiện FnOnce, unwrap_or_else chấp nhận cả ba loại closure và linh hoạt nhất có thể.

Lưu ý: Nếu những gì chúng ta muốn làm không yêu cầu capture một giá trị từ môi trường, chúng ta có thể sử dụng tên của một hàm thay vì một closure. Ví dụ, chúng ta có thể gọi unwrap_or_else(Vec::new) trên một giá trị Option<Vec<T>> để nhận một vector mới, trống nếu giá trị là None. Trình biên dịch tự động thực hiện bất cứ trait Fn nào áp dụng được cho một định nghĩa hàm.

Bây giờ hãy xem xét phương thức thư viện tiêu chuẩn sort_by_key được định nghĩa trên slices, để xem nó khác với unwrap_or_else như thế nào và tại sao sort_by_key sử dụng FnMut thay vì FnOnce cho ràng buộc trait. Closure nhận một đối số dưới dạng một tham chiếu đến mục hiện tại trong slice đang được xem xét, và trả về một giá trị kiểu K có thể được sắp xếp. Hàm này hữu ích khi bạn muốn sắp xếp một slice theo một thuộc tính cụ thể của mỗi mục. Trong Listing 13-7, chúng ta có một danh sách các phiên bản Rectangle và chúng ta sử dụng sort_by_key để sắp xếp chúng theo thuộc tính width của chúng từ thấp đến cao:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{list:#?}");
}

Mã này in:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

Lý do sort_by_key được định nghĩa để lấy một closure FnMut là vì nó gọi closure nhiều lần: một lần cho mỗi mục trong slice. Closure |r| r.width không capture, thay đổi, hoặc di chuyển bất cứ thứ gì từ môi trường của nó, vì vậy nó đáp ứng các yêu cầu ràng buộc trait.

Ngược lại, Listing 13-8 hiển thị một ví dụ về một closure chỉ thực hiện trait FnOnce, vì nó di chuyển một giá trị ra khỏi môi trường. Trình biên dịch sẽ không cho phép chúng ta sử dụng closure này với sort_by_key:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}

Đây là một cách phức tạp và rắc rối (không hoạt động) để cố gắng đếm số lần sort_by_key gọi closure khi sắp xếp list. Mã này cố gắng làm điều này bằng cách đẩy value—một String từ môi trường của closure—vào vector sort_operations. Closure capture value và sau đó di chuyển value ra khỏi closure bằng cách chuyển quyền sở hữu của value cho vector sort_operations. Closure này có thể được gọi một lần; cố gắng gọi nó lần thứ hai sẽ không hoạt động vì value sẽ không còn trong môi trường để được đẩy vào sort_operations nữa! Do đó, closure này chỉ thực hiện FnOnce. Khi chúng ta cố gắng biên dịch mã này, chúng ta nhận được lỗi này rằng value không thể được di chuyển ra khỏi closure vì closure phải thực hiện FnMut:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         ----- captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
   |
help: consider cloning the value if the performance cost is acceptable
   |
18 |         sort_operations.push(value.clone());
   |                                   ++++++++

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 previous error

Lỗi chỉ ra dòng trong phần thân closure di chuyển value ra khỏi môi trường. Để sửa lỗi này, chúng ta cần thay đổi phần thân closure để nó không di chuyển các giá trị ra khỏi môi trường. Giữ một bộ đếm trong môi trường và tăng giá trị của nó trong phần thân closure là một cách trực tiếp hơn để đếm số lần closure được gọi. Closure trong Listing 13-9 làm việc với sort_by_key vì nó chỉ capture một tham chiếu khả biến đến bộ đếm num_sort_operations và do đó có thể được gọi nhiều hơn một lần:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{list:#?}, sorted in {num_sort_operations} operations");
}

Các trait Fn là quan trọng khi định nghĩa hoặc sử dụng các hàm hoặc kiểu mà sử dụng closures. Trong phần tiếp theo, chúng ta sẽ thảo luận về iterators. Nhiều phương thức iterator lấy các đối số closure, vì vậy hãy ghi nhớ các chi tiết về closure này khi chúng ta tiếp tục!

Xử lý Một Chuỗi Các Item với Iterators

Mẫu iterator cho phép bạn thực hiện một nhiệm vụ nào đó trên một chuỗi các phần tử lần lượt. Một iterator chịu trách nhiệm cho logic lặp qua từng phần tử và xác định khi nào chuỗi đã kết thúc. Khi bạn sử dụng iterators, bạn không phải tái thực hiện logic đó cho chính mình.

Trong Rust, iterators là lười biếng, nghĩa là chúng không có tác dụng gì cho đến khi bạn gọi các phương thức tiêu thụ iterator để sử dụng nó. Ví dụ, mã trong Listing 13-10 tạo một iterator trên các phần tử trong vector v1 bằng cách gọi phương thức iter được định nghĩa trên Vec<T>. Mã này tự nó không làm gì có ích.

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}

Iterator được lưu trữ trong biến v1_iter. Một khi chúng ta đã tạo một iterator, chúng ta có thể sử dụng nó theo nhiều cách khác nhau. Trong Listing 3-5 ở Chương 3, chúng ta đã lặp qua một mảng bằng vòng lặp for để thực thi một số mã trên mỗi phần tử của nó. Bên dưới, điều này ngầm tạo ra và sau đó tiêu thụ một iterator, nhưng chúng ta đã bỏ qua cách nó hoạt động chính xác cho đến bây giờ.

Trong ví dụ ở Listing 13-11, chúng ta tách việc tạo iterator khỏi việc sử dụng iterator trong vòng lặp for. Khi vòng lặp for được gọi sử dụng iterator trong v1_iter, mỗi phần tử trong iterator được sử dụng trong một lần lặp của vòng lặp, điều này in ra mỗi giá trị.

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}

Trong các ngôn ngữ không có iterators được cung cấp bởi thư viện chuẩn của chúng, bạn có thể sẽ viết cùng một chức năng này bằng cách bắt đầu với một biến ở chỉ số 0, sử dụng biến đó để truy cập vào vector để lấy một giá trị, và tăng giá trị biến trong một vòng lặp cho đến khi nó đạt đến tổng số phần tử trong vector.

Iterators xử lý tất cả logic đó cho bạn, cắt giảm mã lặp lại mà bạn có thể làm rối. Iterators cho bạn thêm sự linh hoạt để sử dụng cùng một logic với nhiều loại chuỗi khác nhau, không chỉ các cấu trúc dữ liệu mà bạn có thể truy cập theo chỉ mục, như vectors. Hãy xem cách iterators làm điều đó.

Trait Iterator và Phương thức next

Tất cả iterators đều thực hiện một trait có tên Iterator được định nghĩa trong thư viện chuẩn. Định nghĩa của trait trông như thế này:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // phương thức với các triển khai mặc định được lược bỏ
}
}

Chú ý rằng định nghĩa này sử dụng một số cú pháp mới: type ItemSelf::Item, đang định nghĩa một kiểu liên kết với trait này. Chúng ta sẽ nói về các kiểu liên kết một cách sâu sắc trong Chương 20. Hiện tại, tất cả những gì bạn cần biết là mã này nói rằng việc thực hiện trait Iterator yêu cầu bạn cũng phải định nghĩa một kiểu Item, và kiểu Item này được sử dụng trong kiểu trả về của phương thức next. Nói cách khác, kiểu Item sẽ là kiểu được trả về từ iterator.

Trait Iterator chỉ yêu cầu những người thực hiện định nghĩa một phương thức: phương thức next, trả về một phần tử của iterator mỗi lần, được bọc trong Some và, khi lặp kết thúc, trả về None.

Chúng ta có thể gọi phương thức next trực tiếp trên iterators; Listing 13-12 minh họa các giá trị nào được trả về từ các lệnh gọi lặp lại đến next trên iterator được tạo từ vector.

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}

Lưu ý rằng chúng ta cần làm cho v1_iter có thể thay đổi: gọi phương thức next trên một iterator thay đổi trạng thái nội bộ mà iterator sử dụng để theo dõi vị trí của nó trong chuỗi. Nói cách khác, mã này tiêu thụ, hoặc sử dụng hết, iterator. Mỗi lệnh gọi đến next tiêu thụ một phần tử từ iterator. Chúng ta không cần làm cho v1_iter có thể thay đổi khi chúng ta sử dụng vòng lặp for vì vòng lặp đã lấy quyền sở hữu của v1_iter và làm cho nó có thể thay đổi đằng sau hậu trường.

Cũng lưu ý rằng các giá trị chúng ta nhận được từ các lệnh gọi đến next là các tham chiếu bất biến đến các giá trị trong vector. Phương thức iter tạo ra một iterator trên các tham chiếu bất biến. Nếu chúng ta muốn tạo một iterator lấy quyền sở hữu của v1 và trả về các giá trị thuộc sở hữu, chúng ta có thể gọi into_iter thay vì iter. Tương tự, nếu chúng ta muốn lặp qua các tham chiếu có thể thay đổi, chúng ta có thể gọi iter_mut thay vì iter.

Các Phương thức Tiêu thụ Iterator

Trait Iterator có một số phương thức khác nhau với các triển khai mặc định được cung cấp bởi thư viện chuẩn; bạn có thể tìm hiểu về các phương thức này bằng cách xem tài liệu API thư viện chuẩn cho trait Iterator. Một số phương thức này gọi phương thức next trong định nghĩa của chúng, đó là lý do tại sao bạn được yêu cầu thực hiện phương thức next khi thực hiện trait Iterator.

Các phương thức gọi next được gọi là bộ điều hợp tiêu thụ vì gọi chúng sử dụng hết iterator. Một ví dụ là phương thức sum, lấy quyền sở hữu của iterator và lặp qua các phần tử bằng cách liên tục gọi next, do đó tiêu thụ iterator. Khi nó lặp qua, nó thêm mỗi phần tử vào một tổng đang chạy và trả về tổng khi lặp hoàn thành. Listing 13-13 có một test minh họa việc sử dụng phương thức sum.

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}

Chúng ta không được phép sử dụng v1_iter sau lệnh gọi đến sumsum lấy quyền sở hữu của iterator mà chúng ta gọi nó trên.

Các Phương thức tạo ra Iterator Khác

Bộ điều hợp iterator là các phương thức được định nghĩa trên trait Iterator không tiêu thụ iterator. Thay vào đó, chúng tạo ra các iterator khác nhau bằng cách thay đổi một số khía cạnh của iterator gốc.

Listing 13-14 hiển thị một ví dụ về việc gọi phương thức điều hợp iterator map, lấy một closure để gọi trên mỗi phần tử khi các phần tử được lặp qua. Phương thức map trả về một iterator mới tạo ra các phần tử đã được sửa đổi. Closure ở đây tạo ra một iterator mới trong đó mỗi phần tử từ vector sẽ được tăng lên 1:

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}

Tuy nhiên, mã này tạo ra một cảnh báo:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

Mã trong Listing 13-14 không làm gì cả; closure mà chúng ta đã chỉ định không bao giờ được gọi. Cảnh báo nhắc nhở chúng ta lý do tại sao: bộ điều hợp iterator là lười biếng, và chúng ta cần tiêu thụ iterator ở đây.

Để sửa cảnh báo này và tiêu thụ iterator, chúng ta sẽ sử dụng phương thức collect, mà chúng ta đã sử dụng trong Chương 12 với env::args trong Listing 12-1. Phương thức này tiêu thụ iterator và thu thập các giá trị kết quả vào một kiểu dữ liệu tập hợp.

Trong Listing 13-15, chúng ta thu thập các kết quả của việc lặp qua iterator được trả về từ lệnh gọi đến map vào một vector. Vector này sẽ kết thúc chứa mỗi phần tử từ vector gốc, tăng lên 1.

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}

Bởi vì map lấy một closure, chúng ta có thể chỉ định bất kỳ hoạt động nào mà chúng ta muốn thực hiện trên mỗi phần tử. Đây là một ví dụ tuyệt vời về cách closures cho phép bạn tùy chỉnh một số hành vi trong khi tái sử dụng hành vi lặp lại mà trait Iterator cung cấp.

Bạn có thể nối nhiều lệnh gọi đến bộ điều hợp iterator để thực hiện các hoạt động phức tạp theo cách dễ đọc. Nhưng bởi vì tất cả iterators đều lười biếng, bạn phải gọi một trong các phương thức bộ điều hợp tiêu thụ để có được kết quả từ các lệnh gọi đến bộ điều hợp iterator.

Sử dụng Closures Capture Môi trường của Chúng

Nhiều bộ điều hợp iterator lấy closure làm đối số, và thường các closure mà chúng ta sẽ chỉ định làm đối số cho bộ điều hợp iterator sẽ là closure capture môi trường của chúng.

Cho ví dụ này, chúng ta sẽ sử dụng phương thức filter nhận một closure. Closure lấy một phần tử từ iterator và trả về một bool. Nếu closure trả về true, giá trị sẽ được bao gồm trong lần lặp được tạo ra bởi filter. Nếu closure trả về false, giá trị sẽ không được bao gồm.

Trong Listing 13-16, chúng ta sử dụng filter với một closure capture biến shoe_size từ môi trường của nó để lặp qua một tập hợp các phiên bản struct Shoe. Nó sẽ chỉ trả về những đôi giày có kích thước được chỉ định.

#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}

Hàm shoes_in_size lấy quyền sở hữu của một vector giày và một kích thước giày làm tham số. Nó trả về một vector chỉ chứa giày có kích thước được chỉ định.

Trong phần thân của shoes_in_size, chúng ta gọi into_iter để tạo một iterator lấy quyền sở hữu của vector. Sau đó, chúng ta gọi filter để điều chỉnh iterator đó thành một iterator mới chỉ chứa các phần tử mà closure trả về true.

Closure capture tham số shoe_size từ môi trường và so sánh giá trị đó với kích thước của mỗi đôi giày, giữ lại chỉ những đôi giày có kích thước được chỉ định. Cuối cùng, gọi collect thu thập các giá trị được trả về bởi iterator đã điều chỉnh vào một vector được trả về bởi hàm.

Test cho thấy rằng khi chúng ta gọi shoes_in_size, chúng ta chỉ nhận lại những đôi giày có cùng kích thước với giá trị mà chúng ta đã chỉ định.

Cải thiện Dự án I/O của chúng ta

Với kiến thức mới về iterators, chúng ta có thể cải thiện dự án I/O trong Chương 12 bằng cách sử dụng iterators để làm cho những phần trong mã nguồn trở nên rõ ràng và ngắn gọn hơn. Hãy xem cách iterators có thể cải thiện cách triển khai hàm Config::build và hàm search của chúng ta.

Loại bỏ clone bằng cách sử dụng Iterator

Trong Listing 12-6, chúng ta đã thêm mã lấy một slice của các giá trị String và tạo một thể hiện của struct Config bằng cách lập chỉ mục vào slice và sao chép các giá trị, cho phép struct Config sở hữu những giá trị đó. Trong Listing 13-17, chúng ta đã tái hiện lại cách triển khai hàm Config::build như trong Listing 12-23.

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub 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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Vào thời điểm đó, chúng ta đã nói đừng lo lắng về việc gọi clone không hiệu quả vì chúng ta sẽ loại bỏ chúng trong tương lai. Vâng, thời điểm đó là bây giờ!

Chúng ta cần clone ở đây vì chúng ta có một slice với các phần tử String trong tham số args, nhưng hàm build không sở hữu args. Để trả về quyền sở hữu của một thể hiện Config, chúng ta đã phải sao chép giá trị từ trường queryfile_path của Config để thể hiện Config có thể sở hữu các giá trị của nó.

Với kiến thức mới của chúng ta về iterators, chúng ta có thể thay đổi hàm build để nhận quyền sở hữu một iterator làm đối số thay vì mượn một slice. Chúng ta sẽ sử dụng chức năng của iterator thay vì mã kiểm tra độ dài của slice và lập chỉ mục vào các vị trí cụ thể. Điều này sẽ làm rõ những gì hàm Config::build đang làm vì iterator sẽ truy cập các giá trị.

Một khi Config::build nắm quyền sở hữu iterator và ngừng sử dụng các thao tác lập chỉ mục đang mượn, chúng ta có thể di chuyển các giá trị String từ iterator vào Config thay vì gọi clone và tạo một phân bổ mới.

Sử dụng Iterator trả về trực tiếp

Mở file src/main.rs của dự án I/O, nó sẽ trông như thế này:

Filename: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

Đầu tiên chúng ta sẽ thay đổi phần đầu của hàm main mà chúng ta đã có trong Listing 12-24 thành mã trong Listing 13-18, lần này sử dụng một iterator. Điều này sẽ không biên dịch cho đến khi chúng ta cũng cập nhật Config::build.

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

Hàm env::args trả về một iterator! Thay vì thu thập các giá trị iterator vào một vector và sau đó truyền một slice cho Config::build, bây giờ chúng ta đang truyền quyền sở hữu của iterator được trả về từ env::args trực tiếp cho Config::build.

Tiếp theo, chúng ta cần cập nhật định nghĩa của Config::build. Trong file src/lib.rs của dự án I/O, hãy thay đổi chữ ký của Config::build để trông giống như Listing 13-19. Điều này vẫn chưa biên dịch được vì chúng ta cần cập nhật phần thân hàm.

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = 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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Tài liệu thư viện tiêu chuẩn cho hàm env::args cho thấy rằng kiểu của iterator nó trả về là std::env::Args, và kiểu đó triển khai trait Iterator và trả về các giá trị String.

Chúng ta đã cập nhật chữ ký của hàm Config::build để tham số args có một kiểu tổng quát với ràng buộc trait impl Iterator<Item = String> thay vì &[String]. Cách sử dụng cú pháp impl Trait mà chúng ta đã thảo luận trong phần "Traits as Parameters" của Chương 10 có nghĩa là args có thể là bất kỳ kiểu nào triển khai trait Iterator và trả về các mục String.

Bởi vì chúng ta đang lấy quyền sở hữu của args và chúng ta sẽ thay đổi args bằng cách lặp qua nó, chúng ta có thể thêm từ khóa mut vào chỉ định của tham số args để làm cho nó có thể thay đổi.

Sử dụng các phương thức Trait Iterator thay vì lập chỉ mục

Tiếp theo, chúng ta sẽ sửa phần thân của Config::build. Bởi vì args triển khai trait Iterator, chúng ta biết rằng chúng ta có thể gọi phương thức next trên nó! Listing 13-20 cập nhật mã từ Listing 12-23 để sử dụng phương thức next.

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Hãy nhớ rằng giá trị đầu tiên trong giá trị trả về của env::args là tên của chương trình. Chúng ta muốn bỏ qua nó và chuyển đến giá trị tiếp theo, vì vậy đầu tiên chúng ta gọi next và không làm gì với giá trị trả về. Sau đó chúng ta gọi next để lấy giá trị mà chúng ta muốn đặt vào trường query của Config. Nếu next trả về Some, chúng ta sử dụng match để trích xuất giá trị. Nếu nó trả về None, điều đó có nghĩa là không đủ đối số được đưa ra và chúng ta trả về sớm với một giá trị Err. Chúng ta cũng làm tương tự cho giá trị file_path.

Làm cho mã rõ ràng hơn với bộ điều hợp Iterator

Chúng ta cũng có thể tận dụng iterators trong hàm search trong dự án I/O của chúng ta, được tái hiện ở đây trong Listing 13-21 như trong Listing 12-19:

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> {
        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>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

Chúng ta có thể viết mã này một cách ngắn gọn hơn bằng cách sử dụng phương thức bộ điều hợp iterator. Làm như vậy cũng cho phép chúng ta tránh phải có một vector results trung gian có thể thay đổi. Phong cách lập trình hàm ưu tiên việc giảm thiểu lượng trạng thái có thể thay đổi để làm cho mã rõ ràng hơn. Loại bỏ trạng thái có thể thay đổi có thể cho phép một cải tiến trong tương lai để làm cho việc tìm kiếm diễn ra song song, vì chúng ta sẽ không phải quản lý truy cập đồng thời vào vector results. Listing 13-22 cho thấy sự thay đổi này:

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Nhớ lại rằng mục đích của hàm search là trả về tất cả các dòng trong contents có chứa query. Tương tự như ví dụ filter trong Listing 13-16, mã này sử dụng bộ điều hợp filter để chỉ giữ lại các dòng mà line.contains(query) trả về true. Sau đó, chúng ta thu thập các dòng phù hợp vào một vector khác với collect. Đơn giản hơn nhiều! Hãy tự nhiên thực hiện thay đổi tương tự để sử dụng phương thức iterator trong hàm search_case_insensitive.

Lựa chọn giữa Vòng lặp hoặc Iterators

Câu hỏi hợp lý tiếp theo là phong cách nào bạn nên chọn trong mã của riêng bạn và tại sao: triển khai ban đầu trong Listing 13-21 hoặc phiên bản sử dụng iterators trong Listing 13-22. Hầu hết các lập trình viên Rust đều thích sử dụng phong cách iterator. Nó hơi khó nắm bắt lúc đầu, nhưng một khi bạn đã cảm nhận được các bộ điều hợp iterator khác nhau và những gì chúng làm, iterators có thể dễ hiểu hơn. Thay vì phải vật lộn với các chi tiết khác nhau của vòng lặp và xây dựng vector mới, mã tập trung vào mục tiêu cấp cao của vòng lặp. Điều này trừu tượng hóa một số mã thông thường để dễ dàng hơn khi thấy các khái niệm độc đáo cho mã này, chẳng hạn như điều kiện lọc mỗi phần tử trong iterator phải vượt qua.

Nhưng hai cách triển khai có thực sự tương đương không? Giả định trực giác có thể là vòng lặp cấp thấp hơn sẽ nhanh hơn. Hãy nói về hiệu suất.

So sánh Hiệu suất: Vòng lặp và Iterator

Để quyết định nên sử dụng vòng lặp hay iterator, bạn cần biết triển khai nào nhanh hơn: phiên bản của hàm search với vòng lặp for rõ ràng hay phiên bản với iterator.

Chúng tôi đã chạy một bài kiểm tra hiệu suất bằng cách tải toàn bộ nội dung của The Adventures of Sherlock Holmes của Sir Arthur Conan Doyle vào một String và tìm kiếm từ the trong nội dung. Dưới đây là kết quả kiểm tra hiệu suất trên phiên bản search sử dụng vòng lặp for và phiên bản sử dụng iterator:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

Hai triển khai có hiệu suất tương tự! Chúng tôi sẽ không giải thích mã kiểm tra hiệu suất ở đây, bởi vì điểm quan trọng không phải để chứng minh rằng hai phiên bản là tương đương mà để có cảm nhận chung về cách hai triển khai này so sánh về mặt hiệu suất.

Để có một bài kiểm tra hiệu suất toàn diện hơn, bạn nên kiểm tra sử dụng các văn bản khác nhau với kích thước khác nhau làm contents, các từ khác nhau và các từ có độ dài khác nhau làm query, và tất cả các biến thể khác. Điểm quan trọng là: iterator, mặc dù là một trừu tượng cấp cao, được biên dịch thành mã gần giống như khi bạn tự viết mã cấp thấp. Iterator là một trong những trừu tượng không tốn chi phí của Rust, nghĩa là việc sử dụng trừu tượng không áp đặt thêm chi phí thời gian chạy. Điều này tương tự như cách Bjarne Stroustrup, nhà thiết kế và người triển khai ban đầu của C++, định nghĩa không tốn chi phí trong "Foundations of C++" (2012):

Nói chung, các triển khai C++ tuân theo nguyên tắc không tốn chi phí: Những gì bạn không sử dụng, bạn không phải trả giá. Và hơn nữa: Những gì bạn sử dụng, bạn không thể tự viết mã tốt hơn được.

Một ví dụ khác, đoạn mã sau được lấy từ một bộ giải mã âm thanh. Thuật toán giải mã sử dụng phép toán dự đoán tuyến tính để ước tính các giá trị tương lai dựa trên một hàm tuyến tính của các mẫu trước đó. Mã này sử dụng một chuỗi iterator để thực hiện một số phép toán trên ba biến trong phạm vi: một slice dữ liệu buffer, một mảng 12 coefficients, và một lượng để dịch chuyển dữ liệu trong qlp_shift. Chúng tôi đã khai báo các biến trong ví dụ này nhưng không gán giá trị cho chúng; mặc dù mã này không có nhiều ý nghĩa ngoài ngữ cảnh của nó, nó vẫn là một ví dụ ngắn gọn, thực tế về cách Rust chuyển đổi ý tưởng cấp cao thành mã cấp thấp.

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

Để tính toán giá trị của prediction, mã này lặp qua mỗi trong 12 giá trị trong coefficients và sử dụng phương thức zip để ghép cặp các giá trị hệ số với 12 giá trị trước đó trong buffer. Sau đó, với mỗi cặp, chúng ta nhân các giá trị với nhau, tổng hợp tất cả các kết quả, và dịch chuyển các bit trong tổng qlp_shift bit sang phải.

Các phép tính trong ứng dụng như bộ giải mã âm thanh thường ưu tiên hiệu suất cao nhất. Ở đây, chúng ta đang tạo một iterator, sử dụng hai bộ điều hợp, và sau đó tiêu thụ giá trị. Mã assembly nào mà mã Rust này sẽ được biên dịch thành? Vào thời điểm viết bài này, nó được biên dịch thành cùng mã assembly mà bạn sẽ viết bằng tay. Không có vòng lặp nào tương ứng với việc lặp qua các giá trị trong coefficients: Rust biết rằng có 12 lần lặp, vì vậy nó "mở rộng" vòng lặp. Mở rộng vòng lặp là một tối ưu hóa loại bỏ chi phí của mã điều khiển vòng lặp và thay vào đó tạo ra mã lặp lại cho mỗi lần lặp của vòng lặp.

Tất cả các hệ số được lưu trữ trong các thanh ghi, điều này có nghĩa là việc truy cập các giá trị rất nhanh. Không có kiểm tra giới hạn nào trên truy cập mảng trong thời gian chạy. Tất cả các tối ưu hóa mà Rust có thể áp dụng làm cho mã kết quả cực kỳ hiệu quả. Bây giờ bạn đã biết điều này, bạn có thể sử dụng iterator và closure mà không sợ hãi! Chúng làm cho mã có vẻ như ở cấp độ cao hơn nhưng không áp đặt hình phạt hiệu suất thời gian chạy khi làm như vậy.

Tóm tắt

Closure và iterator là các tính năng của Rust lấy cảm hứng từ ý tưởng ngôn ngữ lập trình hàm. Chúng đóng góp vào khả năng của Rust trong việc biểu đạt rõ ràng các ý tưởng cấp cao với hiệu suất cấp thấp. Các triển khai của closure và iterator là như vậy để hiệu suất thời gian chạy không bị ảnh hưởng. Đây là một phần trong mục tiêu của Rust nhằm cung cấp các trừu tượng không tốn chi phí.

Bây giờ chúng ta đã cải thiện khả năng biểu đạt của dự án I/O của mình, hãy xem xét một số tính năng khác của cargo sẽ giúp chúng ta chia sẻ dự án với thế giới.

Thêm về Cargo và Crates.io

Cho đến nay chúng ta mới chỉ sử dụng các tính năng cơ bản nhất của Cargo để xây dựng, chạy và kiểm thử mã của mình, nhưng nó có thể làm được nhiều hơn thế. Trong chương này, chúng ta sẽ thảo luận một số tính năng nâng cao khác để chỉ cho bạn cách thực hiện những việc sau:

  • Tùy chỉnh bản dựng thông qua các hồ sơ phát hành
  • Xuất bản thư viện lên crates.io
  • Tổ chức các dự án lớn với workspaces
  • Cài đặt các tệp nhị phân từ crates.io
  • Mở rộng Cargo bằng cách sử dụng các lệnh tùy chỉnh

Cargo thậm chí có thể làm nhiều hơn cả những chức năng mà chúng ta đề cập trong chương này, vì vậy để có lời giải thích đầy đủ về tất cả các tính năng của nó, hãy xem tài liệu của nó.

Tùy chỉnh Quá trình Xây dựng với Hồ sơ Phát hành

Trong Rust, hồ sơ phát hành (release profiles) là các hồ sơ được định nghĩa trước và có thể tùy chỉnh với các cấu hình khác nhau cho phép lập trình viên có thêm quyền kiểm soát đối với các tùy chọn biên dịch mã. Mỗi hồ sơ được cấu hình độc lập với các hồ sơ khác.

Cargo có hai hồ sơ chính: hồ sơ dev mà Cargo sử dụng khi bạn chạy cargo build và hồ sơ release mà Cargo sử dụng khi bạn chạy cargo build --release. Hồ sơ dev được định nghĩa với các giá trị mặc định tốt cho quá trình phát triển, và hồ sơ release có các giá trị mặc định tốt cho các bản dựng phát hành.

Những tên hồ sơ này có thể quen thuộc từ kết quả đầu ra của quá trình xây dựng của bạn:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

devrelease là các hồ sơ khác nhau được sử dụng bởi trình biên dịch.

Cargo có các cài đặt mặc định cho mỗi hồ sơ, áp dụng khi bạn chưa thêm rõ ràng bất kỳ phần [profile.*] nào vào tệp Cargo.toml của dự án. Bằng cách thêm các phần [profile.*] cho bất kỳ hồ sơ nào bạn muốn tùy chỉnh, bạn ghi đè lên bất kỳ tập hợp con nào của các cài đặt mặc định. Ví dụ, đây là các giá trị mặc định cho cài đặt opt-level của các hồ sơ devrelease:

Tên tệp: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

Cài đặt opt-level kiểm soát số lượng tối ưu hóa mà Rust sẽ áp dụng cho mã của bạn, với phạm vi từ 0 đến 3. Áp dụng nhiều tối ưu hóa hơn sẽ kéo dài thời gian biên dịch, vì vậy nếu bạn đang trong quá trình phát triển và thường xuyên biên dịch mã của mình, bạn sẽ muốn ít tối ưu hóa hơn để biên dịch nhanh hơn, ngay cả khi mã kết quả chạy chậm hơn. Do đó, opt-level mặc định cho dev0. Khi bạn đã sẵn sàng phát hành mã của mình, tốt nhất là dành nhiều thời gian hơn cho việc biên dịch. Bạn sẽ chỉ biên dịch ở chế độ phát hành một lần, nhưng bạn sẽ chạy chương trình đã biên dịch nhiều lần, vì vậy chế độ phát hành đánh đổi thời gian biên dịch lâu hơn lấy mã chạy nhanh hơn. Đó là lý do tại sao opt-level mặc định cho hồ sơ release3.

Bạn có thể ghi đè cài đặt mặc định bằng cách thêm một giá trị khác vào Cargo.toml. Ví dụ, nếu chúng ta muốn sử dụng mức tối ưu hóa 1 trong hồ sơ phát triển, chúng ta có thể thêm hai dòng này vào tệp Cargo.toml của dự án:

Tên tệp: Cargo.toml

[profile.dev]
opt-level = 1

Mã này ghi đè lên cài đặt mặc định là 0. Bây giờ khi chúng ta chạy cargo build, Cargo sẽ sử dụng các giá trị mặc định cho hồ sơ dev cộng với tùy chỉnh của chúng ta cho opt-level. Vì chúng ta đặt opt-level thành 1, Cargo sẽ áp dụng nhiều tối ưu hóa hơn so với mặc định, nhưng không nhiều như trong bản dựng phát hành.

Để biết danh sách đầy đủ các tùy chọn cấu hình và giá trị mặc định cho mỗi hồ sơ, hãy xem tài liệu của Cargo.

Xuất bản một Crate lên Crates.io

Chúng ta đã sử dụng các gói từ crates.io làm các phụ thuộc của dự án, nhưng bạn cũng có thể chia sẻ mã của mình với người khác bằng cách xuất bản các gói của riêng bạn. Sổ đăng ký crate tại crates.io phân phối mã nguồn của các gói của bạn, vì vậy nó chủ yếu lưu trữ mã nguồn mở.

Rust và Cargo có các tính năng giúp gói đã xuất bản của bạn dễ dàng được tìm thấy và sử dụng hơn. Chúng ta sẽ nói về một số tính năng này tiếp theo và sau đó giải thích cách xuất bản một gói.

Tạo Bình luận Tài liệu Hữu ích

Việc tài liệu hóa chính xác các gói sẽ giúp người dùng khác biết cách và khi nào sử dụng chúng, vì vậy đáng để đầu tư thời gian viết tài liệu. Trong Chương 3, chúng ta đã thảo luận về cách bình luận mã Rust bằng hai dấu gạch chéo, //. Rust cũng có một loại bình luận đặc biệt cho tài liệu, được gọi một cách thuận tiện là bình luận tài liệu, sẽ tạo ra tài liệu HTML. HTML hiển thị nội dung của các bình luận tài liệu cho các mục API công khai dành cho các lập trình viên quan tâm đến việc biết cách sử dụng crate của bạn thay vì cách crate của bạn được triển khai.

Bình luận tài liệu sử dụng ba dấu gạch chéo, ///, thay vì hai và hỗ trợ ký hiệu Markdown để định dạng văn bản. Đặt bình luận tài liệu ngay trước mục mà chúng tài liệu hóa. Listing 14-1 hiển thị bình luận tài liệu cho một hàm add_one trong một crate có tên my_crate.

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

Ở đây, chúng ta cung cấp mô tả về những gì hàm add_one làm, bắt đầu một phần với tiêu đề Examples, và sau đó cung cấp mã minh họa cách sử dụng hàm add_one. Chúng ta có thể tạo tài liệu HTML từ bình luận tài liệu này bằng cách chạy cargo doc. Lệnh này chạy công cụ rustdoc được phân phối với Rust và đặt tài liệu HTML được tạo ra trong thư mục target/doc.

Để thuận tiện, chạy cargo doc --open sẽ xây dựng HTML cho tài liệu của crate hiện tại (cũng như tài liệu cho tất cả các phụ thuộc của crate) và mở kết quả trong trình duyệt web. Điều hướng đến hàm add_one và bạn sẽ thấy cách văn bản trong bình luận tài liệu được hiển thị, như trong Hình 14-1:

Tài liệu HTML được tạo ra cho hàm `add_one` của `my_crate`

Hình 14-1: Tài liệu HTML cho hàm add_one

Các Phần Thường Được Sử dụng

Chúng ta đã sử dụng tiêu đề Markdown # Examples trong Listing 14-1 để tạo một phần trong HTML với tiêu đề "Examples." Dưới đây là một số phần khác mà tác giả crate thường sử dụng trong tài liệu của họ:

  • Panics: Các tình huống mà hàm đang được tài liệu hóa có thể gây panic. Người gọi hàm không muốn chương trình của họ bị panic nên đảm bảo rằng họ không gọi hàm trong những tình huống này.
  • Errors: Nếu hàm trả về một Result, mô tả các loại lỗi có thể xảy ra và điều kiện nào có thể gây ra những lỗi đó được trả về có thể hữu ích cho người gọi để họ có thể viết mã xử lý các loại lỗi khác nhau theo các cách khác nhau.
  • Safety: Nếu hàm là unsafe khi gọi (chúng ta thảo luận về tính không an toàn trong Chương 20), nên có một phần giải thích tại sao hàm không an toàn và đề cập đến các bất biến mà hàm mong đợi người gọi duy trì.

Hầu hết các bình luận tài liệu không cần tất cả các phần này, nhưng đây là một danh sách kiểm tra tốt để nhắc nhở bạn về các khía cạnh của mã mà người dùng sẽ quan tâm.

Bình luận Tài liệu như Các Bài Kiểm thử

Thêm các khối mã ví dụ trong bình luận tài liệu của bạn có thể giúp minh họa cách sử dụng thư viện của bạn, và làm như vậy có một lợi ích bổ sung: chạy cargo test sẽ chạy các ví dụ mã trong tài liệu của bạn như các bài kiểm thử! Không có gì tốt hơn tài liệu với các ví dụ. Nhưng không có gì tệ hơn các ví dụ không hoạt động vì mã đã thay đổi kể từ khi tài liệu được viết. Nếu chúng ta chạy cargo test với tài liệu cho hàm add_one từ Listing 14-1, chúng ta sẽ thấy một phần trong kết quả kiểm thử trông như thế này:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

Bây giờ, nếu chúng ta thay đổi hàm hoặc ví dụ để assert_eq! trong ví dụ gây panic và chạy cargo test một lần nữa, chúng ta sẽ thấy rằng các bài kiểm thử tài liệu phát hiện ra ví dụ và mã không còn đồng bộ với nhau!

Bình luận về Các Mục Chứa

Kiểu bình luận tài liệu //! thêm tài liệu vào mục chứa bình luận thay vì vào các mục sau bình luận. Chúng ta thường sử dụng các bình luận tài liệu này bên trong tệp gốc crate (src/lib.rs theo quy ước) hoặc bên trong một module để tài liệu hóa crate hoặc module nói chung.

Ví dụ, để thêm tài liệu mô tả mục đích của crate my_crate chứa hàm add_one, chúng ta thêm bình luận tài liệu bắt đầu bằng //! vào đầu tệp src/lib.rs, như trong Listing 14-2:

//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

Lưu ý rằng không có bất kỳ mã nào sau dòng cuối cùng bắt đầu bằng //!. Bởi vì chúng ta bắt đầu bình luận bằng //! thay vì ///, chúng ta đang tài liệu hóa mục chứa bình luận này thay vì một mục theo sau bình luận này. Trong trường hợp này, mục đó là tệp src/lib.rs, là gốc crate. Các bình luận này mô tả toàn bộ crate.

Khi chúng ta chạy cargo doc --open, các bình luận này sẽ hiển thị trên trang đầu của tài liệu cho my_crate phía trên danh sách các mục công khai trong crate, như trong Hình 14-2.

Tài liệu HTML được tạo ra với một bình luận cho toàn bộ crate

Hình 14-2: Tài liệu được tạo ra cho my_crate, bao gồm bình luận mô tả toàn bộ crate

Bình luận tài liệu trong các mục rất hữu ích để mô tả crates và modules đặc biệt. Sử dụng chúng để giải thích mục đích tổng thể của container để giúp người dùng hiểu tổ chức của crate.

Xuất một API Công khai Thuận tiện với pub use

Cấu trúc của API công khai của bạn là một cân nhắc quan trọng khi xuất bản một crate. Những người sử dụng crate của bạn ít quen thuộc với cấu trúc hơn bạn và có thể gặp khó khăn khi tìm các phần họ muốn sử dụng nếu crate của bạn có một cấu trúc phân cấp module lớn.

Trong Chương 7, chúng ta đã đề cập đến cách làm cho các mục công khai bằng cách sử dụng từ khóa pub, và đưa các mục vào phạm vi với từ khóa use. Tuy nhiên, cấu trúc có ý nghĩa đối với bạn trong khi phát triển một crate có thể không thuận tiện cho người dùng của bạn. Bạn có thể muốn tổ chức các struct của mình trong một hệ thống phân cấp chứa nhiều cấp, nhưng sau đó những người muốn sử dụng một kiểu bạn đã định nghĩa sâu trong hệ thống phân cấp có thể gặp khó khăn khi tìm ra kiểu đó tồn tại. Họ cũng có thể bực mình khi phải nhập use my_crate::some_module::another_module::UsefulType; thay vì use my_crate::UsefulType;.

Tin tốt là nếu cấu trúc không thuận tiện cho người khác sử dụng từ một thư viện khác, bạn không cần phải sắp xếp lại tổ chức nội bộ của mình: thay vào đó, bạn có thể tái xuất các mục để tạo một cấu trúc công khai khác với cấu trúc riêng tư của bạn bằng cách sử dụng pub use. Tái xuất lấy một mục công khai ở một vị trí và làm cho nó công khai ở một vị trí khác, như thể nó được định nghĩa ở vị trí khác đó.

Ví dụ, giả sử chúng ta đã tạo một thư viện có tên art để mô hình hóa các khái niệm nghệ thuật. Trong thư viện này có hai module: module kinds chứa hai enum có tên PrimaryColorSecondaryColor và module utils chứa một hàm có tên mix, như trong Listing 14-3:

//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}

Hình 14-3 hiển thị trang đầu tiên của tài liệu cho crate này được tạo ra bởi cargo doc sẽ trông như thế nào:

Tài liệu được tạo ra cho thư viện `art` liệt kê các module `kinds` và `utils`

Hình 14-3: Trang đầu tiên của tài liệu cho art liệt kê các module kindsutils

Lưu ý rằng các kiểu PrimaryColorSecondaryColor không được liệt kê trên trang đầu, cũng như hàm mix. Chúng ta phải nhấp vào kindsutils để xem chúng.

Một crate khác phụ thuộc vào thư viện này sẽ cần các câu lệnh use đưa các mục từ art vào phạm vi, chỉ định cấu trúc module hiện đang được định nghĩa. Listing 14-4 hiển thị một ví dụ về crate sử dụng các mục PrimaryColormix từ crate art:

use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

Tác giả của mã trong Listing 14-4, sử dụng crate art, đã phải tìm ra rằng PrimaryColor nằm trong module kindsmix nằm trong module utils. Cấu trúc module của crate art liên quan hơn đến các nhà phát triển làm việc trên crate art hơn là những người sử dụng nó. Cấu trúc nội bộ không chứa bất kỳ thông tin hữu ích nào cho ai đó đang cố gắng hiểu cách sử dụng crate art, mà thay vào đó gây ra nhầm lẫn vì các nhà phát triển sử dụng nó phải tìm ra nơi để tìm, và phải chỉ định tên module trong các câu lệnh use.

Để loại bỏ tổ chức nội bộ khỏi API công khai, chúng ta có thể sửa đổi mã crate art trong Listing 14-3 để thêm các câu lệnh pub use để tái xuất các mục ở cấp cao nhất, như trong Listing 14-5:

//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}

Tài liệu API mà cargo doc tạo ra cho crate này bây giờ sẽ liệt kê và liên kết các tái xuất trên trang đầu, như trong Hình 14-4, làm cho các kiểu PrimaryColorSecondaryColor và hàm mix dễ tìm hơn.

Tài liệu được tạo ra cho thư viện `art` với các tái xuất trên trang đầu

Hình 14-4: Trang đầu tiên của tài liệu cho art liệt kê các tái xuất

Người dùng crate art vẫn có thể thấy và sử dụng cấu trúc nội bộ từ Listing 14-3 như được minh họa trong Listing 14-4, hoặc họ có thể sử dụng cấu trúc thuận tiện hơn trong Listing 14-5, như được hiển thị trong Listing 14-6:

use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

Trong các trường hợp có nhiều module lồng nhau, tái xuất các kiểu ở cấp cao nhất với pub use có thể tạo ra sự khác biệt đáng kể trong trải nghiệm của những người sử dụng crate. Một cách sử dụng phổ biến khác của pub use là tái xuất các định nghĩa của một phụ thuộc trong crate hiện tại để làm cho các định nghĩa của crate đó trở thành một phần của API công khai của crate của bạn.

Tạo một cấu trúc API công khai hữu ích là một nghệ thuật hơn là một khoa học, và bạn có thể lặp lại để tìm API hoạt động tốt nhất cho người dùng của bạn. Chọn pub use cho bạn sự linh hoạt trong cách bạn cấu trúc crate của mình nội bộ và tách biệt cấu trúc nội bộ đó khỏi những gì bạn trình bày cho người dùng của bạn. Xem xét một số mã của crates bạn đã cài đặt để xem liệu cấu trúc nội bộ của chúng có khác với API công khai của chúng hay không.

Thiết lập Tài khoản Crates.io

Trước khi bạn có thể xuất bản bất kỳ crate nào, bạn cần tạo một tài khoản trên crates.io và nhận một token API. Để làm điều này, hãy truy cập trang chủ tại crates.io và đăng nhập thông qua tài khoản GitHub. (Tài khoản GitHub hiện là một yêu cầu, nhưng trang web có thể hỗ trợ các cách khác để tạo tài khoản trong tương lai.) Sau khi đăng nhập, hãy truy cập cài đặt tài khoản của bạn tại https://crates.io/me/ và lấy khóa API của bạn. Sau đó chạy lệnh cargo login và dán khóa API của bạn khi được nhắc, như thế này:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

Lệnh này sẽ thông báo cho Cargo về token API của bạn và lưu trữ nó cục bộ trong ~/.cargo/credentials. Lưu ý rằng token này là một bí mật: không chia sẻ nó với bất kỳ ai khác. Nếu bạn chia sẻ nó với bất kỳ ai vì bất kỳ lý do gì, bạn nên thu hồi nó và tạo một token mới trên crates.io.

Thêm Metadata cho một Crate Mới

Giả sử bạn có một crate bạn muốn xuất bản. Trước khi xuất bản, bạn sẽ cần thêm một số metadata trong phần [package] của tệp Cargo.toml của crate.

Crate của bạn sẽ cần một tên duy nhất. Trong khi bạn đang làm việc trên một crate cục bộ, bạn có thể đặt tên crate bất cứ điều gì bạn thích. Tuy nhiên, tên crate trên crates.io được phân bổ theo nguyên tắc ai đến trước được phục vụ trước. Một khi tên crate được lấy, không ai khác có thể xuất bản một crate với tên đó. Trước khi cố gắng xuất bản một crate, hãy tìm kiếm tên bạn muốn sử dụng. Nếu tên đã được sử dụng, bạn sẽ cần tìm một tên khác và chỉnh sửa trường name trong tệp Cargo.toml dưới phần [package] để sử dụng tên mới cho việc xuất bản, như sau:

Tên tệp: Cargo.toml

[package]
name = "guessing_game"

Ngay cả khi bạn đã chọn một tên duy nhất, khi bạn chạy cargo publish để xuất bản crate tại thời điểm này, bạn sẽ nhận được cảnh báo và sau đó là lỗi:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

Điều này dẫn đến lỗi vì bạn thiếu một số thông tin quan trọng: một mô tả và giấy phép là bắt buộc để mọi người biết crate của bạn làm gì và theo những điều khoản nào họ có thể sử dụng nó. Trong Cargo.toml, thêm một mô tả chỉ là một câu hoặc hai, vì nó sẽ xuất hiện với crate của bạn trong kết quả tìm kiếm. Đối với trường license, bạn cần cung cấp một giá trị định danh giấy phép. Trao đổi Dữ liệu Gói Phần mềm (SPDX) của Quỹ Linux liệt kê các định danh bạn có thể sử dụng cho giá trị này. Ví dụ, để chỉ định rằng bạn đã cấp phép crate của mình bằng Giấy phép MIT, hãy thêm định danh MIT:

Tên tệp: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

Nếu bạn muốn sử dụng giấy phép không xuất hiện trong SPDX, bạn cần đặt văn bản của giấy phép đó vào một tệp, bao gồm tệp đó trong dự án của bạn, và sau đó sử dụng license-file để chỉ định tên của tệp đó thay vì sử dụng khóa license.

Hướng dẫn về giấy phép nào phù hợp cho dự án của bạn nằm ngoài phạm vi của cuốn sách này. Nhiều người trong cộng đồng Rust cấp phép cho các dự án của họ theo cách giống như Rust bằng cách sử dụng giấy phép kép là MIT OR Apache-2.0. Thực hành này cho thấy bạn cũng có thể chỉ định nhiều định danh giấy phép được phân tách bằng OR để có nhiều giấy phép cho dự án của bạn.

Với một tên duy nhất, phiên bản, mô tả của bạn, và giấy phép được thêm vào, tệp Cargo.toml cho một dự án sẵn sàng xuất bản có thể trông như thế này:

Tên tệp: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "Một trò chơi vui nhộn nơi bạn đoán số mà máy tính đã chọn."
license = "MIT OR Apache-2.0"

[dependencies]

Tài liệu của Cargo mô tả các metadata khác bạn có thể chỉ định để đảm bảo người khác có thể khám phá và sử dụng crate của bạn dễ dàng hơn.

Xuất bản lên Crates.io

Bây giờ bạn đã tạo một tài khoản, lưu token API của bạn, chọn một tên cho crate của bạn, và chỉ định các metadata bắt buộc, bạn đã sẵn sàng để xuất bản! Xuất bản một crate tải lên một phiên bản cụ thể lên crates.io để người khác sử dụng.

Hãy cẩn thận, vì một lần xuất bản là vĩnh viễn. Phiên bản không bao giờ có thể được ghi đè, và mã không thể bị xóa. Một mục tiêu chính của crates.io là hoạt động như một kho lưu trữ vĩnh viễn của mã để các bản dựng của tất cả các dự án phụ thuộc vào crates từ crates.io sẽ tiếp tục hoạt động. Cho phép xóa phiên bản sẽ làm cho việc đạt được mục tiêu đó là không thể. Tuy nhiên, không có giới hạn về số lượng phiên bản crate bạn có thể xuất bản.

Chạy lệnh cargo publish một lần nữa. Lần này nó sẽ thành công:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

Chúc mừng! Bây giờ bạn đã chia sẻ mã của mình với cộng đồng Rust, và bất kỳ ai cũng có thể dễ dàng thêm crate của bạn làm phụ thuộc của dự án của họ.

Xuất bản Một Phiên bản Mới của Crate Hiện có

Khi bạn đã thực hiện các thay đổi đối với crate của mình và sẵn sàng phát hành một phiên bản mới, bạn thay đổi giá trị version được chỉ định trong tệp Cargo.toml của bạn và xuất bản lại. Sử dụng các quy tắc Semantic Versioning để quyết định một số phiên bản tiếp theo phù hợp dựa trên các loại thay đổi bạn đã thực hiện. Sau đó chạy cargo publish để tải lên phiên bản mới.

Không dùng nữa Các Phiên bản từ Crates.io với cargo yank

Mặc dù bạn không thể xóa các phiên bản trước đó của một crate, bạn có thể ngăn bất kỳ dự án tương lai nào thêm chúng làm phụ thuộc mới. Điều này hữu ích khi một phiên bản crate bị lỗi vì lý do nào đó. Trong những tình huống như vậy, Cargo hỗ trợ yanking (rút lại) một phiên bản crate.

Yanking một phiên bản ngăn các dự án mới phụ thuộc vào phiên bản đó trong khi cho phép tất cả các dự án hiện có phụ thuộc vào nó tiếp tục. Về cơ bản, rút lại có nghĩa là tất cả các dự án với Cargo.lock sẽ không bị hỏng, và bất kỳ tệp Cargo.lock nào được tạo ra trong tương lai sẽ không sử dụng phiên bản đã bị rút lại.

Để rút lại một phiên bản của crate, trong thư mục của crate mà bạn đã xuất bản trước đó, chạy cargo yank và chỉ định phiên bản bạn muốn rút lại. Ví dụ, nếu chúng ta đã xuất bản một crate có tên guessing_game phiên bản 1.0.1 và chúng ta muốn rút lại nó, trong thư mục dự án cho guessing_game chúng ta sẽ chạy:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

Bằng cách thêm --undo vào lệnh, bạn cũng có thể hoàn tác việc rút lại và cho phép các dự án bắt đầu phụ thuộc vào một phiên bản một lần nữa:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

Việc rút lại không xóa bất kỳ mã nào. Ví dụ, nó không thể xóa các bí mật vô tình được tải lên. Nếu điều đó xảy ra, bạn phải đặt lại các bí mật đó ngay lập tức.

Không gian làm việc Cargo

Trong Chương 12, chúng ta đã xây dựng một gói bao gồm một crate nhị phân và một crate thư viện. Khi dự án của bạn phát triển, bạn có thể thấy rằng crate thư viện tiếp tục trở nên lớn hơn và bạn muốn chia gói của mình thành nhiều crate thư viện hơn. Cargo cung cấp một tính năng gọi là workspaces (không gian làm việc) có thể giúp quản lý nhiều gói liên quan được phát triển cùng nhau.

Tạo một Workspace

Một workspace là một tập hợp các gói chia sẻ cùng một Cargo.lock và thư mục đầu ra. Hãy tạo một dự án sử dụng workspace—chúng ta sẽ sử dụng mã đơn giản để có thể tập trung vào cấu trúc của workspace. Có nhiều cách để cấu trúc một workspace, vì vậy chúng ta sẽ chỉ trình bày một cách phổ biến. Chúng ta sẽ có một workspace chứa một crate nhị phân và hai crate thư viện. Crate nhị phân, cung cấp chức năng chính, sẽ phụ thuộc vào hai thư viện. Một thư viện sẽ cung cấp hàm add_one và thư viện khác cung cấp hàm add_two. Ba crate này sẽ là một phần của cùng một workspace. Chúng ta sẽ bắt đầu bằng cách tạo một thư mục mới cho workspace:

$ mkdir add
$ cd add

Tiếp theo, trong thư mục add, chúng ta tạo tệp Cargo.toml sẽ cấu hình toàn bộ workspace. Tệp này sẽ không có phần [package]. Thay vào đó, nó sẽ bắt đầu với phần [workspace] cho phép chúng ta thêm thành viên vào workspace. Chúng ta cũng lưu ý sử dụng phiên bản mới nhất và tốt nhất của thuật toán resolver của Cargo trong workspace của chúng ta bằng cách đặt resolver thành "3".

Tên tệp: Cargo.toml

[workspace]
resolver = "3"

Tiếp theo, chúng ta sẽ tạo crate nhị phân adder bằng cách chạy cargo new trong thư mục add:

$ cargo new adder
    Creating binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

Chạy cargo new bên trong một workspace cũng tự động thêm gói mới tạo vào khóa members trong định nghĩa [workspace] trong Cargo.toml của workspace, như thế này:

[workspace]
resolver = "3"
members = ["adder"]

Tại thời điểm này, chúng ta có thể xây dựng workspace bằng cách chạy cargo build. Các tệp trong thư mục add của bạn sẽ trông như thế này:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Workspace có một thư mục target ở cấp cao nhất nơi các sản phẩm biên dịch sẽ được đặt vào; gói adder không có thư mục target riêng. Ngay cả khi chúng ta chạy cargo build từ bên trong thư mục adder, các sản phẩm biên dịch vẫn sẽ kết thúc trong add/target thay vì add/adder/target. Cargo cấu trúc thư mục target trong một workspace như thế này vì các crate trong một workspace được thiết kế để phụ thuộc vào nhau. Nếu mỗi crate có thư mục target riêng, mỗi crate sẽ phải biên dịch lại từng crate khác trong workspace để đặt các sản phẩm vào thư mục target riêng của nó. Bằng cách chia sẻ một thư mục target, các crate có thể tránh việc xây dựng lại không cần thiết.

Tạo Gói Thứ Hai trong Workspace

Tiếp theo, hãy tạo một gói thành viên khác trong workspace và gọi nó là add_one. Tạo một crate thư viện mới có tên add_one:

$ cargo new add_one --lib
    Creating library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

Cargo.toml cấp cao nhất sẽ bao gồm đường dẫn add_one trong danh sách members:

Tên tệp: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

Thư mục add của bạn bây giờ sẽ có các thư mục và tệp này:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Trong tệp add_one/src/lib.rs, hãy thêm một hàm add_one:

Tên tệp: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

Bây giờ chúng ta có thể để gói adder với chương trình nhị phân của chúng ta phụ thuộc vào gói add_one chứa thư viện của chúng ta. Trước tiên, chúng ta sẽ cần thêm một phụ thuộc đường dẫn vào add_one trong adder/Cargo.toml.

Tên tệp: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo không giả định rằng các crate trong một workspace sẽ phụ thuộc vào nhau, vì vậy chúng ta cần chỉ định rõ ràng mối quan hệ phụ thuộc.

Tiếp theo, hãy sử dụng hàm add_one (từ crate add_one) trong crate adder. Mở tệp adder/src/main.rs và thay đổi hàm main để gọi hàm add_one, như trong Listing 14-7.

fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}

Hãy xây dựng workspace bằng cách chạy cargo build trong thư mục add cấp cao nhất!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

Để chạy crate nhị phân từ thư mục add, chúng ta có thể chỉ định gói nào trong workspace chúng ta muốn chạy bằng cách sử dụng đối số -p và tên gói với cargo run:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

Điều này chạy mã trong adder/src/main.rs, mã này phụ thuộc vào crate add_one.

Phụ thuộc vào một Gói Bên ngoài trong một Workspace

Lưu ý rằng workspace chỉ có một tệp Cargo.lock ở cấp cao nhất, thay vì có Cargo.lock trong thư mục của mỗi crate. Điều này đảm bảo rằng tất cả các crate đều sử dụng cùng phiên bản của tất cả các phụ thuộc. Nếu chúng ta thêm gói rand vào tệp adder/Cargo.tomladd_one/Cargo.toml, Cargo sẽ giải quyết cả hai gói này thành một phiên bản của rand và ghi lại trong Cargo.lock duy nhất. Việc làm cho tất cả các crate trong workspace sử dụng cùng các phụ thuộc có nghĩa là các crate sẽ luôn tương thích với nhau. Hãy thêm crate rand vào phần [dependencies] trong tệp add_one/Cargo.toml để chúng ta có thể sử dụng crate rand trong crate add_one:

Tên tệp: add_one/Cargo.toml

[dependencies]
rand = "0.8.5"

Bây giờ chúng ta có thể thêm use rand; vào tệp add_one/src/lib.rs, và việc xây dựng toàn bộ workspace bằng cách chạy cargo build trong thư mục add sẽ mang rand crate vào và biên dịch nó. Chúng ta sẽ nhận được một cảnh báo vì chúng ta không tham chiếu đến rand mà chúng ta đã đưa vào phạm vi:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

Cargo.lock cấp cao nhất bây giờ chứa thông tin về phụ thuộc của add_one vào rand. Tuy nhiên, mặc dù rand được sử dụng ở đâu đó trong workspace, chúng ta không thể sử dụng nó trong các crate khác trong workspace trừ khi chúng ta thêm rand vào tệp Cargo.toml của họ. Ví dụ: nếu chúng ta thêm use rand; vào tệp adder/src/main.rs cho gói adder, chúng ta sẽ nhận được lỗi:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

Để sửa điều này, hãy chỉnh sửa tệp Cargo.toml cho gói adder và chỉ ra rằng rand cũng là một phụ thuộc cho nó. Việc xây dựng gói adder sẽ thêm rand vào danh sách phụ thuộc cho adder trong Cargo.lock, nhưng không có bản sao bổ sung nào của rand sẽ được tải xuống. Cargo sẽ đảm bảo rằng mọi crate trong mọi gói trong workspace sử dụng gói rand sẽ sử dụng cùng một phiên bản miễn là họ chỉ định các phiên bản tương thích của rand, tiết kiệm không gian cho chúng ta và đảm bảo rằng các crate trong workspace sẽ tương thích với nhau.

Nếu các crate trong workspace chỉ định các phiên bản không tương thích của cùng một phụ thuộc, Cargo sẽ giải quyết từng crate, nhưng vẫn cố gắng giải quyết càng ít phiên bản càng tốt.

Thêm một Bài Kiểm thử vào Workspace

Để một cải tiến khác, hãy thêm một bài kiểm thử cho hàm add_one::add_one trong crate add_one:

Tên tệp: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

Bây giờ chạy cargo test trong thư mục add cấp cao nhất. Chạy cargo test trong một workspace có cấu trúc như thế này sẽ chạy các bài kiểm thử cho tất cả các crate trong workspace:

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Phần đầu tiên của đầu ra cho thấy bài kiểm thử it_works trong crate add_one đã thành công. Phần tiếp theo cho thấy không tìm thấy bài kiểm thử nào trong crate adder, và sau đó phần cuối cùng cho thấy không tìm thấy bài kiểm thử tài liệu nào trong crate add_one.

Chúng ta cũng có thể chạy các bài kiểm thử cho một crate cụ thể trong workspace từ thư mục cấp cao nhất bằng cách sử dụng cờ -p và chỉ định tên của crate mà chúng ta muốn kiểm thử:

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Đầu ra này hiển thị cargo test chỉ chạy các bài kiểm thử cho crate add_one và không chạy các bài kiểm thử crate adder.

Nếu bạn xuất bản các crate trong workspace lên crates.io, mỗi crate trong workspace sẽ cần được xuất bản riêng. Giống như cargo test, chúng ta có thể xuất bản một crate cụ thể trong workspace của chúng ta bằng cách sử dụng cờ -p và chỉ định tên của crate mà chúng ta muốn xuất bản.

Để thực hành thêm, hãy thêm một crate add_two vào workspace này theo cách tương tự như crate add_one!

Khi dự án của bạn phát triển, hãy cân nhắc sử dụng một workspace: nó cho phép bạn làm việc với các thành phần nhỏ hơn, dễ hiểu hơn so với một đống mã lớn. Hơn nữa, việc giữ các crate trong một workspace có thể làm cho việc phối hợp giữa các crate dễ dàng hơn nếu chúng thường xuyên thay đổi cùng một lúc.

Cài đặt Tệp Nhị phân với cargo install

Lệnh cargo install cho phép bạn cài đặt và sử dụng các crate nhị phân một cách cục bộ. Điều này không nhằm mục đích thay thế các gói hệ thống; nó được thiết kế để cung cấp một cách thuận tiện cho các nhà phát triển Rust cài đặt các công cụ mà người khác đã chia sẻ trên crates.io. Lưu ý rằng bạn chỉ có thể cài đặt các gói có mục tiêu nhị phân. Một mục tiêu nhị phân là chương trình có thể chạy được tạo ra nếu crate có tệp src/main.rs hoặc một tệp khác được chỉ định là tệp nhị phân, khác với mục tiêu thư viện không thể chạy độc lập nhưng phù hợp để bao gồm trong các chương trình khác. Thông thường, các crate có thông tin trong tệp README về việc liệu một crate là thư viện, có mục tiêu nhị phân, hoặc cả hai.

Tất cả các tệp nhị phân được cài đặt với cargo install được lưu trữ trong thư mục bin của thư mục gốc cài đặt. Nếu bạn đã cài đặt Rust bằng rustup.rs và không có bất kỳ cấu hình tùy chỉnh nào, thư mục này sẽ là $HOME/.cargo/bin. Đảm bảo rằng thư mục đó nằm trong $PATH của bạn để có thể chạy các chương trình bạn đã cài đặt với cargo install.

Ví dụ, trong Chương 12 chúng ta đã đề cập rằng có một triển khai Rust của công cụ grep gọi là ripgrep để tìm kiếm tệp. Để cài đặt ripgrep, chúng ta có thể chạy như sau:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

Dòng áp chót của đầu ra hiển thị vị trí và tên của tệp nhị phân đã cài đặt, trong trường hợp của ripgreprg. Miễn là thư mục cài đặt nằm trong $PATH của bạn, như đã đề cập trước đó, bạn có thể chạy rg --help và bắt đầu sử dụng một công cụ nhanh hơn, "Rust hơn" để tìm kiếm tệp!

Mở rộng Cargo với Lệnh Tùy chỉnh

Cargo được thiết kế để bạn có thể mở rộng nó với các lệnh phụ mới mà không cần phải sửa đổi nó. Nếu một tệp nhị phân trong $PATH của bạn có tên là cargo-something, bạn có thể chạy nó như thể nó là một lệnh phụ của Cargo bằng cách chạy cargo something. Các lệnh tùy chỉnh như thế này cũng được liệt kê khi bạn chạy cargo --list. Khả năng sử dụng cargo install để cài đặt các phần mở rộng và sau đó chạy chúng giống như các công cụ Cargo tích hợp là một lợi ích cực kỳ thuận tiện từ thiết kế của Cargo!

Tóm tắt

Chia sẻ mã với Cargo và crates.io là một phần làm cho hệ sinh thái Rust hữu ích cho nhiều tác vụ khác nhau. Thư viện chuẩn của Rust nhỏ gọn và ổn định, nhưng các crate dễ dàng được chia sẻ, sử dụng và cải tiến theo một tiến độ khác với ngôn ngữ. Đừng ngần ngại chia sẻ mã hữu ích cho bạn trên crates.io; rất có thể nó cũng sẽ hữu ích cho người khác!

Con trỏ Thông minh

Một con trỏ là một khái niệm chung cho một biến chứa một địa chỉ trong bộ nhớ. Địa chỉ này tham chiếu tới, hoặc "trỏ tới," một số dữ liệu khác. Loại con trỏ phổ biến nhất trong Rust là tham chiếu, mà bạn đã học trong Chương 4. Tham chiếu được biểu thị bằng ký hiệu & và mượn giá trị mà chúng trỏ tới. Chúng không có bất kỳ khả năng đặc biệt nào ngoài việc tham chiếu đến dữ liệu, và chúng không có chi phí phụ.

Con trỏ thông minh, mặt khác, là các cấu trúc dữ liệu hoạt động như một con trỏ nhưng cũng có thêm siêu dữ liệu và khả năng. Khái niệm về con trỏ thông minh không phải là duy nhất trong Rust: con trỏ thông minh bắt nguồn từ C++ và cũng tồn tại trong các ngôn ngữ khác. Rust có nhiều loại con trỏ thông minh khác nhau được định nghĩa trong thư viện chuẩn cung cấp chức năng vượt xa so với tham chiếu. Để khám phá khái niệm chung, chúng ta sẽ xem xét một vài ví dụ khác nhau về con trỏ thông minh, bao gồm một kiểu con trỏ thông minh đếm tham chiếu. Con trỏ này cho phép bạn cho phép dữ liệu có nhiều chủ sở hữu bằng cách theo dõi số lượng chủ sở hữu và, khi không còn chủ sở hữu nào, dọn dẹp dữ liệu.

Rust, với khái niệm về quyền sở hữu và mượn, có một điểm khác biệt bổ sung giữa tham chiếu và con trỏ thông minh: trong khi tham chiếu chỉ mượn dữ liệu, trong nhiều trường hợp con trỏ thông minh sở hữu dữ liệu mà chúng trỏ tới.

Mặc dù chúng ta không gọi chúng như vậy tại thời điểm đó, chúng ta đã gặp một vài con trỏ thông minh trong cuốn sách này, bao gồm StringVec<T> trong Chương 8. Cả hai kiểu này đều được tính là con trỏ thông minh vì chúng sở hữu một số bộ nhớ và cho phép bạn thao tác với nó. Chúng cũng có siêu dữ liệu và khả năng hoặc đảm bảo bổ sung. String, ví dụ, lưu trữ dung lượng của nó dưới dạng siêu dữ liệu và có khả năng bổ sung để đảm bảo dữ liệu của nó luôn là UTF-8 hợp lệ.

Con trỏ thông minh thường được triển khai bằng cách sử dụng structs. Không giống như một struct thông thường, con trỏ thông minh triển khai các trait DerefDrop. Trait Deref cho phép một thể hiện của struct con trỏ thông minh hoạt động như một tham chiếu để bạn có thể viết mã của mình để làm việc với cả tham chiếu hoặc con trỏ thông minh. Trait Drop cho phép bạn tùy chỉnh mã được chạy khi một thể hiện của con trỏ thông minh ra khỏi phạm vi. Trong chương này, chúng ta sẽ thảo luận cả hai trait này và minh họa tại sao chúng quan trọng đối với con trỏ thông minh.

Do mẫu con trỏ thông minh là một mẫu thiết kế chung được sử dụng thường xuyên trong Rust, chương này sẽ không đề cập đến mọi con trỏ thông minh hiện có. Nhiều thư viện có con trỏ thông minh riêng, và bạn thậm chí có thể tự viết. Chúng ta sẽ đề cập đến những con trỏ thông minh phổ biến nhất trong thư viện chuẩn:

  • Box<T>, để phân bổ giá trị trên heap
  • Rc<T>, một kiểu đếm tham chiếu cho phép nhiều quyền sở hữu
  • Ref<T>RefMut<T>, được truy cập thông qua RefCell<T>, một kiểu thực thi quy tắc mượn tại thời điểm chạy thay vì thời điểm biên dịch

Ngoài ra, chúng ta sẽ đề cập đến mẫu tính thay đổi nội bộ nơi một kiểu không thể thay đổi hiển thị một API để thay đổi một giá trị bên trong. Chúng ta cũng sẽ thảo luận về chu kỳ tham chiếu: cách chúng có thể rò rỉ bộ nhớ và cách ngăn chặn chúng.

Hãy bắt đầu!

Sử dụng Box<T> để Trỏ đến Dữ liệu trên Heap

Con trỏ thông minh đơn giản nhất là một box, có kiểu được viết là Box<T>. Box cho phép bạn lưu trữ dữ liệu trên heap thay vì stack. Những gì còn lại trên stack là con trỏ đến dữ liệu heap. Xem lại Chương 4 để ôn lại sự khác biệt giữa stack và heap.

Box không tạo ra chi phí hiệu suất, ngoại trừ việc lưu trữ dữ liệu của chúng trên heap thay vì trên stack. Nhưng chúng cũng không có nhiều khả năng bổ sung. Bạn sẽ sử dụng chúng thường xuyên nhất trong những tình huống sau:

  • Khi bạn có một kiểu mà kích thước không thể biết được tại thời điểm biên dịch và bạn muốn sử dụng một giá trị của kiểu đó trong một ngữ cảnh yêu cầu kích thước chính xác
  • Khi bạn có một lượng lớn dữ liệu và bạn muốn chuyển quyền sở hữu nhưng đảm bảo dữ liệu sẽ không bị sao chép khi làm như vậy
  • Khi bạn muốn sở hữu một giá trị và bạn chỉ quan tâm rằng nó là một kiểu triển khai một trait cụ thể thay vì thuộc về một kiểu cụ thể

Chúng ta sẽ minh họa tình huống đầu tiên trong "Cho phép Kiểu Đệ quy với Boxes". Trong trường hợp thứ hai, việc chuyển quyền sở hữu của một lượng lớn dữ liệu có thể mất nhiều thời gian vì dữ liệu được sao chép trên stack. Để cải thiện hiệu suất trong tình huống này, chúng ta có thể lưu trữ lượng lớn dữ liệu trên heap trong một box. Sau đó, chỉ có một lượng nhỏ dữ liệu con trỏ được sao chép trên stack, trong khi dữ liệu mà nó tham chiếu vẫn ở một vị trí trên heap. Trường hợp thứ ba được gọi là đối tượng trait, và "Sử dụng Đối tượng Trait Cho phép Các Giá trị Có Kiểu Khác nhau," trong Chương 18 được dành cho chủ đề đó. Vì vậy, những gì bạn học ở đây sẽ áp dụng lại trong phần đó!

Sử dụng Box<T> để Lưu trữ Dữ liệu trên Heap

Trước khi chúng ta thảo luận về trường hợp sử dụng lưu trữ heap cho Box<T>, chúng ta sẽ đề cập đến cú pháp và cách tương tác với các giá trị được lưu trữ trong một Box<T>.

Listing 15-1 cho thấy cách sử dụng box để lưu trữ một giá trị i32 trên heap.

fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}

Chúng ta định nghĩa biến b có giá trị là một Box trỏ đến giá trị 5, được cấp phát trên heap. Chương trình này sẽ in ra b = 5; trong trường hợp này, chúng ta có thể truy cập dữ liệu trong box tương tự như cách chúng ta sẽ làm nếu dữ liệu này nằm trên stack. Giống như bất kỳ giá trị sở hữu nào, khi một box ra khỏi phạm vi, như b ở cuối main, nó sẽ bị giải phóng. Việc giải phóng diễn ra cả cho box (được lưu trữ trên stack) và dữ liệu mà nó trỏ đến (được lưu trữ trên heap).

Đặt một giá trị đơn lẻ trên heap không quá hữu ích, vì vậy bạn sẽ không thường xuyên sử dụng box theo cách này. Có các giá trị như một i32 đơn lẻ trên stack, nơi chúng được lưu trữ theo mặc định, thích hợp hơn trong đa số trường hợp. Hãy xem xét một trường hợp trong đó box cho phép chúng ta định nghĩa các kiểu mà chúng ta không được phép nếu không có box.

Cho phép Kiểu Đệ quy với Boxes

Một giá trị của kiểu đệ quy có thể có một giá trị khác của cùng kiểu như một phần của chính nó. Kiểu đệ quy gây ra vấn đề vì Rust cần biết tại thời điểm biên dịch kiểu chiếm bao nhiêu không gian. Tuy nhiên, việc lồng các giá trị của kiểu đệ quy về mặt lý thuyết có thể tiếp tục vô hạn, vì vậy Rust không thể biết giá trị cần bao nhiêu không gian. Vì box có kích thước đã biết, chúng ta có thể cho phép kiểu đệ quy bằng cách chèn một box vào định nghĩa kiểu đệ quy.

Ví dụ về một kiểu đệ quy, hãy khám phá cons list. Đây là một kiểu dữ liệu thường thấy trong các ngôn ngữ lập trình hàm. Kiểu cons list mà chúng ta sẽ định nghĩa khá đơn giản ngoại trừ phần đệ quy; do đó, các khái niệm trong ví dụ chúng ta sẽ làm việc sẽ hữu ích bất cứ khi nào bạn gặp phải các tình huống phức tạp hơn liên quan đến kiểu đệ quy.

Thêm Thông tin về Cons List

Một cons list là một cấu trúc dữ liệu xuất phát từ ngôn ngữ lập trình Lisp và các phương ngữ của nó, được tạo thành từ các cặp lồng nhau, và là phiên bản Lisp của danh sách liên kết. Tên của nó xuất phát từ hàm cons (viết tắt của construct function) trong Lisp xây dựng một cặp mới từ hai đối số của nó. Bằng cách gọi cons trên một cặp bao gồm một giá trị và một cặp khác, chúng ta có thể xây dựng các cons list được tạo thành từ các cặp đệ quy.

Ví dụ, đây là biểu diễn mã giả của một cons list chứa danh sách 1, 2, 3 với mỗi cặp trong ngoặc đơn:

(1, (2, (3, Nil)))

Mỗi phần tử trong một cons list chứa hai thành phần: giá trị của phần tử hiện tại và phần tử tiếp theo. Phần tử cuối cùng trong danh sách chỉ chứa một giá trị gọi là Nil mà không có phần tử tiếp theo. Một cons list được tạo ra bằng cách gọi đệ quy hàm cons. Tên quy ước để biểu thị trường hợp cơ sở của đệ quy là Nil. Lưu ý rằng điều này không giống với khái niệm "null" hoặc "nil" được thảo luận trong Chương 6, là một giá trị không hợp lệ hoặc vắng mặt.

Cons list không phải là cấu trúc dữ liệu thường được sử dụng trong Rust. Hầu hết thời gian khi bạn có một danh sách các mục trong Rust, Vec<T> là một lựa chọn tốt hơn để sử dụng. Các kiểu dữ liệu đệ quy phức tạp khác thực sự hữu ích trong nhiều tình huống khác nhau, nhưng bằng cách bắt đầu với cons list trong chương này, chúng ta có thể khám phá cách box cho phép chúng ta định nghĩa một kiểu dữ liệu đệ quy mà không bị phân tâm quá nhiều.

Listing 15-2 chứa định nghĩa enum cho một cons list. Lưu ý rằng mã này sẽ chưa biên dịch được vì kiểu List không có kích thước đã biết, điều này chúng ta sẽ minh họa.

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

Lưu ý: Chúng ta đang triển khai một cons list chỉ chứa các giá trị i32 cho mục đích của ví dụ này. Chúng ta có thể đã triển khai nó bằng cách sử dụng generics, như chúng ta đã thảo luận trong Chương 10, để định nghĩa một kiểu cons list có thể lưu trữ các giá trị của bất kỳ kiểu nào.

Sử dụng kiểu List để lưu trữ danh sách 1, 2, 3 sẽ trông giống như mã trong Listing 15-3.

enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

Giá trị Cons đầu tiên chứa 1 và một giá trị List khác. Giá trị List này là một giá trị Cons khác chứa 2 và một giá trị List khác. Giá trị List này là một giá trị Cons nữa chứa 3 và một giá trị List, cuối cùng là Nil, biến thể không đệ quy biểu thị kết thúc của danh sách.

Nếu chúng ta cố gắng biên dịch mã trong Listing 15-3, chúng ta sẽ gặp lỗi như trong Listing 15-4.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors

Lỗi cho thấy kiểu này "có kích thước vô hạn." Lý do là chúng ta đã định nghĩa List với một biến thể đệ quy: nó trực tiếp chứa một giá trị khác của chính nó. Kết quả là, Rust không thể tìm ra cần bao nhiêu không gian để lưu trữ một giá trị List. Hãy phân tích tại sao chúng ta gặp lỗi này. Đầu tiên, chúng ta sẽ xem cách Rust quyết định cần bao nhiêu không gian để lưu trữ một giá trị của một kiểu không đệ quy.

Tính toán Kích thước của một Kiểu Không Đệ quy

Nhớ lại enum Message mà chúng ta đã định nghĩa trong Listing 6-2 khi chúng ta thảo luận về định nghĩa enum trong Chương 6:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Để xác định bao nhiêu không gian để cấp phát cho một giá trị Message, Rust đi qua từng biến thể để xem biến thể nào cần nhiều không gian nhất. Rust thấy rằng Message::Quit không cần bất kỳ không gian nào, Message::Move cần đủ không gian để lưu trữ hai giá trị i32, và vân vân. Vì chỉ một biến thể sẽ được sử dụng, không gian nhiều nhất mà một giá trị Message sẽ cần là không gian cần thiết để lưu trữ biến thể lớn nhất trong số các biến thể của nó.

Đối chiếu với điều xảy ra khi Rust cố gắng xác định bao nhiêu không gian mà một kiểu đệ quy như enum List trong Listing 15-2 cần. Trình biên dịch bắt đầu bằng cách nhìn vào biến thể Cons, biến thể này chứa một giá trị của kiểu i32 và một giá trị của kiểu List. Do đó, Cons cần một lượng không gian bằng kích thước của i32 cộng với kích thước của List. Để tìm ra bộ nhớ mà kiểu List cần, trình biên dịch xem xét các biến thể, bắt đầu với biến thể Cons. Biến thể Cons chứa một giá trị kiểu i32 và một giá trị kiểu List, và quá trình này tiếp tục vô hạn, như được minh họa trong Hình 15-1.

Một Cons list vô hạn

Hình 15-1: Một List vô hạn bao gồm các biến thể Cons vô hạn

Sử dụng Box<T> để Có được Kiểu Đệ quy với Kích thước Đã biết

Vì Rust không thể tìm ra bao nhiêu không gian để cấp phát cho các kiểu được định nghĩa đệ quy, trình biên dịch đưa ra một lỗi với gợi ý hữu ích này:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

Trong gợi ý này, sự gián tiếp có nghĩa là thay vì lưu trữ một giá trị trực tiếp, chúng ta nên thay đổi cấu trúc dữ liệu để lưu trữ giá trị gián tiếp bằng cách lưu trữ một con trỏ đến giá trị thay vì lưu trữ giá trị trực tiếp.

Box<T> là một con trỏ, Rust luôn biết bao nhiêu không gian mà một Box<T> cần: kích thước của một con trỏ không thay đổi dựa trên lượng dữ liệu mà nó trỏ đến. Điều này có nghĩa là chúng ta có thể đặt một Box<T> bên trong biến thể Cons thay vì một giá trị List trực tiếp. Box<T> sẽ trỏ đến giá trị List tiếp theo sẽ nằm trên heap thay vì bên trong biến thể Cons. Về mặt khái niệm, chúng ta vẫn có một danh sách, được tạo ra với các danh sách chứa các danh sách khác, nhưng việc triển khai này bây giờ giống như đặt các mục cạnh nhau thay vì bên trong nhau.

Chúng ta có thể thay đổi định nghĩa của enum List trong Listing 15-2 và cách sử dụng List trong Listing 15-3 thành mã trong Listing 15-5, mã này sẽ biên dịch được.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

Biến thể Cons cần kích thước của một i32 cộng với không gian để lưu trữ dữ liệu con trỏ của box. Biến thể Nil không lưu trữ giá trị nào, nên nó cần ít không gian hơn biến thể Cons. Bây giờ chúng ta biết rằng bất kỳ giá trị List nào sẽ chiếm kích thước của một i32 cộng với kích thước của dữ liệu con trỏ của box. Bằng cách sử dụng box, chúng ta đã phá vỡ chuỗi đệ quy vô hạn, vì vậy trình biên dịch có thể tìm ra kích thước cần thiết để lưu trữ một giá trị List. Hình 15-2 cho thấy biến thể Cons trông như thế nào bây giờ.

Một Cons list hữu hạn

Hình 15-2: Một List không có kích thước vô hạn vì Cons chứa một Box

Box chỉ cung cấp sự gián tiếp và cấp phát heap; chúng không có bất kỳ khả năng đặc biệt nào khác, như những gì chúng ta sẽ thấy với các kiểu con trỏ thông minh khác. Chúng cũng không có chi phí hiệu suất mà các khả năng đặc biệt này gây ra, vì vậy chúng có thể hữu ích trong các trường hợp như cons list, nơi sự gián tiếp là tính năng duy nhất chúng ta cần. Chúng ta sẽ xem xét thêm các trường hợp sử dụng cho box trong Chương 18.

Kiểu Box<T> là một con trỏ thông minh vì nó triển khai trait Deref, cho phép các giá trị Box<T> được xử lý như tham chiếu. Khi một giá trị Box<T> ra khỏi phạm vi, dữ liệu heap mà box đang trỏ đến cũng được dọn dẹp nhờ vào việc triển khai trait Drop. Hai trait này sẽ càng quan trọng hơn đối với chức năng được cung cấp bởi các kiểu con trỏ thông minh khác mà chúng ta sẽ thảo luận trong phần còn lại của chương này. Hãy khám phá hai trait này chi tiết hơn.

Xử lý Con trỏ Thông minh Như Tham chiếu Thông thường với Deref

Việc triển khai trait Deref cho phép bạn tùy chỉnh hành vi của toán tử giải tham chiếu * (không nên nhầm lẫn với toán tử nhân hoặc toán tử glob). Bằng cách triển khai Deref theo cách mà một con trỏ thông minh có thể được xử lý như một tham chiếu thông thường, bạn có thể viết mã hoạt động trên tham chiếu và sử dụng mã đó với con trỏ thông minh.

Đầu tiên, hãy xem cách toán tử giải tham chiếu hoạt động với tham chiếu thông thường. Sau đó, chúng ta sẽ thử định nghĩa một kiểu tùy chỉnh hoạt động giống như Box<T>, và xem tại sao toán tử giải tham chiếu không hoạt động như một tham chiếu trên kiểu mới định nghĩa của chúng ta. Chúng ta sẽ khám phá cách triển khai trait Deref khiến con trỏ thông minh có thể hoạt động tương tự như tham chiếu. Sau đó, chúng ta sẽ xem tính năng chuyển đổi giải tham chiếu (deref coercion) của Rust và cách nó cho phép chúng ta làm việc với cả tham chiếu hoặc con trỏ thông minh.

Lưu ý: Có một sự khác biệt lớn giữa kiểu MyBox<T> mà chúng ta sắp xây dựng và Box<T> thực tế: phiên bản của chúng ta sẽ không lưu trữ dữ liệu trên heap. Chúng ta đang tập trung ví dụ này vào Deref, vì vậy nơi dữ liệu thực sự được lưu trữ ít quan trọng hơn hành vi giống con trỏ.

Theo dõi Con trỏ đến Giá trị

Một tham chiếu thông thường là một loại con trỏ, và một cách để nghĩ về con trỏ là như một mũi tên đến một giá trị được lưu trữ ở đâu đó. Trong Listing 15-6, chúng ta tạo một tham chiếu đến một giá trị i32 và sau đó sử dụng toán tử giải tham chiếu để theo dõi tham chiếu đến giá trị.

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Biến x chứa một giá trị i325. Chúng ta đặt y bằng một tham chiếu đến x. Chúng ta có thể khẳng định rằng x bằng 5. Tuy nhiên, nếu chúng ta muốn khẳng định về giá trị trong y, chúng ta phải sử dụng *y để theo dõi tham chiếu đến giá trị mà nó đang trỏ tới (do đó giải tham chiếu) để trình biên dịch có thể so sánh giá trị thực. Một khi chúng ta giải tham chiếu y, chúng ta có quyền truy cập vào giá trị số nguyên mà y đang trỏ tới mà chúng ta có thể so sánh với 5.

Nếu chúng ta cố gắng viết assert_eq!(5, y); thay vào đó, chúng ta sẽ nhận được lỗi biên dịch này:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

So sánh một số và một tham chiếu đến một số không được phép vì chúng là các kiểu khác nhau. Chúng ta phải sử dụng toán tử giải tham chiếu để theo dõi tham chiếu đến giá trị mà nó đang trỏ tới.

Sử dụng Box<T> Như một Tham chiếu

Chúng ta có thể viết lại mã trong Listing 15-6 để sử dụng Box<T> thay vì một tham chiếu; toán tử giải tham chiếu được sử dụng trên Box<T> trong Listing 15-7 hoạt động theo cùng một cách như toán tử giải tham chiếu được sử dụng trên tham chiếu trong Listing 15-6:

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Sự khác biệt chính giữa Listing 15-7 và Listing 15-6 là ở đây chúng ta đặt y là một thực thể của một box trỏ đến một giá trị sao chép của x thay vì một tham chiếu trỏ đến giá trị của x. Trong khẳng định cuối cùng, chúng ta có thể sử dụng toán tử giải tham chiếu để theo dõi con trỏ của box theo cách tương tự như khi y là một tham chiếu. Tiếp theo, chúng ta sẽ khám phá điều gì đặc biệt về Box<T> cho phép chúng ta sử dụng toán tử giải tham chiếu bằng cách định nghĩa kiểu của riêng chúng ta.

Định nghĩa Con trỏ Thông minh Riêng của Chúng ta

Hãy xây dựng một con trỏ thông minh tương tự như kiểu Box<T> được cung cấp bởi thư viện chuẩn để trải nghiệm cách con trỏ thông minh hoạt động khác với tham chiếu theo mặc định. Sau đó, chúng ta sẽ xem cách thêm khả năng sử dụng toán tử giải tham chiếu.

Kiểu Box<T> cuối cùng được định nghĩa như một tuple struct với một phần tử, vì vậy Listing 15-8 định nghĩa một kiểu MyBox<T> theo cách tương tự. Chúng ta cũng sẽ định nghĩa một hàm new để phù hợp với hàm new được định nghĩa trên Box<T>.

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}

Chúng ta định nghĩa một struct có tên MyBox và khai báo một tham số generic T vì chúng ta muốn kiểu của chúng ta giữ các giá trị của bất kỳ kiểu nào. Kiểu MyBox là một tuple struct với một phần tử kiểu T. Hàm MyBox::new nhận một tham số kiểu T và trả về một thực thể MyBox chứa giá trị được truyền vào.

Hãy thử thêm hàm main trong Listing 15-7 vào Listing 15-8 và thay đổi nó để sử dụng kiểu MyBox<T> mà chúng ta đã định nghĩa thay vì Box<T>. Mã trong Listing 15-9 sẽ không biên dịch vì Rust không biết cách giải tham chiếu MyBox.

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Đây là lỗi biên dịch kết quả:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

Kiểu MyBox<T> của chúng ta không thể được giải tham chiếu vì chúng ta chưa triển khai khả năng đó trên kiểu của chúng ta. Để kích hoạt giải tham chiếu với toán tử *, chúng ta triển khai trait Deref.

Triển khai Trait Deref

Như đã thảo luận trong "Triển khai một Trait trên một Kiểu" trong Chương 10, để triển khai một trait, chúng ta cần cung cấp các triển khai cho các phương thức yêu cầu của trait. Trait Deref, được cung cấp bởi thư viện chuẩn, yêu cầu chúng ta triển khai một phương thức có tên deref mượn self và trả về một tham chiếu đến dữ liệu bên trong. Listing 15-10 chứa một triển khai của Deref để thêm vào định nghĩa của MyBox<T>.

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Cú pháp type Target = T; định nghĩa một kiểu liên kết cho trait Deref để sử dụng. Kiểu liên kết là một cách khác để khai báo một tham số generic, nhưng bạn không cần phải lo lắng về chúng bây giờ; chúng ta sẽ đề cập đến chúng chi tiết hơn trong Chương 20.

Chúng ta điền vào thân của phương thức deref với &self.0 để deref trả về một tham chiếu đến giá trị mà chúng ta muốn truy cập với toán tử *; nhớ lại từ "Sử dụng Tuple Structs Không Có Trường Có Tên để Tạo Các Kiểu Khác nhau" trong Chương 5 rằng .0 truy cập giá trị đầu tiên trong một tuple struct. Hàm main trong Listing 15-9 gọi * trên giá trị MyBox<T> bây giờ biên dịch được, và các khẳng định đều thành công!

Không có trait Deref, trình biên dịch chỉ có thể giải tham chiếu tham chiếu &. Phương thức deref cung cấp cho trình biên dịch khả năng lấy một giá trị của bất kỳ kiểu nào triển khai Deref và gọi phương thức deref để lấy tham chiếu & mà nó biết cách giải tham chiếu.

Khi chúng ta nhập *y trong Listing 15-9, đằng sau hậu trường Rust thực sự chạy mã này:

*(y.deref())

Rust thay thế toán tử * bằng một lệnh gọi đến phương thức deref và sau đó là một giải tham chiếu thông thường để chúng ta không phải suy nghĩ về việc liệu chúng ta có cần gọi phương thức deref hay không. Tính năng Rust này cho phép chúng ta viết mã hoạt động giống hệt nhau dù chúng ta có một tham chiếu thông thường hay một kiểu triển khai Deref.

Lý do phương thức deref trả về một tham chiếu đến một giá trị, và giải tham chiếu thông thường bên ngoài dấu ngoặc đơn trong *(y.deref()) vẫn cần thiết, liên quan đến hệ thống sở hữu. Nếu phương thức deref trả về giá trị trực tiếp thay vì một tham chiếu đến giá trị, giá trị sẽ bị di chuyển ra khỏi self. Chúng ta không muốn lấy quyền sở hữu của giá trị bên trong MyBox<T> trong trường hợp này hoặc trong hầu hết các trường hợp khi chúng ta sử dụng toán tử giải tham chiếu.

Lưu ý rằng toán tử * được thay thế bằng một lệnh gọi đến phương thức deref và sau đó là một lệnh gọi đến toán tử * chỉ một lần, mỗi lần chúng ta sử dụng * trong mã của chúng ta. Vì sự thay thế của toán tử * không đệ quy vô hạn, chúng ta kết thúc với dữ liệu kiểu i32, phù hợp với 5 trong assert_eq! trong Listing 15-9.

Chuyển đổi Giải tham chiếu Ngầm với Hàm và Phương thức

Chuyển đổi giải tham chiếu (Deref coercion) chuyển đổi một tham chiếu đến một kiểu triển khai trait Deref thành một tham chiếu đến một kiểu khác. Ví dụ, chuyển đổi giải tham chiếu có thể chuyển đổi &String thành &strString triển khai trait Deref sao cho nó trả về &str. Chuyển đổi giải tham chiếu là một tiện ích Rust thực hiện trên các đối số cho hàm và phương thức, và chỉ hoạt động trên các kiểu triển khai trait Deref. Nó xảy ra tự động khi chúng ta truyền một tham chiếu đến giá trị của một kiểu cụ thể làm đối số cho một hàm hoặc phương thức mà không khớp với kiểu tham số trong định nghĩa hàm hoặc phương thức. Một chuỗi các lệnh gọi đến phương thức deref chuyển đổi kiểu chúng ta đã cung cấp thành kiểu mà tham số cần.

Chuyển đổi giải tham chiếu được thêm vào Rust để các lập trình viên viết lệnh gọi hàm và phương thức không cần thêm quá nhiều tham chiếu và giải tham chiếu rõ ràng với &*. Tính năng chuyển đổi giải tham chiếu cũng cho phép chúng ta viết nhiều mã hơn có thể hoạt động cho cả tham chiếu hoặc con trỏ thông minh.

Để thấy chuyển đổi giải tham chiếu trong hành động, hãy sử dụng kiểu MyBox<T> mà chúng ta đã định nghĩa trong Listing 15-8 cũng như triển khai của Deref mà chúng ta đã thêm vào trong Listing 15-10. Listing 15-11 hiển thị định nghĩa của một hàm có tham số là một lát chuỗi.

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}

Chúng ta có thể gọi hàm hello với một lát chuỗi làm đối số, chẳng hạn như hello("Rust");. Chuyển đổi giải tham chiếu làm cho việc gọi hello với một tham chiếu đến một giá trị kiểu MyBox<String> trở nên khả thi, như trong Listing 15-12.

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

Ở đây chúng ta đang gọi hàm hello với đối số &m, là một tham chiếu đến một giá trị MyBox<String>. Vì chúng ta đã triển khai trait Deref trên MyBox<T> trong Listing 15-10, Rust có thể chuyển đổi &MyBox<String> thành &String bằng cách gọi deref. Thư viện chuẩn cung cấp một triển khai của Deref trên String trả về một lát chuỗi, và điều này có trong tài liệu API cho Deref. Rust gọi deref một lần nữa để chuyển đổi &String thành &str, khớp với định nghĩa của hàm hello.

Nếu Rust không triển khai chuyển đổi giải tham chiếu, chúng ta sẽ phải viết mã trong Listing 15-13 thay vì mã trong Listing 15-12 để gọi hello với một giá trị kiểu &MyBox<String>.

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

(*m) giải tham chiếu MyBox<String> thành một String. Sau đó, &[..] lấy một lát chuỗi của String bằng với toàn bộ chuỗi để khớp với chữ ký của hello. Mã này không có chuyển đổi giải tham chiếu khó đọc, viết và hiểu hơn với tất cả các ký hiệu liên quan. Chuyển đổi giải tham chiếu cho phép Rust xử lý các chuyển đổi này cho chúng ta tự động.

Khi trait Deref được định nghĩa cho các kiểu liên quan, Rust sẽ phân tích các kiểu và sử dụng Deref::deref nhiều lần nếu cần để lấy một tham chiếu khớp với kiểu của tham số. Số lần cần thiết để chèn Deref::deref được giải quyết tại thời điểm biên dịch, vì vậy không có hình phạt thời gian chạy khi tận dụng lợi thế của chuyển đổi giải tham chiếu!

Cách Chuyển đổi Giải tham chiếu Tương tác với Tính Thay đổi

Tương tự như cách bạn sử dụng trait Deref để ghi đè lên toán tử * trên tham chiếu không thay đổi, bạn có thể sử dụng trait DerefMut để ghi đè lên toán tử * trên tham chiếu có thể thay đổi.

Rust thực hiện chuyển đổi giải tham chiếu khi nó tìm thấy các kiểu và triển khai trait trong ba trường hợp:

  1. Từ &T đến &U khi T: Deref<Target=U>
  2. Từ &mut T đến &mut U khi T: DerefMut<Target=U>
  3. Từ &mut T đến &U khi T: Deref<Target=U>

Hai trường hợp đầu tiên giống nhau ngoại trừ trường hợp thứ hai triển khai tính thay đổi. Trường hợp đầu tiên nêu rằng nếu bạn có một &T, và T triển khai Deref đến một kiểu U nào đó, bạn có thể nhận được một &U một cách trong suốt. Trường hợp thứ hai nêu rằng sự chuyển đổi giải tham chiếu tương tự cũng xảy ra cho tham chiếu có thể thay đổi.

Trường hợp thứ ba phức tạp hơn: Rust cũng sẽ chuyển đổi một tham chiếu có thể thay đổi thành một tham chiếu không thay đổi. Nhưng điều ngược lại không khả thi: tham chiếu không thay đổi sẽ không bao giờ chuyển đổi thành tham chiếu có thể thay đổi. Vì quy tắc mượn, nếu bạn có một tham chiếu có thể thay đổi, tham chiếu có thể thay đổi đó phải là tham chiếu duy nhất đến dữ liệu đó (nếu không, chương trình sẽ không biên dịch). Chuyển đổi một tham chiếu có thể thay đổi thành một tham chiếu không thay đổi sẽ không bao giờ phá vỡ quy tắc mượn. Chuyển đổi một tham chiếu không thay đổi thành một tham chiếu có thể thay đổi sẽ yêu cầu tham chiếu không thay đổi ban đầu là tham chiếu không thay đổi duy nhất đến dữ liệu đó, nhưng quy tắc mượn không đảm bảo điều đó. Do đó, Rust không thể đưa ra giả định rằng việc chuyển đổi một tham chiếu không thay đổi thành một tham chiếu có thể thay đổi là khả thi.

Chạy Mã khi Dọn dẹp với Trait Drop

Trait quan trọng thứ hai cho mẫu con trỏ thông minh là Drop, cho phép bạn tùy chỉnh những gì xảy ra khi một giá trị sắp ra khỏi phạm vi. Bạn có thể cung cấp một triển khai cho trait Drop trên bất kỳ kiểu nào, và mã đó có thể được sử dụng để giải phóng tài nguyên như tệp hoặc kết nối mạng.

Chúng ta giới thiệu Drop trong ngữ cảnh của con trỏ thông minh vì chức năng của trait Drop gần như luôn được sử dụng khi triển khai một con trỏ thông minh. Ví dụ, khi một Box<T> bị hủy, nó sẽ giải phóng không gian trên heap mà box trỏ tới.

Trong một số ngôn ngữ, đối với một số kiểu, lập trình viên phải gọi mã để giải phóng bộ nhớ hoặc tài nguyên mỗi khi họ sử dụng xong một thể hiện của những kiểu đó. Ví dụ bao gồm file handles, sockets và locks. Nếu họ quên, hệ thống có thể bị quá tải và gặp sự cố. Trong Rust, bạn có thể chỉ định một đoạn mã cụ thể được chạy bất cứ khi nào một giá trị ra khỏi phạm vi, và trình biên dịch sẽ tự động chèn mã này. Kết quả là, bạn không cần phải cẩn thận đặt mã dọn dẹp ở mọi nơi trong chương trình mà một thể hiện của một kiểu cụ thể đã hoàn thành—bạn vẫn sẽ không rò rỉ tài nguyên!

Bạn chỉ định mã để chạy khi một giá trị ra khỏi phạm vi bằng cách triển khai trait Drop. Trait Drop yêu cầu bạn triển khai một phương thức có tên drop nhận một tham chiếu có thể thay đổi tới self. Để xem khi nào Rust gọi drop, hãy triển khai drop với các câu lệnh println! tạm thời.

Listing 15-14 hiển thị một struct CustomSmartPointer mà chức năng tùy chỉnh duy nhất của nó là sẽ in Dropping CustomSmartPointer! khi thể hiện ra khỏi phạm vi, để hiển thị khi Rust chạy phương thức drop.

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}

Trait Drop được bao gồm trong prelude, vì vậy chúng ta không cần phải đưa nó vào phạm vi. Chúng ta triển khai trait Drop trên CustomSmartPointer và cung cấp một triển khai cho phương thức drop gọi println!. Phần thân của phương thức drop là nơi bạn sẽ đặt bất kỳ logic nào mà bạn muốn chạy khi một thể hiện của kiểu của bạn ra khỏi phạm vi. Chúng ta đang in một số văn bản ở đây để minh họa trực quan khi Rust sẽ gọi drop.

Trong main, chúng ta tạo hai thể hiện của CustomSmartPointer và sau đó in CustomSmartPointers created. Ở cuối main, các thể hiện của CustomSmartPointer của chúng ta sẽ ra khỏi phạm vi, và Rust sẽ gọi mã mà chúng ta đặt trong phương thức drop, in thông báo cuối cùng của chúng ta. Lưu ý rằng chúng ta không cần phải gọi phương thức drop một cách rõ ràng.

Khi chúng ta chạy chương trình này, chúng ta sẽ thấy đầu ra sau:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

Rust tự động gọi drop cho chúng ta khi các thể hiện của chúng ta ra khỏi phạm vi, gọi mã mà chúng ta đã chỉ định. Các biến được hủy theo thứ tự ngược lại so với việc tạo, vì vậy d đã bị hủy trước c. Mục đích của ví dụ này là cung cấp cho bạn một hướng dẫn trực quan về cách phương thức drop hoạt động; thông thường, bạn sẽ chỉ định mã dọn dẹp mà kiểu của bạn cần chạy thay vì một thông báo in.

Không may, việc vô hiệu hóa chức năng drop tự động không đơn giản. Vô hiệu hóa drop thường không cần thiết; toàn bộ ý nghĩa của trait Drop là nó được xử lý tự động. Tuy nhiên, đôi khi bạn có thể muốn dọn dẹp một giá trị sớm. Một ví dụ là khi sử dụng con trỏ thông minh quản lý khóa: bạn có thể muốn buộc phương thức drop giải phóng khóa để mã khác trong cùng phạm vi có thể lấy khóa. Rust không cho phép bạn gọi phương thức drop của trait Drop theo cách thủ công; thay vào đó, bạn phải gọi hàm std::mem::drop được cung cấp bởi thư viện chuẩn nếu bạn muốn buộc một giá trị bị hủy trước khi kết thúc phạm vi của nó.

Nếu chúng ta cố gắng gọi phương thức drop của trait Drop theo cách thủ công bằng cách sửa đổi hàm main từ Listing 15-14, như trong Listing 15-15, chúng ta sẽ gặp lỗi trình biên dịch.

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}

Khi chúng ta cố gắng biên dịch mã này, chúng ta sẽ gặp lỗi này:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 |     drop(c);
   |     +++++ ~

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` (bin "drop-example") due to 1 previous error

Thông báo lỗi này nêu rõ rằng chúng ta không được phép gọi drop một cách rõ ràng. Thông báo lỗi sử dụng thuật ngữ destructor, đó là thuật ngữ lập trình chung cho một hàm dọn dẹp một thể hiện. Một destructor tương tự như một constructor, tạo ra một thể hiện. Hàm drop trong Rust là một destructor cụ thể.

Rust không cho phép chúng ta gọi drop một cách rõ ràng vì Rust vẫn sẽ tự động gọi drop trên giá trị ở cuối main. Điều này sẽ gây ra lỗi double free vì Rust sẽ cố gắng dọn dẹp cùng một giá trị hai lần.

Chúng ta không thể vô hiệu hóa việc chèn drop tự động khi một giá trị ra khỏi phạm vi, và chúng ta không thể gọi phương thức drop một cách rõ ràng. Vì vậy, nếu chúng ta cần buộc một giá trị được dọn dẹp sớm, chúng ta sử dụng hàm std::mem::drop.

Hàm std::mem::drop khác với phương thức drop trong trait Drop. Chúng ta gọi nó bằng cách truyền một đối số là giá trị mà chúng ta muốn buộc hủy. Hàm này nằm trong prelude, vì vậy chúng ta có thể sửa đổi main trong Listing 15-15 để gọi hàm drop, như trong Listing 15-16.

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}

Chạy mã này sẽ in ra như sau:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

Văn bản Dropping CustomSmartPointer with data `some data`! được in giữa văn bản CustomSmartPointer created.CustomSmartPointer dropped before the end of main., cho thấy rằng mã phương thức drop được gọi để hủy c tại thời điểm đó.

Bạn có thể sử dụng mã được chỉ định trong triển khai trait Drop theo nhiều cách để làm cho việc dọn dẹp trở nên thuận tiện và an toàn: ví dụ, bạn có thể sử dụng nó để tạo bộ cấp phát bộ nhớ của riêng bạn! Với trait Drop và hệ thống sở hữu của Rust, bạn không phải nhớ dọn dẹp vì Rust làm điều đó tự động.

Bạn cũng không phải lo lắng về các vấn đề do vô tình dọn dẹp các giá trị vẫn đang được sử dụng: hệ thống sở hữu đảm bảo các tham chiếu luôn hợp lệ cũng đảm bảo rằng drop chỉ được gọi một lần khi giá trị không còn được sử dụng.

Bây giờ chúng ta đã xem xét Box<T> và một số đặc điểm của con trỏ thông minh, hãy xem một vài con trỏ thông minh khác được định nghĩa trong thư viện chuẩn.

Rc<T>, Con Trỏ Thông Minh Đếm Tham Chiếu

Trong đa số trường hợp, quyền sở hữu (ownership) rất rõ ràng: bạn biết chính xác biến nào sở hữu một giá trị cụ thể. Tuy nhiên, có những trường hợp một giá trị có thể có nhiều chủ sở hữu. Ví dụ, trong cấu trúc dữ liệu đồ thị, nhiều cạnh có thể trỏ đến cùng một nút, và nút đó về mặt khái niệm được sở hữu bởi tất cả các cạnh trỏ đến nó. Một nút không nên được dọn dẹp trừ khi nó không có bất kỳ cạnh nào trỏ đến và do đó không có chủ sở hữu nào.

Bạn phải kích hoạt quyền sở hữu đa chủ một cách rõ ràng bằng cách sử dụng kiểu Rust Rc<T>, viết tắt của reference counting (đếm tham chiếu). Kiểu Rc<T> theo dõi số lượng tham chiếu đến một giá trị để xác định liệu giá trị đó có còn đang được sử dụng hay không. Nếu không có tham chiếu nào đến một giá trị, giá trị có thể được dọn dẹp mà không làm mất hiệu lực bất kỳ tham chiếu nào.

Hãy tưởng tượng Rc<T> như một chiếc TV trong phòng khách gia đình. Khi một người vào xem TV, họ bật nó lên. Những người khác có thể vào phòng và xem TV. Khi người cuối cùng rời khỏi phòng, họ tắt TV vì nó không còn được sử dụng nữa. Nếu ai đó tắt TV trong khi những người khác vẫn đang xem, sẽ có sự phản đối từ những người xem TV còn lại!

Chúng ta sử dụng kiểu Rc<T> khi muốn cấp phát dữ liệu trên heap cho nhiều phần của chương trình đọc và chúng ta không thể xác định tại thời điểm biên dịch phần nào sẽ kết thúc việc sử dụng dữ liệu cuối cùng. Nếu chúng ta biết phần nào sẽ kết thúc cuối cùng, chúng ta có thể đơn giản làm cho phần đó trở thành chủ sở hữu của dữ liệu, và các quy tắc sở hữu thông thường được thực thi tại thời điểm biên dịch sẽ có hiệu lực.

Lưu ý rằng Rc<T> chỉ dùng trong các tình huống đơn luồng. Khi chúng ta thảo luận về đồng thời trong Chương 16, chúng ta sẽ đề cập đến cách thực hiện đếm tham chiếu trong các chương trình đa luồng.

Sử dụng Rc<T> để Chia Sẻ Dữ Liệu

Hãy quay lại ví dụ về danh sách cons trong Listing 15-5. Nhớ rằng chúng ta đã định nghĩa nó bằng Box<T>. Lần này, chúng ta sẽ tạo hai danh sách đều chia sẻ quyền sở hữu của một danh sách thứ ba. Về mặt khái niệm, điều này trông giống như Hình 15-3.

Hai danh sách chia sẻ quyền sở hữu của một danh sách thứ ba

Hình 15-3: Hai danh sách, bc, chia sẻ quyền sở hữu của danh sách thứ ba, a

Chúng ta sẽ tạo danh sách a chứa 5 và sau đó là 10. Sau đó, chúng ta sẽ tạo thêm hai danh sách nữa: b bắt đầu bằng 3c bắt đầu bằng 4. Cả hai danh sách bc sẽ tiếp tục với danh sách a đầu tiên chứa 510. Nói cách khác, cả hai danh sách sẽ chia sẻ danh sách đầu tiên chứa 510.

Việc cố gắng triển khai kịch bản này bằng định nghĩa List với Box<T> sẽ không hoạt động, như được hiển thị trong Listing 15-17:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

Khi chúng ta biên dịch mã này, chúng ta nhận được lỗi này:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

Các biến thể Cons sở hữu dữ liệu mà chúng nắm giữ, vì vậy khi chúng ta tạo danh sách b, a được chuyển vào bb sở hữu a. Sau đó, khi chúng ta cố gắng sử dụng a lần nữa khi tạo c, chúng ta không được phép vì a đã bị chuyển đi.

Chúng ta có thể thay đổi định nghĩa của Cons để nắm giữ tham chiếu thay thế, nhưng sau đó chúng ta sẽ phải chỉ định các tham số thời gian sống. Bằng cách chỉ định tham số thời gian sống, chúng ta sẽ chỉ định rằng mọi phần tử trong danh sách sẽ tồn tại ít nhất lâu bằng toàn bộ danh sách. Đây là trường hợp cho các phần tử và danh sách trong Listing 15-17, nhưng không phải trong mọi tình huống.

Thay vào đó, chúng ta sẽ thay đổi định nghĩa của List để sử dụng Rc<T> thay vì Box<T>, như được hiển thị trong Listing 15-18. Mỗi biến thể Cons bây giờ sẽ chứa một giá trị và một Rc<T> trỏ đến một List. Khi chúng ta tạo b, thay vì lấy quyền sở hữu của a, chúng ta sẽ sao chép Rc<List>a đang nắm giữ, do đó tăng số lượng tham chiếu từ một lên hai và cho phép ab chia sẻ quyền sở hữu dữ liệu trong Rc<List> đó. Chúng ta cũng sẽ sao chép a khi tạo c, tăng số lượng tham chiếu từ hai lên ba. Mỗi lần chúng ta gọi Rc::clone, số lượng tham chiếu đến dữ liệu trong Rc<List> sẽ tăng lên, và dữ liệu sẽ không được dọn dẹp trừ khi không có tham chiếu nào đến nó.

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

Chúng ta cần thêm câu lệnh use để đưa Rc<T> vào phạm vi vì nó không có trong prelude. Trong main, chúng ta tạo danh sách chứa 5 và 10 và lưu trữ nó trong một Rc<List> mới trong a. Sau đó, khi chúng ta tạo bc, chúng ta gọi hàm Rc::clone và truyền một tham chiếu đến Rc<List> trong a làm đối số.

Chúng ta có thể đã gọi a.clone() thay vì Rc::clone(&a), nhưng quy ước của Rust là sử dụng Rc::clone trong trường hợp này. Việc triển khai Rc::clone không tạo một bản sao sâu của tất cả dữ liệu như hầu hết các triển khai clone của các kiểu khác. Lệnh gọi đến Rc::clone chỉ tăng số lượng tham chiếu, điều này không tốn nhiều thời gian. Việc sao chép sâu dữ liệu có thể tốn nhiều thời gian. Bằng cách sử dụng Rc::clone cho việc đếm tham chiếu, chúng ta có thể phân biệt trực quan giữa các loại bản sao sâu và các loại bản sao tăng số lượng tham chiếu. Khi tìm kiếm vấn đề về hiệu suất trong mã, chúng ta chỉ cần xem xét các bản sao sâu và có thể bỏ qua các lệnh gọi đến Rc::clone.

Sao Chép một Rc<T> Tăng Số Lượng Tham Chiếu

Hãy thay đổi ví dụ đang hoạt động của chúng ta trong Listing 15-18 để chúng ta có thể thấy số lượng tham chiếu thay đổi khi chúng ta tạo và loại bỏ các tham chiếu đến Rc<List> trong a.

Trong Listing 15-19, chúng ta sẽ thay đổi main để nó có một phạm vi bên trong xung quanh danh sách c; sau đó chúng ta có thể thấy số lượng tham chiếu thay đổi như thế nào khi c ra khỏi phạm vi.

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

Tại mỗi điểm trong chương trình khi số lượng tham chiếu thay đổi, chúng ta in ra số lượng tham chiếu, mà chúng ta nhận được bằng cách gọi hàm Rc::strong_count. Hàm này được đặt tên là strong_count thay vì count vì kiểu Rc<T> cũng có weak_count; chúng ta sẽ xem weak_count được sử dụng để làm gì trong "Ngăn chặn Chu kỳ Tham chiếu Bằng cách Sử dụng Weak<T>".

Mã này in ra như sau:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

Chúng ta có thể thấy rằng Rc<List> trong a có số lượng tham chiếu ban đầu là 1; sau đó mỗi lần chúng ta gọi clone, số lượng tăng lên 1. Khi c ra khỏi phạm vi, số lượng giảm đi 1. Chúng ta không phải gọi một hàm để giảm số lượng tham chiếu như chúng ta phải gọi Rc::clone để tăng số lượng tham chiếu: việc triển khai của trait Drop tự động giảm số lượng tham chiếu khi một giá trị Rc<T> ra khỏi phạm vi.

Điều chúng ta không thể thấy trong ví dụ này là khi b và sau đó a ra khỏi phạm vi ở cuối main, số lượng lúc đó là 0, và Rc<List> được dọn dẹp hoàn toàn. Sử dụng Rc<T> cho phép một giá trị có nhiều chủ sở hữu, và số lượng đảm bảo rằng giá trị vẫn hợp lệ miễn là bất kỳ chủ sở hữu nào vẫn còn tồn tại.

Thông qua các tham chiếu bất biến, Rc<T> cho phép bạn chia sẻ dữ liệu giữa nhiều phần của chương trình để chỉ đọc. Nếu Rc<T> cho phép bạn có nhiều tham chiếu thay đổi cũng vậy, bạn có thể vi phạm một trong các quy tắc mượn đã thảo luận trong Chương 4: nhiều lần mượn thay đổi đến cùng một nơi có thể gây ra các cuộc đua dữ liệu và sự không nhất quán. Nhưng khả năng thay đổi dữ liệu rất hữu ích! Trong phần tiếp theo, chúng ta sẽ thảo luận về mẫu khả biến nội bộ và kiểu RefCell<T> mà bạn có thể sử dụng kết hợp với Rc<T> để làm việc với hạn chế bất biến này.

RefCell<T> và Mẫu Khả Biến Nội Bộ

Khả biến nội bộ (Interior mutability) là một mẫu thiết kế trong Rust cho phép bạn thay đổi dữ liệu ngay cả khi có các tham chiếu bất biến đến dữ liệu đó; thông thường, hành động này bị cấm bởi các quy tắc mượn. Để thay đổi dữ liệu, mẫu này sử dụng mã unsafe bên trong cấu trúc dữ liệu để uốn cong các quy tắc thông thường của Rust về việc thay đổi và mượn. Mã không an toàn chỉ ra cho trình biên dịch rằng chúng ta đang kiểm tra các quy tắc thủ công thay vì dựa vào trình biên dịch để kiểm tra chúng cho chúng ta; chúng ta sẽ thảo luận thêm về mã không an toàn trong Chương 20.

Chúng ta chỉ có thể sử dụng các kiểu sử dụng mẫu khả biến nội bộ khi chúng ta có thể đảm bảo rằng các quy tắc mượn sẽ được tuân thủ trong thời gian chạy, mặc dù trình biên dịch không thể đảm bảo điều đó. Mã unsafe liên quan sau đó được bọc trong một API an toàn, và kiểu bên ngoài vẫn bất biến.

Hãy khám phá khái niệm này bằng cách xem xét kiểu RefCell<T> theo mẫu khả biến nội bộ.

Thực Thi Quy Tắc Mượn trong Thời Gian Chạy với RefCell<T>

Không giống như Rc<T>, kiểu RefCell<T> đại diện cho quyền sở hữu duy nhất đối với dữ liệu mà nó chứa. Vậy điều gì làm cho RefCell<T> khác với một kiểu như Box<T>? Hãy nhớ lại các quy tắc mượn mà bạn đã học trong Chương 4:

  • Tại bất kỳ thời điểm nào, bạn có thể có hoặc là một tham chiếu có thể thay đổi hoặc bất kỳ số lượng tham chiếu bất biến nào (nhưng không phải cả hai).
  • Tham chiếu phải luôn hợp lệ.

Với tham chiếu và Box<T>, các bất biến của quy tắc mượn được thực thi tại thời điểm biên dịch. Với RefCell<T>, các bất biến này được thực thi trong thời gian chạy. Với tham chiếu, nếu bạn vi phạm các quy tắc này, bạn sẽ gặp lỗi trình biên dịch. Với RefCell<T>, nếu bạn vi phạm các quy tắc này, chương trình của bạn sẽ hoảng loạn và thoát.

Ưu điểm của việc kiểm tra các quy tắc mượn tại thời điểm biên dịch là các lỗi sẽ được phát hiện sớm hơn trong quá trình phát triển, và không có tác động đến hiệu suất thời gian chạy vì tất cả phân tích đều được hoàn thành trước đó. Vì những lý do đó, kiểm tra các quy tắc mượn tại thời điểm biên dịch là lựa chọn tốt nhất trong đa số trường hợp, đó là lý do tại sao đây là mặc định của Rust.

Ưu điểm của việc kiểm tra các quy tắc mượn trong thời gian chạy thay vì thời gian biên dịch là một số kịch bản an toàn bộ nhớ nhất định được cho phép, trong khi chúng sẽ bị từ chối bởi các kiểm tra thời gian biên dịch. Phân tích tĩnh, như trình biên dịch Rust, về bản chất là bảo thủ. Một số thuộc tính của mã không thể phát hiện được bằng cách phân tích mã: ví dụ nổi tiếng nhất là Bài toán Dừng (Halting Problem), nằm ngoài phạm vi của cuốn sách này nhưng là một chủ đề thú vị để nghiên cứu.

Bởi vì một số phân tích là không thể, nếu trình biên dịch Rust không thể chắc chắn rằng mã tuân thủ các quy tắc sở hữu, nó có thể từ chối một chương trình đúng; theo cách này, nó là bảo thủ. Nếu Rust chấp nhận một chương trình không chính xác, người dùng sẽ không thể tin tưởng vào các đảm bảo mà Rust đưa ra. Tuy nhiên, nếu Rust từ chối một chương trình đúng, lập trình viên sẽ bị bất tiện, nhưng không có gì thảm khốc có thể xảy ra. Kiểu RefCell<T> hữu ích khi bạn chắc chắn rằng mã của bạn tuân theo các quy tắc mượn nhưng trình biên dịch không thể hiểu và đảm bảo điều đó.

Tương tự như Rc<T>, RefCell<T> chỉ để sử dụng trong các tình huống đơn luồng và sẽ đưa ra lỗi thời gian biên dịch nếu bạn cố gắng sử dụng nó trong bối cảnh đa luồng. Chúng ta sẽ nói về cách lấy chức năng của RefCell<T> trong chương trình đa luồng trong Chương 16.

Đây là một tóm tắt về lý do để chọn Box<T>, Rc<T>, hoặc RefCell<T>:

  • Rc<T> cho phép nhiều chủ sở hữu của cùng một dữ liệu; Box<T>RefCell<T> có chủ sở hữu duy nhất.
  • Box<T> cho phép mượn bất biến hoặc có thể thay đổi được kiểm tra tại thời điểm biên dịch; Rc<T> chỉ cho phép mượn bất biến được kiểm tra tại thời điểm biên dịch; RefCell<T> cho phép mượn bất biến hoặc có thể thay đổi được kiểm tra trong thời gian chạy.
  • Bởi vì RefCell<T> cho phép mượn có thể thay đổi được kiểm tra trong thời gian chạy, bạn có thể thay đổi giá trị bên trong RefCell<T> ngay cả khi RefCell<T> là bất biến.

Thay đổi giá trị bên trong một giá trị bất biến là mẫu khả biến nội bộ. Hãy xem xét một tình huống mà khả biến nội bộ hữu ích và xem xét cách nó có thể thực hiện được.

Khả Biến Nội Bộ: Mượn Có Thể Thay Đổi cho một Giá Trị Bất Biến

Một hệ quả của các quy tắc mượn là khi bạn có một giá trị bất biến, bạn không thể mượn nó một cách có thể thay đổi. Ví dụ, đoạn mã này sẽ không biên dịch:

fn main() {
    let x = 5;
    let y = &mut x;
}

Nếu bạn cố gắng biên dịch mã này, bạn sẽ gặp lỗi sau:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

Tuy nhiên, có những tình huống mà sẽ rất hữu ích nếu một giá trị có thể tự thay đổi bản thân trong các phương thức của nó nhưng xuất hiện bất biến đối với mã khác. Mã bên ngoài các phương thức của giá trị sẽ không thể thay đổi giá trị. Sử dụng RefCell<T> là một cách để có khả năng khả biến nội bộ, nhưng RefCell<T> không hoàn toàn vượt qua các quy tắc mượn: trình kiểm tra mượn trong trình biên dịch cho phép khả biến nội bộ này, và các quy tắc mượn được kiểm tra trong thời gian chạy thay vì. Nếu bạn vi phạm các quy tắc, bạn sẽ nhận được một panic! thay vì lỗi trình biên dịch.

Hãy làm việc thông qua một ví dụ thực tế mà chúng ta có thể sử dụng RefCell<T> để thay đổi một giá trị bất biến và xem tại sao điều đó lại hữu ích.

Một Trường Hợp Sử Dụng cho Khả Biến Nội Bộ: Đối Tượng Giả Lập

Đôi khi trong quá trình kiểm thử, một lập trình viên sẽ sử dụng một kiểu thay thế cho một kiểu khác, để quan sát hành vi cụ thể và khẳng định rằng nó được triển khai chính xác. Kiểu thay thế này được gọi là test double. Hãy nghĩ về nó theo nghĩa của một diễn viên đóng thế trong làm phim, nơi một người bước vào và thay thế cho một diễn viên để thực hiện một cảnh đặc biệt khó khăn. Test double đứng thay cho các kiểu khác khi chúng ta đang chạy kiểm thử. Đối tượng giả lập (Mock objects) là các loại test double cụ thể ghi lại những gì xảy ra trong quá trình kiểm thử để bạn có thể khẳng định rằng các hành động đúng đã diễn ra.

Rust không có đối tượng theo cùng nghĩa như các ngôn ngữ khác có đối tượng, và Rust không có chức năng đối tượng giả lập được tích hợp vào thư viện chuẩn như một số ngôn ngữ khác. Tuy nhiên, bạn chắc chắn có thể tạo một struct sẽ phục vụ cùng mục đích như một đối tượng giả lập.

Đây là kịch bản chúng ta sẽ kiểm thử: chúng ta sẽ tạo một thư viện theo dõi một giá trị so với một giá trị tối đa và gửi tin nhắn dựa trên mức độ gần với giá trị tối đa của giá trị hiện tại. Thư viện này có thể được sử dụng để theo dõi hạn ngạch của người dùng đối với số lượng cuộc gọi API mà họ được phép thực hiện, ví dụ.

Thư viện của chúng ta sẽ chỉ cung cấp chức năng theo dõi mức độ gần với tối đa của một giá trị và các tin nhắn nên là gì ở thời điểm nào. Các ứng dụng sử dụng thư viện của chúng ta sẽ được kỳ vọng cung cấp cơ chế để gửi tin nhắn: ứng dụng có thể đặt một tin nhắn trong ứng dụng, gửi email, gửi tin nhắn văn bản, hoặc làm việc khác. Thư viện không cần biết chi tiết đó. Tất cả những gì nó cần là một thứ triển khai một trait mà chúng ta sẽ cung cấp gọi là Messenger. Listing 15-20 hiển thị mã thư viện.

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

Một phần quan trọng của mã này là trait Messenger có một phương thức gọi là send nhận một tham chiếu bất biến đến self và văn bản của tin nhắn. Trait này là giao diện mà đối tượng giả lập của chúng ta cần triển khai để có thể được sử dụng theo cùng cách như một đối tượng thực. Phần quan trọng khác là chúng ta muốn kiểm thử hành vi của phương thức set_value trên LimitTracker. Chúng ta có thể thay đổi những gì chúng ta truyền vào cho tham số value, nhưng set_value không trả về bất cứ thứ gì để chúng ta đưa ra khẳng định. Chúng ta muốn có thể nói rằng nếu chúng ta tạo một LimitTracker với thứ gì đó triển khai trait Messenger và một giá trị cụ thể cho max, khi chúng ta truyền các số khác nhau cho value, messenger được yêu cầu gửi các tin nhắn thích hợp.

Chúng ta cần một đối tượng giả lập mà, thay vì gửi email hoặc tin nhắn văn bản khi chúng ta gọi send, sẽ chỉ theo dõi các tin nhắn mà nó được yêu cầu gửi. Chúng ta có thể tạo một thể hiện mới của đối tượng giả lập, tạo một LimitTracker sử dụng đối tượng giả lập, gọi phương thức set_value trên LimitTracker, và sau đó kiểm tra xem đối tượng giả lập có các tin nhắn mà chúng ta mong đợi không. Listing 15-21 hiển thị một nỗ lực triển khai một đối tượng giả lập để làm điều đó, nhưng trình kiểm tra mượn sẽ không cho phép.

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}

Mã kiểm thử này định nghĩa một struct MockMessenger có trường sent_messages với một Vec của các giá trị String để theo dõi các tin nhắn mà nó được yêu cầu gửi. Chúng ta cũng định nghĩa một hàm liên kết new để thuận tiện cho việc tạo các giá trị MockMessenger mới bắt đầu với một danh sách tin nhắn trống. Sau đó, chúng ta triển khai trait Messenger cho MockMessenger để chúng ta có thể cung cấp một MockMessenger cho một LimitTracker. Trong định nghĩa của phương thức send, chúng ta lấy tin nhắn được truyền vào dưới dạng một tham số và lưu trữ nó trong danh sách sent_messages của MockMessenger.

Trong kiểm thử, chúng ta đang kiểm tra những gì xảy ra khi LimitTracker được yêu cầu đặt value thành một cái gì đó lớn hơn 75 phần trăm của giá trị max. Đầu tiên, chúng ta tạo một MockMessenger mới, sẽ bắt đầu với một danh sách tin nhắn trống. Sau đó, chúng ta tạo một LimitTracker mới và cung cấp cho nó một tham chiếu đến MockMessenger mới và một giá trị max100. Chúng ta gọi phương thức set_value trên LimitTracker với một giá trị 80, lớn hơn 75 phần trăm của 100. Sau đó, chúng ta khẳng định rằng danh sách tin nhắn mà MockMessenger đang theo dõi bây giờ nên có một tin nhắn trong đó.

Tuy nhiên, có một vấn đề với kiểm thử này, như được hiển thị ở đây:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
2  ~     fn send(&mut self, msg: &str);
3  | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

Chúng ta không thể sửa đổi MockMessenger để theo dõi các tin nhắn, vì phương thức send nhận một tham chiếu bất biến đến self. Chúng ta cũng không thể làm theo gợi ý từ văn bản lỗi để sử dụng &mut self trong cả phương thức impl và định nghĩa trait. Chúng ta không muốn thay đổi trait Messenger chỉ vì mục đích kiểm thử. Thay vào đó, chúng ta cần tìm một cách để làm cho mã kiểm thử của chúng ta hoạt động chính xác với thiết kế hiện tại của chúng ta.

Đây là một tình huống mà khả biến nội bộ có thể giúp đỡ! Chúng ta sẽ lưu trữ sent_messages trong một RefCell<T>, và sau đó phương thức send sẽ có thể sửa đổi sent_messages để lưu trữ các tin nhắn chúng ta đã thấy. Listing 15-22 hiển thị nó trông như thế nào.

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

Trường sent_messages bây giờ có kiểu RefCell<Vec<String>> thay vì Vec<String>. Trong hàm new, chúng ta tạo một thể hiện RefCell<Vec<String>> mới xung quanh vectơ trống.

Đối với việc triển khai phương thức send, tham số đầu tiên vẫn là một mượn bất biến của self, phù hợp với định nghĩa trait. Chúng ta gọi borrow_mut trên RefCell<Vec<String>> trong self.sent_messages để có được một tham chiếu có thể thay đổi đến giá trị bên trong RefCell<Vec<String>>, đó là vectơ. Sau đó, chúng ta có thể gọi push trên tham chiếu có thể thay đổi đến vectơ để theo dõi các tin nhắn được gửi trong quá trình kiểm thử.

Thay đổi cuối cùng chúng ta phải thực hiện là trong khẳng định: để xem có bao nhiêu mục trong vectơ bên trong, chúng ta gọi borrow trên RefCell<Vec<String>> để có được một tham chiếu bất biến đến vectơ.

Bây giờ bạn đã thấy cách sử dụng RefCell<T>, hãy đi sâu vào cách nó hoạt động!

Theo dõi các Mượn tại Thời gian Chạy với RefCell<T>

Khi tạo tham chiếu bất biến và có thể thay đổi, chúng ta sử dụng cú pháp &&mut, tương ứng. Với RefCell<T>, chúng ta sử dụng các phương thức borrowborrow_mut, là một phần của API an toàn thuộc về RefCell<T>. Phương thức borrow trả về kiểu con trỏ thông minh Ref<T>, và borrow_mut trả về kiểu con trỏ thông minh RefMut<T>. Cả hai kiểu đều triển khai Deref, vì vậy chúng ta có thể coi chúng như các tham chiếu thông thường.

RefCell<T> theo dõi có bao nhiêu con trỏ thông minh Ref<T>RefMut<T> đang hoạt động. Mỗi lần chúng ta gọi borrow, RefCell<T> tăng số lượng mượn bất biến đang hoạt động. Khi giá trị Ref<T> ra khỏi phạm vi, số lượng mượn bất biến giảm xuống 1. Giống như các quy tắc mượn thời gian biên dịch, RefCell<T> cho phép chúng ta có nhiều mượn bất biến hoặc một mượn có thể thay đổi tại bất kỳ thời điểm nào.

Nếu chúng ta cố gắng vi phạm các quy tắc này, thay vì nhận được lỗi trình biên dịch như chúng ta sẽ làm với tham chiếu, việc triển khai RefCell<T> sẽ hoảng loạn trong thời gian chạy. Listing 15-23 hiển thị một sửa đổi của việc triển khai send trong Listing 15-22. Chúng ta cố tình cố gắng tạo hai mượn có thể thay đổi hoạt động cho cùng một phạm vi để minh họa rằng RefCell<T> ngăn chúng ta làm điều này trong thời gian chạy.

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

Chúng ta tạo một biến one_borrow cho con trỏ thông minh RefMut<T> được trả về từ borrow_mut. Sau đó, chúng ta tạo một mượn có thể thay đổi khác theo cùng cách trong biến two_borrow. Điều này tạo ra hai tham chiếu có thể thay đổi trong cùng một phạm vi, không được phép. Khi chúng ta chạy các kiểm thử cho thư viện của mình, mã trong Listing 15-23 sẽ biên dịch mà không có bất kỳ lỗi nào, nhưng kiểm thử sẽ thất bại:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Lưu ý rằng mã hoảng loạn với thông báo already borrowed: BorrowMutError. Đây là cách RefCell<T> xử lý vi phạm các quy tắc mượn trong thời gian chạy.

Việc chọn để bắt các lỗi mượn trong thời gian chạy thay vì thời gian biên dịch, như chúng ta đã làm ở đây, có nghĩa là bạn có khả năng tìm thấy lỗi trong mã của mình sau này trong quá trình phát triển: có thể không phải cho đến khi mã của bạn được triển khai lên môi trường sản xuất. Ngoài ra, mã của bạn sẽ phải chịu một hình phạt hiệu suất thời gian chạy nhỏ do phải theo dõi các mượn trong thời gian chạy thay vì thời gian biên dịch. Tuy nhiên, việc sử dụng RefCell<T> làm cho việc viết một đối tượng giả lập có thể tự sửa đổi để theo dõi các tin nhắn mà nó đã thấy khi bạn đang sử dụng nó trong một bối cảnh chỉ cho phép các giá trị bất biến trở nên khả thi. Bạn có thể sử dụng RefCell<T> bất chấp sự đánh đổi của nó để có được nhiều chức năng hơn so với các tham chiếu thông thường cung cấp.

Cho Phép Nhiều Chủ Sở Hữu của Dữ Liệu Có Thể Thay Đổi với Rc<T>RefCell<T>

Một cách phổ biến để sử dụng RefCell<T> là kết hợp với Rc<T>. Hãy nhớ rằng Rc<T> cho phép bạn có nhiều chủ sở hữu của một số dữ liệu, nhưng nó chỉ cung cấp quyền truy cập bất biến vào dữ liệu đó. Nếu bạn có một Rc<T> chứa một RefCell<T>, bạn có thể có được một giá trị có thể có nhiều chủ sở hữu mà bạn có thể thay đổi!

Ví dụ, hãy nhớ lại ví dụ danh sách cons trong Listing 15-18 nơi chúng ta sử dụng Rc<T> để cho phép nhiều danh sách chia sẻ quyền sở hữu của một danh sách khác. Bởi vì Rc<T> chỉ chứa các giá trị bất biến, chúng ta không thể thay đổi bất kỳ giá trị nào trong danh sách một khi chúng ta đã tạo chúng. Hãy thêm vào RefCell<T> để có khả năng thay đổi các giá trị trong danh sách. Listing 15-24 hiển thị rằng bằng cách sử dụng RefCell<T> trong định nghĩa Cons, chúng ta có thể sửa đổi giá trị được lưu trữ trong tất cả các danh sách.

#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}

Chúng ta tạo một giá trị là một thể hiện của Rc<RefCell<i32>> và lưu trữ nó trong một biến có tên value để chúng ta có thể truy cập nó trực tiếp sau này. Sau đó, chúng ta tạo một List trong a với một biến thể Cons chứa value. Chúng ta cần sao chép value để cả avalue đều có quyền sở hữu của giá trị bên trong 5 thay vì chuyển quyền sở hữu từ value sang a hoặc có a mượn từ value.

Chúng ta bọc danh sách a trong một Rc<T> để khi chúng ta tạo danh sách bc, cả hai đều có thể tham chiếu đến a, đó là những gì chúng ta đã làm trong Listing 15-18.

Sau khi chúng ta đã tạo danh sách trong a, b, và c, chúng ta muốn thêm 10 vào giá trị trong value. Chúng ta làm điều này bằng cách gọi borrow_mut trên value, sử dụng tính năng tự động tham chiếu mà chúng ta đã thảo luận trong "Where's the -> Operator?") trong Chương 5 để tham chiếu đến Rc<T> đến giá trị RefCell<T> bên trong. Phương thức borrow_mut trả về một con trỏ thông minh RefMut<T>, và chúng ta sử dụng toán tử tham chiếu trên nó và thay đổi giá trị bên trong.

Khi chúng ta in a, b, và c, chúng ta có thể thấy rằng tất cả chúng đều có giá trị sửa đổi là 15 thay vì 5:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

Kỹ thuật này khá gọn gàng! Bằng cách sử dụng RefCell<T>, chúng ta có một giá trị List bất biến bên ngoài. Nhưng chúng ta có thể sử dụng các phương thức trên RefCell<T> cung cấp quyền truy cập vào khả biến nội bộ của nó để chúng ta có thể sửa đổi dữ liệu của mình khi chúng ta cần. Các kiểm tra thời gian chạy của các quy tắc mượn bảo vệ chúng ta khỏi các cuộc đua dữ liệu, và đôi khi đáng để đánh đổi một chút tốc độ để có được sự linh hoạt này trong cấu trúc dữ liệu của chúng ta. Lưu ý rằng RefCell<T> không hoạt động cho mã đa luồng! Mutex<T> là phiên bản an toàn cho luồng của RefCell<T>, và chúng ta sẽ thảo luận về Mutex<T> trong Chương 16.

Chu Kỳ Tham Chiếu Có Thể Rò Rỉ Bộ Nhớ

Các đảm bảo an toàn bộ nhớ của Rust khiến việc vô tình tạo ra bộ nhớ không bao giờ được dọn dẹp (được gọi là rò rỉ bộ nhớ) trở nên khó khăn, nhưng không phải là không thể. Việc ngăn chặn hoàn toàn rò rỉ bộ nhớ không phải là một trong những đảm bảo của Rust, có nghĩa là rò rỉ bộ nhớ là an toàn về mặt bộ nhớ trong Rust. Chúng ta có thể thấy rằng Rust cho phép rò rỉ bộ nhớ bằng cách sử dụng Rc<T>RefCell<T>: có thể tạo các tham chiếu trong đó các mục tham chiếu lẫn nhau trong một chu kỳ. Điều này tạo ra rò rỉ bộ nhớ vì số lượng tham chiếu của mỗi mục trong chu kỳ sẽ không bao giờ đạt đến 0, và các giá trị sẽ không bao giờ bị hủy.

Tạo Chu Kỳ Tham Chiếu

Hãy xem cách một chu kỳ tham chiếu có thể xảy ra và cách ngăn chặn nó, bắt đầu với định nghĩa của enum List và một phương thức tail trong Listing 15-25.

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}

Chúng ta đang sử dụng một biến thể khác của định nghĩa List từ Listing 15-5. Phần tử thứ hai trong biến thể Cons bây giờ là RefCell<Rc<List>>, có nghĩa là thay vì có khả năng sửa đổi giá trị i32 như chúng ta đã làm trong Listing 15-24, chúng ta muốn sửa đổi giá trị List mà một biến thể Cons đang trỏ đến. Chúng ta cũng thêm một phương thức tail để thuận tiện cho việc truy cập vào mục thứ hai nếu chúng ta có một biến thể Cons.

Trong Listing 15-26, chúng ta đang thêm một hàm main sử dụng các định nghĩa trong Listing 15-25. Mã này tạo một danh sách trong a và một danh sách trong b trỏ đến danh sách trong a. Sau đó, nó sửa đổi danh sách trong a để trỏ đến b, tạo thành một chu kỳ tham chiếu. Có các câu lệnh println! dọc theo đường đi để hiển thị số lượng tham chiếu tại các điểm khác nhau trong quá trình này.

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}

Chúng ta tạo một thể hiện Rc<List> chứa một giá trị List trong biến a với một danh sách ban đầu của 5, Nil. Sau đó, chúng ta tạo một thể hiện Rc<List> chứa một giá trị List khác trong biến b chứa giá trị 10 và trỏ đến danh sách trong a.

Chúng ta sửa đổi a để nó trỏ đến b thay vì Nil, tạo thành một chu kỳ. Chúng ta làm điều đó bằng cách sử dụng phương thức tail để lấy một tham chiếu đến RefCell<Rc<List>> trong a, mà chúng ta đặt trong biến link. Sau đó, chúng ta sử dụng phương thức borrow_mut trên RefCell<Rc<List>> để thay đổi giá trị bên trong từ một Rc<List> chứa giá trị Nil thành Rc<List> trong b.

Khi chúng ta chạy mã này, giữ cho println! cuối cùng được comment lại trong thời điểm này, chúng ta sẽ nhận được đầu ra này:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

Số lượng tham chiếu của các thể hiện Rc<List> trong cả ab là 2 sau khi chúng ta thay đổi danh sách trong a để trỏ đến b. Vào cuối main, Rust loại bỏ biến b, giảm số lượng tham chiếu của thể hiện Rc<List> trong b từ 2 xuống 1. Bộ nhớ mà Rc<List> có trên heap sẽ không bị hủy tại thời điểm này, vì số lượng tham chiếu của nó là 1, không phải 0. Sau đó, Rust loại bỏ a, giảm số lượng tham chiếu của thể hiện Rc<List> trong a từ 2 xuống 1. Bộ nhớ của thể hiện này cũng không thể bị hủy, vì thể hiện Rc<List> khác vẫn tham chiếu đến nó. Bộ nhớ được cấp phát cho danh sách sẽ vẫn không được thu gom mãi mãi. Để hình dung chu kỳ tham chiếu này, chúng ta đã tạo sơ đồ trong Hình 15-4.

Chu kỳ tham chiếu của danh sách

Hình 15-4: Một chu kỳ tham chiếu của danh sách ab trỏ đến nhau

Nếu bạn bỏ comment dòng println! cuối cùng và chạy chương trình, Rust sẽ cố gắng in chu kỳ này với a trỏ đến b trỏ đến a và cứ thế cho đến khi nó tràn ngăn xếp.

So với một chương trình thực tế, hậu quả của việc tạo chu kỳ tham chiếu trong ví dụ này không quá nghiêm trọng: ngay sau khi chúng ta tạo chu kỳ tham chiếu, chương trình kết thúc. Tuy nhiên, nếu một chương trình phức tạp hơn cấp phát nhiều bộ nhớ trong một chu kỳ và giữ nó trong thời gian dài, chương trình sẽ sử dụng nhiều bộ nhớ hơn mức cần thiết và có thể làm quá tải hệ thống, khiến nó hết bộ nhớ khả dụng.

Việc tạo chu kỳ tham chiếu không dễ dàng thực hiện, nhưng cũng không phải là không thể. Nếu bạn có các giá trị RefCell<T> chứa các giá trị Rc<T> hoặc các kết hợp lồng nhau tương tự của các kiểu với khả biến nội bộ và đếm tham chiếu, bạn phải đảm bảo rằng bạn không tạo chu kỳ; bạn không thể dựa vào Rust để phát hiện chúng. Việc tạo một chu kỳ tham chiếu sẽ là một lỗi logic trong chương trình của bạn mà bạn nên sử dụng các bài kiểm tra tự động, đánh giá mã và các thực hành phát triển phần mềm khác để giảm thiểu.

Một giải pháp khác để tránh chu kỳ tham chiếu là tổ chức lại cấu trúc dữ liệu của bạn để một số tham chiếu thể hiện quyền sở hữu và một số tham chiếu không. Kết quả là, bạn có thể có các chu kỳ được tạo thành từ một số mối quan hệ sở hữu và một số mối quan hệ không sở hữu, và chỉ có các mối quan hệ sở hữu ảnh hưởng đến việc liệu một giá trị có thể bị hủy hay không. Trong Listing 15-25, chúng ta luôn muốn các biến thể Cons sở hữu danh sách của chúng, vì vậy việc tổ chức lại cấu trúc dữ liệu là không thể. Hãy xem một ví dụ sử dụng đồ thị được tạo thành từ các nút cha và nút con để thấy khi nào các mối quan hệ không sở hữu là một cách thích hợp để ngăn chặn chu kỳ tham chiếu.

Ngăn Chặn Chu Kỳ Tham Chiếu Sử Dụng Weak<T>

Cho đến nay, chúng ta đã chứng minh rằng gọi Rc::clone tăng strong_count của một thể hiện Rc<T>, và một thể hiện Rc<T> chỉ được dọn dẹp nếu strong_count của nó là 0. Bạn cũng có thể tạo tham chiếu yếu đến giá trị trong một thể hiện Rc<T> bằng cách gọi Rc::downgrade và truyền một tham chiếu đến Rc<T>. Tham chiếu mạnh là cách bạn có thể chia sẻ quyền sở hữu của một thể hiện Rc<T>. Tham chiếu yếu không thể hiện một mối quan hệ sở hữu, và số lượng của chúng không ảnh hưởng đến thời điểm một thể hiện Rc<T> được dọn dẹp. Chúng sẽ không gây ra chu kỳ tham chiếu vì bất kỳ chu kỳ nào liên quan đến một số tham chiếu yếu sẽ bị phá vỡ khi số lượng tham chiếu mạnh của các giá trị liên quan là 0.

Khi bạn gọi Rc::downgrade, bạn nhận được một con trỏ thông minh kiểu Weak<T>. Thay vì tăng strong_count trong thể hiện Rc<T> lên 1, gọi Rc::downgrade tăng weak_count lên 1. Kiểu Rc<T> sử dụng weak_count để theo dõi có bao nhiêu tham chiếu Weak<T> tồn tại, tương tự như strong_count. Sự khác biệt là weak_count không cần phải là 0 để thể hiện Rc<T> được dọn dẹp.

Bởi vì giá trị mà Weak<T> tham chiếu đến có thể đã bị hủy, để làm bất cứ điều gì với giá trị mà một Weak<T> đang trỏ đến, bạn phải đảm bảo rằng giá trị vẫn tồn tại. Làm điều này bằng cách gọi phương thức upgrade trên một thể hiện Weak<T>, sẽ trả về một Option<Rc<T>>. Bạn sẽ nhận được kết quả là Some nếu giá trị Rc<T> chưa bị hủy và kết quả là None nếu giá trị Rc<T> đã bị hủy. Bởi vì upgrade trả về một Option<Rc<T>>, Rust sẽ đảm bảo rằng trường hợp Some và trường hợp None được xử lý, và sẽ không có con trỏ không hợp lệ.

Ví dụ, thay vì sử dụng một danh sách mà các mục chỉ biết về mục tiếp theo, chúng ta sẽ tạo một cây mà các mục biết về các mục con các mục cha của chúng.

Tạo Cấu Trúc Dữ Liệu Cây: Một Node với Các Nút Con

Để bắt đầu, chúng ta sẽ xây dựng một cây với các nút biết về các nút con của chúng. Chúng ta sẽ tạo một struct có tên Node chứa giá trị i32 của riêng nó cũng như tham chiếu đến các giá trị Node con của nó:

Tên tệp: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Chúng ta muốn một Node sở hữu các con của nó, và chúng ta muốn chia sẻ quyền sở hữu đó với các biến để chúng ta có thể truy cập trực tiếp vào mỗi Node trong cây. Để làm điều này, chúng ta định nghĩa các mục Vec<T> là các giá trị kiểu Rc<Node>. Chúng ta cũng muốn sửa đổi nút nào là con của nút khác, vì vậy chúng ta có một RefCell<T> trong children bao quanh Vec<Rc<Node>>.

Tiếp theo, chúng ta sẽ sử dụng định nghĩa struct của mình và tạo một thể hiện Node có tên leaf với giá trị 3 và không có con, và một thể hiện khác có tên branch với giá trị 5leaf là một trong những đứa con của nó, như trong Listing 15-27.

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Chúng ta sao chép Rc<Node> trong leaf và lưu trữ nó trong branch, có nghĩa là Node trong leaf bây giờ có hai chủ sở hữu: leafbranch. Chúng ta có thể đi từ branch đến leaf thông qua branch.children, nhưng không có cách nào để đi từ leaf đến branch. Lý do là leaf không có tham chiếu đến branch và không biết chúng có liên quan. Chúng ta muốn leaf biết rằng branch là cha của nó. Chúng ta sẽ làm điều đó tiếp theo.

Thêm Tham Chiếu từ Nút Con đến Nút Cha của Nó

Để làm cho nút con nhận thức được nút cha của nó, chúng ta cần thêm một trường parent vào định nghĩa struct Node của chúng ta. Vấn đề là quyết định kiểu của parent nên là gì. Chúng ta biết rằng nó không thể chứa một Rc<T> vì điều đó sẽ tạo ra một chu kỳ tham chiếu với leaf.parent trỏ đến branchbranch.children trỏ đến leaf, điều này sẽ khiến giá trị strong_count của chúng không bao giờ là 0.

Nghĩ về mối quan hệ theo một cách khác, một nút cha nên sở hữu các con của nó: nếu một nút cha bị hủy, các nút con của nó cũng nên bị hủy. Tuy nhiên, một nút con không nên sở hữu nút cha của nó: nếu chúng ta hủy một nút con, nút cha vẫn nên tồn tại. Đây là một trường hợp cho tham chiếu yếu!

Vì vậy, thay vì Rc<T>, chúng ta sẽ làm cho kiểu của parent sử dụng Weak<T>, cụ thể là một RefCell<Weak<Node>>. Bây giờ định nghĩa struct Node của chúng ta trông như thế này:

Tên tệp: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

Một nút sẽ có thể tham chiếu đến nút cha của nó nhưng không sở hữu nút cha của nó. Trong Listing 15-28, chúng ta cập nhật main để sử dụng định nghĩa mới này để nút leaf sẽ có cách tham chiếu đến nút cha của nó, branch.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

Việc tạo nút leaf trông giống với Listing 15-27 ngoại trừ trường parent: leaf bắt đầu mà không có cha, vì vậy chúng ta tạo một thể hiện tham chiếu Weak<Node> mới và trống.

Tại thời điểm này, khi chúng ta cố gắng lấy tham chiếu đến cha của leaf bằng cách sử dụng phương thức upgrade, chúng ta nhận được giá trị None. Chúng ta thấy điều này trong đầu ra từ câu lệnh println! đầu tiên:

leaf parent = None

Khi chúng ta tạo nút branch, nó cũng sẽ có một tham chiếu Weak<Node> mới trong trường parent, vì branch không có nút cha. Chúng ta vẫn có leaf là một trong những đứa con của branch. Một khi chúng ta có thể hiện Node trong branch, chúng ta có thể sửa đổi leaf để cung cấp cho nó một tham chiếu Weak<Node> đến nút cha của nó. Chúng ta sử dụng phương thức borrow_mut trên RefCell<Weak<Node>> trong trường parent của leaf, và sau đó chúng ta sử dụng hàm Rc::downgrade để tạo một tham chiếu Weak<Node> đến branch từ Rc<Node> trong branch.

Khi chúng ta in cha của leaf lần nữa, lần này chúng ta sẽ nhận được một biến thể Some chứa branch: bây giờ leaf có thể truy cập nút cha của nó! Khi chúng ta in leaf, chúng ta cũng tránh được chu kỳ cuối cùng dẫn đến tràn ngăn xếp như chúng ta đã có trong Listing 15-26; các tham chiếu Weak<Node> được in dưới dạng (Weak):

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

Sự thiếu đầu ra vô hạn cho thấy mã này không tạo ra một chu kỳ tham chiếu. Chúng ta cũng có thể nhận thấy điều này bằng cách nhìn vào các giá trị mà chúng ta nhận được từ việc gọi Rc::strong_countRc::weak_count.

Trực Quan Hóa Thay Đổi đối với strong_countweak_count

Hãy xem giá trị strong_countweak_count của các thể hiện Rc<Node> thay đổi như thế nào bằng cách tạo một phạm vi bên trong mới và di chuyển việc tạo branch vào phạm vi đó. Bằng cách làm như vậy, chúng ta có thể thấy điều gì xảy ra khi branch được tạo và sau đó bị hủy khi nó ra khỏi phạm vi. Các sửa đổi được hiển thị trong Listing 15-29.

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

Sau khi leaf được tạo, Rc<Node> của nó có số lượng mạnh là 1 và số lượng yếu là 0. Trong phạm vi bên trong, chúng ta tạo branch và liên kết nó với leaf, tại thời điểm đó khi chúng ta in số lượng, Rc<Node> trong branch sẽ có số lượng mạnh là 1 và số lượng yếu là 1 (vì leaf.parent trỏ đến branch với một Weak<Node>). Khi chúng ta in số lượng trong leaf, chúng ta sẽ thấy nó sẽ có số lượng mạnh là 2 vì branch bây giờ có một bản sao của Rc<Node> của leaf được lưu trữ trong branch.children, nhưng vẫn có số lượng yếu là 0.

Khi phạm vi bên trong kết thúc, branch ra khỏi phạm vi và số lượng mạnh của Rc<Node> giảm xuống 0, vì vậy Node của nó bị hủy. Số lượng yếu là 1 từ leaf.parent không ảnh hưởng đến việc Node có bị hủy hay không, vì vậy chúng ta không bị rò rỉ bộ nhớ!

Nếu chúng ta cố gắng truy cập vào cha của leaf sau khi kết thúc phạm vi, chúng ta sẽ lại nhận được None. Vào cuối chương trình, Rc<Node> trong leaf có số lượng mạnh là 1 và số lượng yếu là 0, vì biến leaf bây giờ lại là tham chiếu duy nhất đến Rc<Node>.

Tất cả logic quản lý số lượng và hủy giá trị được tích hợp vào Rc<T>Weak<T> và triển khai của chúng về trait Drop. Bằng cách chỉ định rằng mối quan hệ từ một nút con đến nút cha của nó nên là một tham chiếu Weak<T> trong định nghĩa của Node, bạn có thể có các nút cha trỏ đến các nút con và ngược lại mà không tạo ra một chu kỳ tham chiếu và rò rỉ bộ nhớ.

Tóm Tắt

Chương này đã đề cập đến cách sử dụng con trỏ thông minh để đưa ra các đảm bảo và đánh đổi khác nhau so với những gì Rust mặc định cung cấp với các tham chiếu thông thường. Kiểu Box<T> có kích thước đã biết và trỏ đến dữ liệu được cấp phát trên heap. Kiểu Rc<T> theo dõi số lượng tham chiếu đến dữ liệu trên heap để dữ liệu có thể có nhiều chủ sở hữu. Kiểu RefCell<T> với khả biến nội bộ của nó cung cấp cho chúng ta một kiểu mà chúng ta có thể sử dụng khi cần một kiểu bất biến nhưng cần thay đổi giá trị bên trong của kiểu đó; nó cũng thực thi các quy tắc mượn trong thời gian chạy thay vì tại thời điểm biên dịch.

Chúng ta cũng đã thảo luận về các trait DerefDrop, cho phép nhiều chức năng của con trỏ thông minh. Chúng ta đã khám phá các chu kỳ tham chiếu có thể gây ra rò rỉ bộ nhớ và cách ngăn chặn chúng bằng cách sử dụng Weak<T>.

Nếu chương này đã kích thích sự quan tâm của bạn và bạn muốn triển khai con trỏ thông minh của riêng mình, hãy xem "The Rustonomicon" để biết thêm thông tin hữu ích.

Tiếp theo, chúng ta sẽ nói về đồng thời trong Rust. Bạn thậm chí sẽ tìm hiểu về một vài con trỏ thông minh mới.

Lập Trình Đồng Thời Không Lo Lắng

Xử lý lập trình đồng thời một cách an toàn và hiệu quả là một mục tiêu chính khác của Rust. Lập trình đồng thời (Concurrent programming), trong đó các phần khác nhau của chương trình thực thi độc lập, và lập trình song song (parallel programming), trong đó các phần khác nhau của chương trình thực thi cùng một lúc, đang ngày càng trở nên quan trọng khi ngày càng nhiều máy tính tận dụng nhiều bộ xử lý của chúng. Trong lịch sử, lập trình trong những bối cảnh này thường khó khăn và dễ gây lỗi. Rust hy vọng sẽ thay đổi điều đó.

Ban đầu, nhóm phát triển Rust nghĩ rằng đảm bảo an toàn bộ nhớ và ngăn chặn các vấn đề đồng thời là hai thách thức riêng biệt cần được giải quyết bằng các phương pháp khác nhau. Theo thời gian, nhóm đã phát hiện ra rằng hệ thống quyền sở hữu (ownership) và kiểu dữ liệu là một bộ công cụ mạnh mẽ để giúp quản lý cả an toàn bộ nhớ các vấn đề đồng thời! Bằng cách tận dụng quyền sở hữu và kiểm tra kiểu, nhiều lỗi đồng thời trở thành lỗi thời điểm biên dịch trong Rust thay vì lỗi thời gian chạy. Do đó, thay vì khiến bạn tốn nhiều thời gian cố gắng tái hiện chính xác các trường hợp mà lỗi đồng thời thời gian chạy xảy ra, mã không đúng sẽ từ chối biên dịch và hiển thị lỗi giải thích vấn đề. Kết quả là, bạn có thể sửa mã của mình trong khi bạn đang làm việc với nó thay vì có thể phải sửa sau khi nó đã được triển khai vào sản xuất. Chúng tôi đã đặt biệt danh cho khía cạnh này của Rust là lập trình đồng thời không lo lắng (fearless concurrency). Lập trình đồng thời không lo lắng cho phép bạn viết mã không có lỗi tinh vi và dễ dàng tái cấu trúc mà không gây ra lỗi mới.

Lưu ý: Để đơn giản hóa, chúng tôi sẽ gọi nhiều vấn đề là đồng thời (concurrent) thay vì chính xác hơn bằng cách nói đồng thời và/hoặc song song (concurrent and/or parallel). Trong chương này, vui lòng thay thế trong đầu đồng thời và/hoặc song song bất cứ khi nào chúng tôi sử dụng đồng thời. Trong chương tiếp theo, nơi sự phân biệt quan trọng hơn, chúng tôi sẽ cụ thể hơn.

Nhiều ngôn ngữ rất giáo điều về các giải pháp họ cung cấp để xử lý các vấn đề đồng thời. Ví dụ, Erlang có chức năng thanh lịch cho đồng thời truyền tin nhắn nhưng chỉ có cách mơ hồ để chia sẻ trạng thái giữa các luồng. Việc chỉ hỗ trợ một tập con của các giải pháp có thể là một chiến lược hợp lý cho các ngôn ngữ cấp cao hơn, bởi vì một ngôn ngữ cấp cao hơn hứa hẹn lợi ích từ việc từ bỏ một số kiểm soát để có được sự trừu tượng. Tuy nhiên, các ngôn ngữ cấp thấp hơn được kỳ vọng sẽ cung cấp giải pháp có hiệu suất tốt nhất trong bất kỳ tình huống nào và có ít sự trừu tượng hơn trên phần cứng. Do đó, Rust cung cấp nhiều công cụ để mô hình hóa vấn đề theo cách phù hợp với tình huống và yêu cầu của bạn.

Dưới đây là các chủ đề chúng ta sẽ đề cập trong chương này:

  • Làm thế nào để tạo luồng để chạy nhiều phần mã cùng một lúc
  • Đồng thời truyền tin (Message-passing concurrency), nơi các kênh gửi tin nhắn giữa các luồng
  • Đồng thời trạng thái chia sẻ (Shared-state concurrency), nơi nhiều luồng có quyền truy cập vào một số phần dữ liệu
  • Các trait SyncSend, mở rộng các đảm bảo đồng thời của Rust cho các kiểu do người dùng định nghĩa cũng như các kiểu được cung cấp bởi thư viện chuẩn

Sử Dụng Luồng để Chạy Mã Đồng Thời

Trong hầu hết các hệ điều hành hiện đại, mã của một chương trình đã thực thi được chạy trong một quy trình (process), và hệ điều hành sẽ quản lý nhiều quy trình cùng một lúc. Trong một chương trình, bạn cũng có thể có các phần độc lập chạy đồng thời. Các tính năng chạy các phần độc lập này được gọi là luồng (threads). Ví dụ, một máy chủ web có thể có nhiều luồng để nó có thể phản hồi nhiều hơn một yêu cầu cùng một lúc.

Việc chia nhỏ tính toán trong chương trình của bạn thành nhiều luồng để chạy nhiều tác vụ cùng một lúc có thể cải thiện hiệu suất, nhưng nó cũng làm tăng độ phức tạp. Bởi vì các luồng có thể chạy đồng thời, không có đảm bảo cố hữu về thứ tự mà các phần mã của bạn trên các luồng khác nhau sẽ chạy. Điều này có thể dẫn đến các vấn đề, chẳng hạn như:

  • Điều kiện tranh đua (Race conditions), trong đó các luồng đang truy cập dữ liệu hoặc tài nguyên theo thứ tự không nhất quán
  • Bế tắc (Deadlocks), trong đó hai luồng đang chờ đợi nhau, ngăn cản cả hai luồng tiếp tục
  • Lỗi chỉ xảy ra trong một số tình huống nhất định và khó tái tạo và sửa chữa một cách đáng tin cậy

Rust cố gắng giảm thiểu các tác động tiêu cực của việc sử dụng luồng, nhưng lập trình trong bối cảnh đa luồng vẫn cần suy nghĩ cẩn thận và yêu cầu cấu trúc mã khác với cấu trúc trong các chương trình chạy trong một luồng duy nhất.

Các ngôn ngữ lập trình triển khai luồng theo một vài cách khác nhau, và nhiều hệ điều hành cung cấp một API mà ngôn ngữ có thể gọi để tạo luồng mới. Thư viện chuẩn của Rust sử dụng mô hình 1:1 cho việc triển khai luồng, theo đó một chương trình sử dụng một luồng hệ điều hành cho mỗi một luồng ngôn ngữ. Có các crate triển khai các mô hình luồng khác có sự đánh đổi khác nhau so với mô hình 1:1. (Hệ thống async của Rust, mà chúng ta sẽ thấy trong chương tiếp theo, cung cấp một cách tiếp cận khác cho đồng thời.)

Tạo Luồng Mới với spawn

Để tạo một luồng mới, chúng ta gọi hàm thread::spawn và truyền cho nó một closure (chúng ta đã nói về closure trong Chương 13) chứa mã mà chúng ta muốn chạy trong luồng mới. Ví dụ trong Listing 16-1 in một số văn bản từ luồng chính và văn bản khác từ một luồng mới:

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Lưu ý rằng khi luồng chính của một chương trình Rust hoàn thành, tất cả các luồng được tạo ra đều bị tắt, cho dù chúng đã hoàn thành chạy hay chưa. Đầu ra từ chương trình này có thể hơi khác nhau mỗi lần, nhưng nó sẽ trông giống như sau:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

Các lệnh gọi đến thread::sleep buộc một luồng dừng thực thi trong một khoảng thời gian ngắn, cho phép một luồng khác chạy. Các luồng có thể sẽ lần lượt thay phiên nhau, nhưng điều đó không được đảm bảo: nó phụ thuộc vào cách hệ điều hành của bạn lên lịch các luồng. Trong lần chạy này, luồng chính in trước, mặc dù câu lệnh in từ luồng được tạo ra xuất hiện đầu tiên trong mã. Và mặc dù chúng ta bảo luồng được tạo ra in cho đến khi i9, nó chỉ đạt đến 5 trước khi luồng chính tắt.

Nếu bạn chạy mã này và chỉ thấy đầu ra từ luồng chính, hoặc không thấy bất kỳ sự chồng chéo nào, hãy thử tăng số lượng trong các phạm vi để tạo thêm cơ hội cho hệ điều hành chuyển đổi giữa các luồng.

Chờ Tất Cả Các Luồng Hoàn Thành Bằng Cách Sử Dụng join Handles

Mã trong Listing 16-1 không chỉ dừng luồng được tạo ra sớm hơn dự kiến trong hầu hết thời gian do luồng chính kết thúc, mà còn vì không có đảm bảo về thứ tự chạy của các luồng, chúng ta cũng không thể đảm bảo rằng luồng được tạo ra sẽ có cơ hội chạy!

Chúng ta có thể khắc phục vấn đề luồng được tạo ra không chạy hoặc kết thúc sớm bằng cách lưu giá trị trả về của thread::spawn trong một biến. Kiểu trả về của thread::spawnJoinHandle<T>. Một JoinHandle<T> là một giá trị được sở hữu mà, khi chúng ta gọi phương thức join trên nó, sẽ chờ cho luồng của nó kết thúc. Listing 16-2 hiển thị cách sử dụng JoinHandle<T> của luồng mà chúng ta đã tạo trong Listing 16-1 và cách gọi join để đảm bảo luồng được tạo ra hoàn thành trước khi main thoát.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

Gọi join trên handle sẽ chặn luồng hiện đang chạy cho đến khi luồng được đại diện bởi handle kết thúc. Chặn (Blocking) một luồng có nghĩa là luồng đó bị ngăn không cho thực hiện công việc hoặc thoát. Bởi vì chúng ta đã đặt lệnh gọi đến join sau vòng lặp for của luồng chính, việc chạy Listing 16-2 sẽ tạo ra đầu ra tương tự như sau:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

Hai luồng tiếp tục thay phiên nhau, nhưng luồng chính chờ đợi vì lời gọi đến handle.join() và không kết thúc cho đến khi luồng được tạo ra hoàn thành.

Nhưng hãy xem điều gì xảy ra khi chúng ta thay vào đó di chuyển handle.join() trước vòng lặp for trong main, như thế này:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Luồng chính sẽ chờ luồng được tạo ra hoàn thành và sau đó chạy vòng lặp for của nó, vì vậy đầu ra sẽ không còn xen kẽ nữa, như hiển thị ở đây:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

Các chi tiết nhỏ, chẳng hạn như nơi join được gọi, có thể ảnh hưởng đến việc các luồng của bạn có chạy cùng lúc hay không.

Sử Dụng Closure move với Luồng

Chúng ta thường sử dụng từ khóa move với closure được truyền cho thread::spawn bởi vì closure sau đó sẽ lấy quyền sở hữu các giá trị mà nó sử dụng từ môi trường, do đó chuyển quyền sở hữu của các giá trị đó từ một luồng này sang luồng khác. Trong "Capturing the Environment With Closures" trong Chương 13, chúng ta đã thảo luận về move trong bối cảnh closure. Bây giờ, chúng ta sẽ tập trung nhiều hơn vào sự tương tác giữa movethread::spawn.

Lưu ý trong Listing 16-1 rằng closure mà chúng ta truyền cho thread::spawn không nhận tham số nào: chúng ta không sử dụng bất kỳ dữ liệu nào từ luồng chính trong mã của luồng được tạo ra. Để sử dụng dữ liệu từ luồng chính trong luồng được tạo ra, closure của luồng được tạo ra phải bắt các giá trị mà nó cần. Listing 16-3 hiển thị một nỗ lực tạo một vectơ trong luồng chính và sử dụng nó trong luồng được tạo ra. Tuy nhiên, điều này chưa hoạt động, như bạn sẽ thấy trong một lúc nữa.

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

Closure sử dụng v, vì vậy nó sẽ bắt v và làm cho nó trở thành một phần của môi trường của closure. Bởi vì thread::spawn chạy closure này trong một luồng mới, chúng ta nên có thể truy cập v bên trong luồng mới đó. Nhưng khi chúng ta biên dịch ví dụ này, chúng ta nhận được lỗi sau:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust suy luận cách bắt v, và bởi vì println! chỉ cần một tham chiếu đến v, closure cố gắng mượn v. Tuy nhiên, có một vấn đề: Rust không thể biết luồng được tạo ra sẽ chạy trong bao lâu, vì vậy nó không biết liệu tham chiếu đến v sẽ luôn hợp lệ hay không.

Listing 16-4 cung cấp một kịch bản mà có khả năng cao hơn có một tham chiếu đến v mà sẽ không hợp lệ:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

Nếu Rust cho phép chúng ta chạy mã này, có khả năng luồng được tạo ra sẽ ngay lập tức bị đặt vào nền mà không chạy chút nào. Luồng được tạo ra có một tham chiếu đến v bên trong, nhưng luồng chính ngay lập tức làm rơi v, sử dụng hàm drop mà chúng ta đã thảo luận trong Chương 15. Sau đó, khi luồng được tạo ra bắt đầu thực thi, v không còn hợp lệ nữa, vì vậy một tham chiếu đến nó cũng không hợp lệ. Ôi không!

Để sửa lỗi trình biên dịch trong Listing 16-3, chúng ta có thể sử dụng lời khuyên từ thông báo lỗi:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Bằng cách thêm từ khóa move trước closure, chúng ta buộc closure lấy quyền sở hữu các giá trị mà nó đang sử dụng thay vì để Rust suy luận rằng nó nên mượn các giá trị. Sửa đổi Listing 16-3 như trong Listing 16-5 sẽ biên dịch và chạy như chúng ta dự định.

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

Chúng ta có thể bị cám dỗ để thử cùng một điều để sửa mã trong Listing 16-4 nơi luồng chính gọi drop bằng cách sử dụng một closure move. Tuy nhiên, cách sửa này sẽ không hoạt động vì những gì Listing 16-4 đang cố gắng làm bị cấm vì lý do khác. Nếu chúng ta thêm move vào closure, chúng ta sẽ di chuyển v vào môi trường của closure, và chúng ta không thể gọi drop trên nó trong luồng chính nữa. Chúng ta sẽ nhận được lỗi trình biên dịch này thay thế:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Các quy tắc sở hữu của Rust đã cứu chúng ta một lần nữa! Chúng ta nhận được lỗi từ mã trong Listing 16-3 vì Rust đang bảo thủ và chỉ mượn v cho luồng, điều đó có nghĩa là luồng chính có thể theo lý thuyết làm mất hiệu lực tham chiếu của luồng được tạo ra. Bằng cách nói với Rust để chuyển quyền sở hữu của v cho luồng được tạo ra, chúng ta đang đảm bảo với Rust rằng luồng chính sẽ không sử dụng v nữa. Nếu chúng ta thay đổi Listing 16-4 theo cùng một cách, chúng ta sẽ vi phạm các quy tắc sở hữu khi chúng ta cố gắng sử dụng v trong luồng chính. Từ khóa move ghi đè mặc định bảo thủ của Rust về việc mượn; nó không cho phép chúng ta vi phạm các quy tắc sở hữu.

Bây giờ chúng ta đã đề cập đến những gì là luồng và các phương thức được cung cấp bởi API luồng, hãy xem một số tình huống mà chúng ta có thể sử dụng luồng.

Sử Dụng Trao Đổi Tin Nhắn để Truyền Dữ Liệu Giữa Các Thread

Một cách tiếp cận ngày càng phổ biến để đảm bảo đồng thời an toàn là trao đổi tin nhắn (message passing), trong đó các thread hoặc actor giao tiếp bằng cách gửi tin nhắn chứa dữ liệu cho nhau. Đây là ý tưởng được tóm tắt trong một khẩu hiệu từ tài liệu của ngôn ngữ Go: "Đừng giao tiếp bằng cách chia sẻ bộ nhớ; thay vào đó, hãy chia sẻ bộ nhớ bằng cách giao tiếp."

Để thực hiện tính đồng thời bằng cách gửi tin nhắn, thư viện chuẩn của Rust cung cấp một cài đặt của các kênh (channels). Một kênh là một khái niệm lập trình chung mà qua đó dữ liệu được gửi từ một thread sang thread khác.

Bạn có thể tưởng tượng một kênh trong lập trình giống như một kênh nước một chiều, chẳng hạn như một dòng suối hoặc một con sông. Nếu bạn đặt một vật gì đó như một con vịt cao su vào sông, nó sẽ trôi xuôi dòng đến cuối đường thủy.

Một kênh có hai nửa: một bộ phát (transmitter) và một bộ thu (receiver). Nửa bộ phát là vị trí thượng nguồn nơi bạn đặt con vịt cao su vào sông, và nửa bộ thu là nơi con vịt cao su kết thúc ở hạ nguồn. Một phần của mã của bạn gọi các phương thức trên bộ phát với dữ liệu bạn muốn gửi, và một phần khác kiểm tra đầu nhận để tìm các tin nhắn đến. Một kênh được gọi là đóng (closed) nếu một trong hai nửa bộ phát hoặc bộ thu bị loại bỏ.

Ở đây, chúng ta sẽ xây dựng một chương trình có một thread để tạo giá trị và gửi chúng qua một kênh, và một thread khác sẽ nhận các giá trị và in chúng ra. Chúng ta sẽ gửi các giá trị đơn giản giữa các thread bằng một kênh để minh họa tính năng này. Khi bạn đã quen với kỹ thuật này, bạn có thể sử dụng các kênh cho bất kỳ thread nào cần giao tiếp với nhau, chẳng hạn như một hệ thống chat hoặc một hệ thống mà nhiều thread thực hiện các phần của một phép tính và gửi các phần đó đến một thread tổng hợp kết quả.

Đầu tiên, trong Listing 16-6, chúng ta sẽ tạo một kênh nhưng chưa làm gì với nó. Lưu ý rằng đoạn mã này chưa thể biên dịch được vì Rust không thể xác định loại giá trị mà chúng ta muốn gửi qua kênh.

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

Chúng ta tạo một kênh mới bằng hàm mpsc::channel; mpsc là viết tắt của multiple producer, single consumer (nhiều người sản xuất, một người tiêu dùng). Nói ngắn gọn, cách thư viện chuẩn của Rust triển khai các kênh có nghĩa là một kênh có thể có nhiều đầu gửi tạo ra giá trị nhưng chỉ một đầu nhận tiêu thụ những giá trị đó. Hãy tưởng tượng nhiều dòng suối chảy vào một con sông lớn: mọi thứ được gửi xuống bất kỳ dòng suối nào đều sẽ kết thúc trong một con sông ở cuối. Hiện tại chúng ta sẽ bắt đầu với một nhà sản xuất duy nhất, nhưng chúng ta sẽ thêm nhiều nhà sản xuất khi làm cho ví dụ này hoạt động.

Hàm mpsc::channel trả về một tuple, phần tử đầu tiên là đầu gửi - bộ phát - và phần tử thứ hai là đầu nhận - bộ thu. Các chữ viết tắt txrx theo truyền thống được sử dụng trong nhiều lĩnh vực cho transmitter (bộ phát) và receiver (bộ thu), vì vậy chúng ta đặt tên cho các biến của mình như vậy để chỉ ra mỗi đầu. Chúng ta đang sử dụng câu lệnh let với một mẫu phân rã tuple; chúng ta sẽ thảo luận về việc sử dụng các mẫu trong câu lệnh let và phân rã trong Chương 19. Hiện tại, hãy biết rằng sử dụng câu lệnh let theo cách này là một phương pháp thuận tiện để trích xuất các phần của tuple được trả về bởi mpsc::channel.

Hãy chuyển đầu phát vào một thread được tạo ra và có nó gửi một chuỗi để thread được tạo ra đang giao tiếp với thread chính, như được hiển thị trong Listing 16-7. Điều này giống như đặt một con vịt cao su vào sông ở thượng nguồn hoặc gửi một tin nhắn trò chuyện từ một thread sang thread khác.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

Một lần nữa, chúng ta đang sử dụng thread::spawn để tạo một thread mới và sau đó sử dụng move để di chuyển tx vào closure để thread được tạo ra sở hữu tx. Thread được tạo ra cần sở hữu bộ phát để có thể gửi tin nhắn thông qua kênh.

Bộ phát có một phương thức send nhận giá trị mà chúng ta muốn gửi. Phương thức send trả về một kiểu Result<T, E>, vì vậy nếu bộ thu đã bị loại bỏ và không có nơi nào để gửi giá trị, thao tác gửi sẽ trả về một lỗi. Trong ví dụ này, chúng ta đang gọi unwrap để panic trong trường hợp có lỗi. Nhưng trong một ứng dụng thực tế, chúng ta sẽ xử lý nó đúng cách: quay lại Chương 9 để xem lại các chiến lược xử lý lỗi đúng cách.

Trong Listing 16-8, chúng ta sẽ nhận giá trị từ bộ thu trong thread chính. Điều này giống như lấy con vịt cao su từ nước ở cuối sông hoặc nhận một tin nhắn trò chuyện.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

Bộ thu có hai phương thức hữu ích: recvtry_recv. Chúng ta đang sử dụng recv, viết tắt của receive, sẽ chặn thực thi của thread chính và đợi cho đến khi một giá trị được gửi xuống kênh. Khi một giá trị được gửi, recv sẽ trả về nó trong một Result<T, E>. Khi bộ phát đóng, recv sẽ trả về một lỗi để báo hiệu rằng không có thêm giá trị nào sẽ đến.

Phương thức try_recv không chặn, nhưng thay vào đó sẽ trả về một Result<T, E> ngay lập tức: một giá trị Ok chứa một tin nhắn nếu có sẵn và một giá trị Err nếu không có tin nhắn nào vào lúc này. Sử dụng try_recv rất hữu ích nếu thread này có công việc khác cần làm trong khi chờ tin nhắn: chúng ta có thể viết một vòng lặp gọi try_recv thường xuyên, xử lý một tin nhắn nếu có, và nếu không thì làm việc khác trong một thời gian ngắn cho đến khi kiểm tra lại.

Chúng ta đã sử dụng recv trong ví dụ này để đơn giản hóa; chúng ta không có công việc nào khác cho thread chính để làm ngoài việc đợi tin nhắn, vì vậy việc chặn thread chính là phù hợp.

Khi chúng ta chạy mã trong Listing 16-8, chúng ta sẽ thấy giá trị được in ra từ thread chính:

Got: hi

Hoàn hảo!

Kênh và Chuyển Giao Quyền Sở Hữu

Các quy tắc sở hữu đóng vai trò quan trọng trong việc gửi tin nhắn vì chúng giúp bạn viết mã đồng thời an toàn. Ngăn chặn lỗi trong lập trình đồng thời là lợi thế của việc suy nghĩ về quyền sở hữu trong toàn bộ chương trình Rust của bạn. Hãy thực hiện một thí nghiệm để hiển thị cách kênh và quyền sở hữu hoạt động cùng nhau để ngăn chặn vấn đề: chúng ta sẽ cố gắng sử dụng một giá trị val trong thread được tạo ra sau khi chúng ta đã gửi nó xuống kênh. Hãy thử biên dịch mã trong Listing 16-9 để xem tại sao mã này không được phép.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

Ở đây, chúng ta cố gắng in val sau khi đã gửi nó xuống kênh thông qua tx.send. Cho phép điều này sẽ là một ý tưởng tồi: một khi giá trị đã được gửi đến một thread khác, thread đó có thể sửa đổi hoặc loại bỏ nó trước khi chúng ta cố gắng sử dụng lại giá trị. Có thể, các sửa đổi của thread khác có thể gây ra lỗi hoặc kết quả không mong muốn do dữ liệu không nhất quán hoặc không tồn tại. Tuy nhiên, Rust đưa ra một lỗi nếu chúng ta cố gắng biên dịch mã trong Listing 16-9:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:26
   |
8  |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                          ^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error

Lỗi đồng thời của chúng ta đã gây ra một lỗi biên dịch. Hàm send lấy quyền sở hữu tham số của nó, và khi giá trị được chuyển đi, bộ thu nhận quyền sở hữu của nó. Điều này ngăn chúng ta vô tình sử dụng lại giá trị sau khi gửi nó; hệ thống sở hữu kiểm tra rằng mọi thứ đều ổn.

Gửi Nhiều Giá Trị và Theo Dõi Bộ Thu Đang Chờ Đợi

Mã trong Listing 16-8 đã được biên dịch và chạy, nhưng nó không hiển thị rõ ràng cho chúng ta thấy rằng hai thread riêng biệt đang nói chuyện với nhau qua kênh. Trong Listing 16-10, chúng ta đã thực hiện một số sửa đổi sẽ chứng minh rằng mã trong Listing 16-8 đang chạy đồng thời: thread được tạo ra sẽ gửi nhiều tin nhắn và tạm dừng một giây giữa mỗi tin nhắn.

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}

Lần này, thread được tạo ra có một vector chuỗi mà chúng ta muốn gửi đến thread chính. Chúng ta lặp qua chúng, gửi từng cái riêng lẻ, và tạm dừng giữa mỗi lần bằng cách gọi hàm thread::sleep với một giá trị Duration là một giây.

Trong thread chính, chúng ta không còn gọi hàm recv một cách rõ ràng nữa: thay vào đó, chúng ta đang xử lý rx như một iterator. Đối với mỗi giá trị nhận được, chúng ta đang in nó ra. Khi kênh đóng, việc lặp sẽ kết thúc.

Khi chạy mã trong Listing 16-10, bạn sẽ thấy đầu ra sau với một khoảng dừng một giây giữa mỗi dòng:

Got: hi
Got: from
Got: the
Got: thread

Bởi vì chúng ta không có bất kỳ mã nào tạm dừng hoặc trì hoãn trong vòng lặp for trong thread chính, chúng ta có thể biết rằng thread chính đang đợi để nhận giá trị từ thread được tạo ra.

Tạo Nhiều Nhà Sản Xuất bằng cách Sao Chép Bộ Phát

Trước đó chúng ta đã đề cập rằng mpsc là từ viết tắt của multiple producer, single consumer (nhiều nhà sản xuất, một người tiêu dùng). Hãy sử dụng mpsc và mở rộng mã trong Listing 16-10 để tạo nhiều thread đều gửi giá trị đến cùng một bộ thu. Chúng ta có thể làm như vậy bằng cách sao chép bộ phát, như được hiển thị trong Listing 16-11.

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}

Lần này, trước khi chúng ta tạo thread đầu tiên, chúng ta gọi clone trên bộ phát. Điều này sẽ cung cấp cho chúng ta một bộ phát mới mà chúng ta có thể truyền cho thread được tạo ra đầu tiên. Chúng ta truyền bộ phát gốc cho thread thứ hai được tạo ra. Điều này cung cấp cho chúng ta hai thread, mỗi thread gửi các tin nhắn khác nhau đến một bộ thu.

Khi bạn chạy mã, đầu ra của bạn sẽ trông giống như thế này:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

Bạn có thể thấy các giá trị theo thứ tự khác, tùy thuộc vào hệ thống của bạn. Đây là điều làm cho tính đồng thời vừa thú vị vừa khó khăn. Nếu bạn thử nghiệm với thread::sleep, cung cấp cho nó các giá trị khác nhau trong các thread khác nhau, mỗi lần chạy sẽ không xác định hơn và tạo ra đầu ra khác nhau mỗi lần.

Bây giờ chúng ta đã xem xét cách kênh hoạt động, hãy xem xét một phương pháp đồng thời khác.

Đồng Thời với Trạng Thái Được Chia Sẻ

Truyền tin nhắn là một cách tốt để xử lý tính đồng thời, nhưng không phải là cách duy nhất. Một phương pháp khác là cho phép nhiều thread truy cập cùng một dữ liệu được chia sẻ. Hãy xem xét phần này từ khẩu hiệu trong tài liệu ngôn ngữ Go một lần nữa: "Đừng giao tiếp bằng cách chia sẻ bộ nhớ."

Giao tiếp bằng cách chia sẻ bộ nhớ sẽ trông như thế nào? Ngoài ra, tại sao những người đam mê phương pháp truyền tin nhắn lại khuyên không nên sử dụng việc chia sẻ bộ nhớ?

Theo một cách nào đó, các kênh trong bất kỳ ngôn ngữ lập trình nào cũng tương tự như quyền sở hữu đơn, bởi vì một khi bạn truyền một giá trị xuống kênh, bạn không nên sử dụng giá trị đó nữa. Đồng thời với bộ nhớ được chia sẻ giống như quyền sở hữu đa: nhiều thread có thể truy cập cùng một vị trí bộ nhớ tại cùng một thời điểm. Như bạn đã thấy trong Chương 15, nơi các con trỏ thông minh làm cho quyền sở hữu đa trở nên khả thi, quyền sở hữu đa có thể thêm độ phức tạp vì các chủ sở hữu khác nhau này cần được quản lý. Hệ thống kiểu và quy tắc sở hữu của Rust hỗ trợ rất nhiều trong việc quản lý này một cách chính xác. Ví dụ, hãy xem xét mutex, một trong những nguyên thủy đồng thời phổ biến nhất cho bộ nhớ được chia sẻ.

Sử Dụng Mutex để Cho Phép Truy Cập Dữ Liệu từ Một Thread tại Một Thời Điểm

Mutex là viết tắt của mutual exclusion (loại trừ tương hỗ), nghĩa là một mutex chỉ cho phép một thread truy cập một số dữ liệu tại bất kỳ thời điểm nào. Để truy cập dữ liệu trong một mutex, một thread trước tiên phải báo hiệu rằng nó muốn truy cập bằng cách yêu cầu có được khóa của mutex. Khóa là một cấu trúc dữ liệu là một phần của mutex, giúp theo dõi ai hiện đang có quyền truy cập độc quyền vào dữ liệu. Do đó, mutex được mô tả là bảo vệ dữ liệu mà nó chứa thông qua hệ thống khóa.

Mutex có tiếng là khó sử dụng vì bạn phải nhớ hai quy tắc:

  1. Bạn phải cố gắng có được khóa trước khi sử dụng dữ liệu.
  2. Khi bạn đã hoàn thành việc sử dụng dữ liệu mà mutex bảo vệ, bạn phải mở khóa dữ liệu để các thread khác có thể có được khóa.

Để có một phép ẩn dụ thực tế cho mutex, hãy tưởng tượng một cuộc thảo luận tại một hội nghị với chỉ một micro. Trước khi một người tham gia có thể nói chuyện, họ phải yêu cầu hoặc báo hiệu rằng họ muốn sử dụng micro. Khi họ nhận được micro, họ có thể nói chuyện trong bao lâu tùy thích và sau đó chuyển micro cho người tham gia tiếp theo muốn phát biểu. Nếu một người tham gia quên chuyển micro khi họ đã nói xong, không ai khác có thể nói chuyện. Nếu việc quản lý micro được chia sẻ bị sai, cuộc thảo luận sẽ không diễn ra như dự kiến!

Việc quản lý mutex có thể rất khó để làm đúng, đó là lý do tại sao rất nhiều người đam mê sử dụng kênh. Tuy nhiên, nhờ có hệ thống kiểu và quy tắc sở hữu của Rust, bạn không thể khóa và mở khóa sai.

API của Mutex<T>

Để làm ví dụ về cách sử dụng mutex, hãy bắt đầu bằng cách sử dụng mutex trong một ngữ cảnh đơn thread, như trong Listing 16-12.

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}

Giống như nhiều kiểu, chúng ta tạo một Mutex<T> bằng cách sử dụng hàm liên kết new. Để truy cập dữ liệu bên trong mutex, chúng ta sử dụng phương thức lock để có được khóa. Lệnh gọi này sẽ chặn thread hiện tại nên nó không thể làm bất kỳ công việc nào cho đến khi đến lượt chúng ta có khóa.

Lệnh gọi đến lock sẽ thất bại nếu một thread khác đang giữ khóa bị panic. Trong trường hợp đó, không ai có thể có được khóa, vì vậy chúng ta đã chọn unwrap và có thread này panic nếu chúng ta ở trong tình huống đó.

Sau khi chúng ta đã có được khóa, chúng ta có thể coi giá trị trả về, có tên là num trong trường hợp này, như một tham chiếu có thể thay đổi đến dữ liệu bên trong. Hệ thống kiểu đảm bảo rằng chúng ta có được khóa trước khi sử dụng giá trị trong m. Kiểu của mMutex<i32>, không phải i32, vì vậy chúng ta phải gọi lock để có thể sử dụng giá trị i32. Chúng ta không thể quên; hệ thống kiểu sẽ không cho phép chúng ta truy cập giá trị i32 bên trong nếu không.

Như bạn có thể nghi ngờ, Mutex<T> là một con trỏ thông minh. Chính xác hơn, lệnh gọi đến lock trả về một con trỏ thông minh được gọi là MutexGuard, được bọc trong một LockResult mà chúng ta đã xử lý bằng cách gọi unwrap. Con trỏ thông minh MutexGuard thực hiện Deref để trỏ đến dữ liệu bên trong của chúng ta; con trỏ thông minh cũng có một triển khai Drop giải phóng khóa một cách tự động khi MutexGuard ra khỏi phạm vi, điều này xảy ra vào cuối phạm vi bên trong. Kết quả là, chúng ta không có nguy cơ quên giải phóng khóa và chặn mutex không được sử dụng bởi các thread khác, vì việc giải phóng khóa xảy ra tự động.

Sau khi thả khóa, chúng ta có thể in giá trị mutex và thấy rằng chúng ta đã có thể thay đổi giá trị i32 bên trong thành 6.

Chia Sẻ Mutex<T> Giữa Nhiều Thread

Bây giờ hãy thử chia sẻ một giá trị giữa nhiều thread bằng cách sử dụng Mutex<T>. Chúng ta sẽ tạo 10 thread và có mỗi thread tăng giá trị bộ đếm lên 1, vì vậy bộ đếm sẽ tăng từ 0 lên 10. Ví dụ trong Listing 16-13 sẽ có lỗi trình biên dịch, và chúng ta sẽ sử dụng lỗi đó để tìm hiểu thêm về cách sử dụng Mutex<T> và cách Rust giúp chúng ta sử dụng nó đúng cách.

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Chúng ta tạo một biến counter để chứa một i32 bên trong một Mutex<T>, như chúng ta đã làm trong Listing 16-12. Tiếp theo, chúng ta tạo 10 thread bằng cách lặp qua một dãy số. Chúng ta sử dụng thread::spawn và cung cấp cho tất cả các thread cùng một closure: closure di chuyển bộ đếm vào thread, lấy khóa trên Mutex<T> bằng cách gọi phương thức lock, và sau đó cộng 1 vào giá trị trong mutex. Khi một thread hoàn thành việc chạy closure của nó, num sẽ ra khỏi phạm vi và giải phóng khóa để thread khác có thể lấy khóa.

Trong thread chính, chúng ta thu thập tất cả các handle join. Sau đó, như chúng ta đã làm trong Listing 16-2, chúng ta gọi join trên mỗi handle để đảm bảo tất cả các thread đều hoàn thành. Tại thời điểm đó, thread chính sẽ có được khóa và in kết quả của chương trình này.

Chúng ta đã gợi ý rằng ví dụ này sẽ không biên dịch. Bây giờ hãy tìm hiểu tại sao!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8  |     for _ in 0..10 {
   |     -------------- inside of this loop
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
8  ~     let mut value = counter.lock();
9  ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

Thông báo lỗi nói rằng giá trị counter đã được di chuyển trong lần lặp trước của vòng lặp. Rust đang nói với chúng ta rằng chúng ta không thể di chuyển quyền sở hữu của khóa counter vào nhiều thread. Hãy sửa lỗi trình biên dịch bằng phương pháp sở hữu đa mà chúng ta đã thảo luận trong Chương 15.

Sở Hữu Đa với Nhiều Thread

Trong Chương 15, chúng ta đã gán một giá trị cho nhiều chủ sở hữu bằng cách sử dụng con trỏ thông minh Rc<T> để tạo một giá trị được đếm tham chiếu. Hãy làm tương tự ở đây và xem điều gì xảy ra. Chúng ta sẽ bọc Mutex<T> trong Rc<T> trong Listing 16-14 và sao chép Rc<T> trước khi di chuyển quyền sở hữu vào thread.

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Một lần nữa, chúng ta biên dịch và nhận được... các lỗi khác nhau! Trình biên dịch đang dạy chúng ta rất nhiều.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

Wow, thông báo lỗi đó rất dài dòng! Đây là phần quan trọng cần tập trung: `Rc<Mutex<i32>>` không thể được gửi giữa các thread một cách an toàn. Trình biên dịch cũng đang nói với chúng ta lý do tại sao: đặc tính `Send` không được triển khai cho `Rc<Mutex<i32>>`. Chúng ta sẽ nói về Send trong phần tiếp theo: đó là một trong những đặc tính đảm bảo rằng các kiểu mà chúng ta sử dụng với thread được thiết kế để sử dụng trong các tình huống đồng thời.

Thật không may, Rc<T> không an toàn để chia sẻ giữa các thread. Khi Rc<T> quản lý số lượng tham chiếu, nó cộng vào số lượng cho mỗi lệnh gọi clone và trừ từ số lượng khi mỗi bản sao được loại bỏ. Nhưng nó không sử dụng bất kỳ nguyên thủy đồng thời nào để đảm bảo rằng các thay đổi đối với số lượng không thể bị gián đoạn bởi một thread khác. Điều này có thể dẫn đến số lượng sai - các lỗi tinh vi có thể dẫn đến rò rỉ bộ nhớ hoặc một giá trị bị loại bỏ trước khi chúng ta hoàn thành với nó. Những gì chúng ta cần là một kiểu giống hệt như Rc<T> nhưng thực hiện thay đổi số lượng tham chiếu theo cách an toàn với thread.

Đếm Tham Chiếu Nguyên Tử với Arc<T>

May mắn thay, Arc<T> một kiểu giống như Rc<T> mà an toàn để sử dụng trong các tình huống đồng thời. Chữ a là viết tắt của atomic (nguyên tử), có nghĩa là nó là một kiểu đếm tham chiếu nguyên tử. Nguyên tử là một loại nguyên thủy đồng thời bổ sung mà chúng ta sẽ không đề cập chi tiết ở đây: xem tài liệu thư viện chuẩn cho std::sync::atomic để biết thêm chi tiết. Tại thời điểm này, bạn chỉ cần biết rằng nguyên tử hoạt động giống như các kiểu nguyên thủy nhưng an toàn để chia sẻ giữa các thread.

Bạn có thể thắc mắc tại sao tất cả các kiểu nguyên thủy không phải là nguyên tử và tại sao các kiểu thư viện chuẩn không được triển khai để sử dụng Arc<T> theo mặc định. Lý do là vì an toàn thread đi kèm với một mức phạt hiệu suất mà bạn chỉ muốn trả khi thực sự cần. Nếu bạn chỉ thực hiện các thao tác trên các giá trị trong một thread duy nhất, mã của bạn có thể chạy nhanh hơn nếu nó không phải thực thi các bảo đảm mà nguyên tử cung cấp.

Hãy quay lại ví dụ của chúng ta: Arc<T>Rc<T> có cùng một API, vì vậy chúng ta sửa chương trình của mình bằng cách thay đổi dòng use, lệnh gọi đến new, và lệnh gọi đến clone. Mã trong Listing 16-15 cuối cùng sẽ biên dịch và chạy.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Mã này sẽ in ra như sau:

Result: 10

Chúng ta đã làm được! Chúng ta đã đếm từ 0 đến 10, điều này có vẻ không quá ấn tượng, nhưng nó đã dạy chúng ta rất nhiều về Mutex<T> và an toàn thread. Bạn cũng có thể sử dụng cấu trúc chương trình này để thực hiện các thao tác phức tạp hơn chỉ là tăng bộ đếm. Sử dụng chiến lược này, bạn có thể chia một phép tính thành các phần độc lập, chia các phần đó giữa các thread, và sau đó sử dụng Mutex<T> để có mỗi thread cập nhật kết quả cuối cùng với phần của nó.

Lưu ý rằng nếu bạn đang thực hiện các phép toán số đơn giản, có các kiểu đơn giản hơn Mutex<T> được cung cấp bởi mô-đun std::sync::atomic của thư viện chuẩn. Các kiểu này cung cấp truy cập an toàn, đồng thời, nguyên tử vào các kiểu nguyên thủy. Chúng ta đã chọn sử dụng Mutex<T> với một kiểu nguyên thủy cho ví dụ này để chúng ta có thể tập trung vào cách Mutex<T> hoạt động.

Sự Tương Đồng Giữa RefCell<T>/Rc<T>Mutex<T>/Arc<T>

Bạn có thể đã nhận thấy rằng counter không thể thay đổi nhưng chúng ta có thể nhận được một tham chiếu có thể thay đổi đến giá trị bên trong nó; điều này có nghĩa là Mutex<T> cung cấp khả năng thay đổi nội bộ, giống như họ Cell làm. Theo cách tương tự, chúng ta đã sử dụng RefCell<T> trong Chương 15 để cho phép chúng ta thay đổi nội dung bên trong Rc<T>, chúng ta sử dụng Mutex<T> để thay đổi nội dung bên trong Arc<T>.

Một chi tiết khác cần lưu ý là Rust không thể bảo vệ bạn khỏi tất cả các loại lỗi logic khi bạn sử dụng Mutex<T>. Nhớ lại từ Chương 15 rằng việc sử dụng Rc<T> đi kèm với nguy cơ tạo ra các chu kỳ tham chiếu, trong đó hai giá trị Rc<T> tham chiếu đến nhau, gây ra rò rỉ bộ nhớ. Tương tự, Mutex<T> đi kèm với nguy cơ tạo ra deadlocks (bế tắc). Những điều này xảy ra khi một thao tác cần khóa hai tài nguyên và hai thread mỗi cái đã có được một trong các khóa, khiến chúng đợi nhau mãi mãi. Nếu bạn quan tâm đến deadlocks, hãy thử tạo một chương trình Rust có deadlock; sau đó nghiên cứu các chiến lược giảm thiểu deadlock cho mutex trong bất kỳ ngôn ngữ nào và thử triển khai chúng trong Rust. Tài liệu API của thư viện chuẩn cho Mutex<T>MutexGuard cung cấp thông tin hữu ích.

Chúng ta sẽ kết thúc chương này bằng cách nói về các đặc tính SendSync và cách chúng ta có thể sử dụng chúng với các kiểu tùy chỉnh.

Đồng Thời Mở Rộng với Các Đặc Tính SendSync

Điều thú vị là, hầu hết các tính năng đồng thời mà chúng ta đã nói đến trong chương này đều là một phần của thư viện chuẩn, không phải ngôn ngữ. Lựa chọn của bạn để xử lý tính đồng thời không bị giới hạn ở ngôn ngữ hoặc thư viện chuẩn; bạn có thể viết các tính năng đồng thời của riêng mình hoặc sử dụng những tính năng được viết bởi người khác.

Tuy nhiên, trong số các khái niệm đồng thời quan trọng được nhúng vào ngôn ngữ thay vì thư viện chuẩn là các đặc tính std::markerSendSync.

Cho Phép Chuyển Giao Quyền Sở Hữu Giữa Các Thread với Send

Đặc tính đánh dấu Send chỉ ra rằng quyền sở hữu của các giá trị thuộc kiểu triển khai Send có thể được chuyển giao giữa các thread. Hầu hết mọi kiểu Rust đều là Send, nhưng có một số ngoại lệ, bao gồm Rc<T>: kiểu này không thể triển khai Send vì nếu bạn sao chép một giá trị Rc<T> và cố gắng chuyển quyền sở hữu của bản sao đó sang một thread khác, cả hai thread có thể cập nhật số lượng tham chiếu cùng một lúc. Vì lý do này, Rc<T> được triển khai để sử dụng trong các tình huống đơn thread, nơi bạn không muốn trả giá cho hiệu suất an toàn thread.

Do đó, hệ thống kiểu và ràng buộc đặc tính của Rust đảm bảo rằng bạn không bao giờ vô tình gửi một giá trị Rc<T> qua các thread một cách không an toàn. Khi chúng ta cố gắng làm điều này trong Listing 16-14, chúng ta đã nhận được lỗi the trait Send is not implemented for Rc<Mutex<i32>>. Khi chúng ta chuyển sang Arc<T>, vốn triển khai Send, mã đã biên dịch thành công.

Bất kỳ kiểu nào được tạo thành hoàn toàn từ các kiểu Send cũng tự động được đánh dấu là Send. Hầu hết tất cả các kiểu nguyên thủy đều là Send, ngoại trừ con trỏ thô, thứ mà chúng ta sẽ thảo luận trong Chương 20.

Cho Phép Truy Cập từ Nhiều Thread với Sync

Đặc tính đánh dấu Sync chỉ ra rằng kiểu triển khai Sync an toàn để được tham chiếu từ nhiều thread. Nói cách khác, bất kỳ kiểu T nào triển khai Sync nếu &T (một tham chiếu không thay đổi đến T) triển khai Send, nghĩa là tham chiếu có thể được gửi an toàn đến một thread khác. Tương tự như Send, các kiểu nguyên thủy đều triển khai Sync, và các kiểu được tạo thành hoàn toàn từ các kiểu triển khai Sync cũng triển khai Sync.

Con trỏ thông minh Rc<T> cũng không triển khai Sync vì cùng những lý do mà nó không triển khai Send. Kiểu RefCell<T> (mà chúng ta đã nói đến trong Chương 15) và họ các kiểu Cell<T> liên quan không triển khai Sync. Việc triển khai kiểm tra mượn mà RefCell<T> thực hiện trong thời gian chạy không an toàn cho thread. Con trỏ thông minh Mutex<T> triển khai Sync và có thể được sử dụng để chia sẻ quyền truy cập với nhiều thread như bạn đã thấy trong "Chia Sẻ Mutex<T> Giữa Nhiều Thread".

Triển Khai SendSync Thủ Công Là Không An Toàn

Vì các kiểu được tạo thành hoàn toàn từ các kiểu khác triển khai các đặc tính SendSync cũng tự động triển khai SendSync, chúng ta không cần phải triển khai những đặc tính này thủ công. Là các đặc tính đánh dấu, chúng thậm chí không có bất kỳ phương thức nào để triển khai. Chúng chỉ hữu ích để thực thi các bất biến liên quan đến tính đồng thời.

Việc triển khai thủ công các đặc tính này liên quan đến việc triển khai mã Rust không an toàn. Chúng ta sẽ nói về việc sử dụng mã Rust không an toàn trong Chương 20; hiện tại, thông tin quan trọng là việc xây dựng các kiểu đồng thời mới không được tạo từ các phần SendSync đòi hỏi suy nghĩ cẩn thận để duy trì các đảm bảo an toàn. "The Rustonomicon" có thêm thông tin về các đảm bảo này và cách để duy trì chúng.

Tóm Tắt

Đây không phải là lần cuối cùng bạn thấy tính đồng thời trong cuốn sách này: chương tiếp theo tập trung vào lập trình bất đồng bộ, và dự án trong Chương 21 sẽ sử dụng các khái niệm trong chương này trong một tình huống thực tế hơn so với các ví dụ nhỏ được thảo luận ở đây.

Như đã đề cập trước đó, vì rất ít phần trong cách Rust xử lý tính đồng thời là một phần của ngôn ngữ, nhiều giải pháp đồng thời được triển khai dưới dạng các crate. Những crate này phát triển nhanh hơn so với thư viện chuẩn, vì vậy hãy nhớ tìm kiếm online cho các crate hiện đại, tiên tiến để sử dụng trong các tình huống đa luồng.

Thư viện chuẩn của Rust cung cấp các kênh để truyền tin nhắn và các kiểu con trỏ thông minh, như Mutex<T>Arc<T>, an toàn để sử dụng trong các ngữ cảnh đồng thời. Hệ thống kiểu và bộ kiểm tra mượn đảm bảo rằng mã sử dụng các giải pháp này sẽ không gặp phải các cuộc đua dữ liệu hoặc tham chiếu không hợp lệ. Khi bạn đã biên dịch được mã của mình, bạn có thể yên tâm rằng nó sẽ chạy tốt trên nhiều thread mà không gặp phải những lỗi khó theo dõi thường thấy trong các ngôn ngữ khác. Lập trình đồng thời không còn là một khái niệm đáng sợ nữa: hãy tiến tới và làm cho chương trình của bạn chạy đồng thời, không sợ hãi!

Cơ Bản về Lập Trình Bất Đồng Bộ: Async, Await, Futures, và Streams

Nhiều thao tác mà chúng ta yêu cầu máy tính thực hiện có thể mất một khoảng thời gian để hoàn thành. Sẽ thật tốt nếu chúng ta có thể làm việc khác trong khi đang chờ đợi những quá trình chạy lâu đó hoàn thành. Máy tính hiện đại cung cấp hai kỹ thuật để làm nhiều thao tác cùng một lúc: song song và đồng thời. Tuy nhiên, một khi chúng ta bắt đầu viết các chương trình liên quan đến các thao tác song song hoặc đồng thời, chúng ta nhanh chóng gặp phải những thách thức mới vốn có trong lập trình bất đồng bộ, nơi các thao tác có thể không hoàn thành tuần tự theo thứ tự chúng được bắt đầu. Chương này phát triển từ việc sử dụng thread trong Chương 16 cho tính song song và đồng thời bằng cách giới thiệu một cách tiếp cận thay thế cho lập trình bất đồng bộ: Futures, Streams của Rust, cú pháp asyncawait hỗ trợ chúng, và các công cụ để quản lý và điều phối giữa các thao tác bất đồng bộ.

Hãy xem xét một ví dụ. Giả sử bạn đang xuất một video về buổi lễ gia đình mà bạn đã tạo, một thao tác có thể mất từ vài phút đến vài giờ. Việc xuất video sẽ sử dụng càng nhiều năng lực CPU và GPU càng tốt. Nếu bạn chỉ có một lõi CPU và hệ điều hành của bạn không tạm dừng việc xuất đó cho đến khi nó hoàn thành — nghĩa là, nếu nó thực hiện việc xuất đồng bộ — bạn không thể làm bất cứ điều gì khác trên máy tính của mình trong khi tác vụ đó đang chạy. Đó sẽ là một trải nghiệm khá khó chịu. May mắn thay, hệ điều hành của máy tính bạn có thể, và thực sự, ngắt quãng việc xuất một cách vô hình thường xuyên để cho phép bạn làm việc khác đồng thời.

Bây giờ giả sử bạn đang tải xuống một video được chia sẻ bởi người khác, điều này cũng có thể mất một thời gian nhưng không chiếm nhiều thời gian CPU. Trong trường hợp này, CPU phải đợi dữ liệu đến từ mạng. Mặc dù bạn có thể bắt đầu đọc dữ liệu khi nó bắt đầu đến, nhưng có thể mất một thời gian để tất cả hiện lên. Ngay cả khi dữ liệu đã có đầy đủ, nếu video khá lớn, có thể mất ít nhất một hoặc hai giây để tải tất cả. Điều đó có thể không nghe có vẻ nhiều, nhưng đó là một khoảng thời gian rất dài đối với bộ xử lý hiện đại, có thể thực hiện hàng tỷ thao tác mỗi giây. Một lần nữa, hệ điều hành của bạn sẽ ngắt quãng chương trình của bạn một cách vô hình để cho phép CPU thực hiện công việc khác trong khi chờ cuộc gọi mạng hoàn thành.

Việc xuất video là một ví dụ về thao tác bị ràng buộc bởi CPU hoặc bị ràng buộc bởi tính toán. Nó bị giới hạn bởi tốc độ xử lý dữ liệu tiềm năng của máy tính trong CPU hoặc GPU, và bao nhiêu trong số tốc độ đó nó có thể dành cho thao tác đó. Việc tải xuống video là một ví dụ về thao tác bị ràng buộc bởi IO, bởi vì nó bị giới hạn bởi tốc độ của đầu vào và đầu ra của máy tính; nó chỉ có thể nhanh bằng tốc độ dữ liệu có thể được gửi qua mạng.

Trong cả hai ví dụ này, sự gián đoạn vô hình của hệ điều hành cung cấp một hình thức của tính đồng thời. Tuy nhiên, tính đồng thời đó chỉ xảy ra ở cấp độ của toàn bộ chương trình: hệ điều hành ngắt quãng một chương trình để cho phép các chương trình khác thực hiện công việc. Trong nhiều trường hợp, bởi vì chúng ta hiểu các chương trình của mình ở một mức độ chi tiết hơn nhiều so với hệ điều hành, chúng ta có thể phát hiện các cơ hội cho tính đồng thời mà hệ điều hành không thể thấy.

Ví dụ, nếu chúng ta đang xây dựng một công cụ để quản lý việc tải xuống tệp, chúng ta nên có thể viết chương trình của mình để việc bắt đầu một lần tải xuống sẽ không khóa giao diện người dùng, và người dùng nên có thể bắt đầu nhiều lần tải xuống cùng một lúc. Tuy nhiên, nhiều API hệ điều hành để tương tác với mạng là chặn, nghĩa là chúng chặn tiến trình của chương trình cho đến khi dữ liệu mà chúng đang xử lý hoàn toàn sẵn sàng.

Lưu ý: Đây là cách hoạt động của hầu hết các lệnh gọi hàm, nếu bạn nghĩ về nó. Tuy nhiên, thuật ngữ chặn thường được dành riêng cho các lệnh gọi hàm tương tác với tệp, mạng, hoặc các tài nguyên khác trên máy tính, bởi vì đó là những trường hợp mà một chương trình riêng lẻ sẽ được hưởng lợi từ thao tác không chặn.

Chúng ta có thể tránh chặn thread chính của mình bằng cách tạo ra một thread riêng để tải xuống mỗi tệp. Tuy nhiên, chi phí phụ của những thread đó cuối cùng sẽ trở thành một vấn đề. Sẽ tốt hơn nếu lệnh gọi không chặn ngay từ đầu. Cũng sẽ tốt hơn nếu chúng ta có thể viết theo cùng một kiểu trực tiếp mà chúng ta sử dụng trong mã chặn, tương tự như sau:

let data = fetch_data_from(url).await;
println!("{data}");

Đó chính xác là những gì mà trừu tượng async (viết tắt của asynchronous) của Rust cung cấp cho chúng ta. Trong chương này, bạn sẽ tìm hiểu tất cả về async khi chúng ta đề cập đến các chủ đề sau:

  • Cách sử dụng cú pháp asyncawait của Rust
  • Cách sử dụng mô hình async để giải quyết một số thách thức tương tự mà chúng ta đã xem xét trong Chương 16
  • Cách đa luồng và async cung cấp các giải pháp bổ sung cho nhau, mà bạn có thể kết hợp trong nhiều trường hợp

Tuy nhiên, trước khi chúng ta thấy cách async hoạt động trong thực tế, chúng ta cần có một đoạn ngắn để thảo luận về sự khác biệt giữa tính song song và tính đồng thời.

Tính Song Song và Tính Đồng Thời

Chúng ta đã coi tính song song và tính đồng thời là phần lớn có thể hoán đổi cho nhau cho đến nay. Bây giờ chúng ta cần phân biệt giữa chúng một cách chính xác hơn, bởi vì sự khác biệt sẽ xuất hiện khi chúng ta bắt đầu làm việc.

Hãy xem xét các cách khác nhau mà một nhóm có thể chia nhỏ công việc trên một dự án phần mềm. Bạn có thể giao cho một thành viên nhiều nhiệm vụ, giao cho mỗi thành viên một nhiệm vụ, hoặc sử dụng kết hợp của hai phương pháp.

Khi một cá nhân làm việc trên nhiều nhiệm vụ khác nhau trước khi bất kỳ nhiệm vụ nào hoàn thành, đây là tính đồng thời. Có thể bạn có hai dự án khác nhau được kiểm tra trên máy tính của bạn, và khi bạn cảm thấy chán hoặc bị kẹt trên một dự án, bạn chuyển sang dự án khác. Bạn chỉ là một người, vì vậy bạn không thể tiến triển trên cả hai nhiệm vụ cùng một lúc chính xác, nhưng bạn có thể đa nhiệm, tiến triển trên một nhiệm vụ tại một thời điểm bằng cách chuyển đổi giữa chúng (xem Hình 17-1).

Một sơ đồ với các hộp được dán nhãn Task A và Task B, với các viên kim cương trong chúng đại diện cho các nhiệm vụ phụ. Có các mũi tên chỉ từ A1 đến B1, B1 đến A2, A2 đến B2, B2 đến A3, A3 đến A4, và A4 đến B3. Các mũi tên giữa các nhiệm vụ phụ vượt qua các hộp giữa Task A và Task B.
Hình 17-1: Một luồng công việc đồng thời, chuyển đổi giữa Nhiệm vụ A và Nhiệm vụ B

Khi nhóm chia nhỏ một nhóm nhiệm vụ bằng cách để mỗi thành viên nhận một nhiệm vụ và làm việc trên đó một mình, đây là tính song song. Mỗi người trong nhóm có thể tiến triển chính xác cùng một lúc (xem Hình 17-2).

Một sơ đồ với các hộp được dán nhãn Task A và Task B, với các viên kim cương trong chúng đại diện cho các nhiệm vụ phụ. Có các mũi tên chỉ từ A1 đến A2, A2 đến A3, A3 đến A4, B1 đến B2, và B2 đến B3. Không có mũi tên nào đi qua giữa các hộp cho Task A và Task B.
Hình 17-2: Một luồng công việc song song, nơi công việc diễn ra trên Nhiệm vụ A và Nhiệm vụ B một cách độc lập

Trong cả hai luồng công việc này, bạn có thể phải phối hợp giữa các nhiệm vụ khác nhau. Có thể bạn nghĩ rằng nhiệm vụ được giao cho một người hoàn toàn độc lập với công việc của mọi người khác, nhưng trên thực tế nó yêu cầu một người khác trong nhóm phải hoàn thành nhiệm vụ của họ trước. Một số công việc có thể được thực hiện song song, nhưng một số công việc lại thực sự là tuần tự: nó chỉ có thể xảy ra trong một chuỗi, một nhiệm vụ sau nhiệm vụ khác, như trong Hình 17-3.

Một sơ đồ với các hộp được dán nhãn Task A và Task B, với các viên kim cương trong chúng đại diện cho các nhiệm vụ phụ. Có các mũi tên chỉ từ A1 đến A2, A2 đến một cặp đường thẳng dọc dày như một biểu tượng 'tạm dừng', từ biểu tượng đó đến A3, B1 đến B2, B2 đến B3, nằm bên dưới biểu tượng đó, B3 đến A3, và B3 đến B4.
Hình 17-3: Một luồng công việc song song một phần, nơi công việc diễn ra trên Nhiệm vụ A và Nhiệm vụ B một cách độc lập cho đến khi Nhiệm vụ A3 bị chặn do kết quả của Nhiệm vụ B3.

Tương tự, bạn có thể nhận ra rằng một trong các nhiệm vụ của bạn phụ thuộc vào một nhiệm vụ khác của bạn. Bây giờ công việc đồng thời của bạn cũng đã trở thành tuần tự.

Tính song song và tính đồng thời cũng có thể giao nhau. Nếu bạn biết rằng một đồng nghiệp bị kẹt cho đến khi bạn hoàn thành một trong các nhiệm vụ của mình, bạn có thể sẽ tập trung tất cả nỗ lực vào nhiệm vụ đó để "mở khóa" cho đồng nghiệp của bạn. Bạn và đồng nghiệp của bạn không còn có thể làm việc song song, và bạn cũng không còn có thể làm việc đồng thời trên các nhiệm vụ của riêng bạn.

Cùng một động lực cơ bản cũng áp dụng với phần mềm và phần cứng. Trên một máy với một lõi CPU duy nhất, CPU chỉ có thể thực hiện một thao tác tại một thời điểm, nhưng nó vẫn có thể làm việc đồng thời. Sử dụng các công cụ như thread, tiến trình, và async, máy tính có thể tạm dừng một hoạt động và chuyển sang các hoạt động khác trước khi cuối cùng quay lại hoạt động đầu tiên đó. Trên một máy với nhiều lõi CPU, nó cũng có thể làm việc song song. Một lõi có thể thực hiện một nhiệm vụ trong khi một lõi khác thực hiện một nhiệm vụ hoàn toàn không liên quan, và những thao tác đó thực sự xảy ra cùng một lúc.

Khi làm việc với async trong Rust, chúng ta luôn đang xử lý tính đồng thời. Tùy thuộc vào phần cứng, hệ điều hành, và runtime async mà chúng ta đang sử dụng (sẽ nói thêm về runtime async sau), tính đồng thời đó cũng có thể sử dụng tính song song bên dưới nắp capo.

Bây giờ, hãy đi sâu vào cách lập trình async trong Rust thực sự hoạt động.

Futures và Cú Pháp Async

Các yếu tố chính của lập trình bất đồng bộ trong Rust là futures và các từ khóa asyncawait của Rust.

Một future là một giá trị có thể chưa sẵn sàng ngay bây giờ nhưng sẽ trở nên sẵn sàng tại một thời điểm nào đó trong tương lai. (Khái niệm này xuất hiện trong nhiều ngôn ngữ, đôi khi dưới các tên khác như task hoặc promise.) Rust cung cấp một đặc tính Future như một khối xây dựng để các thao tác bất đồng bộ khác nhau có thể được triển khai với các cấu trúc dữ liệu khác nhau nhưng với một giao diện chung. Trong Rust, futures là các kiểu triển khai đặc tính Future. Mỗi future chứa thông tin riêng về tiến trình đã được thực hiện và "sẵn sàng" có nghĩa là gì.

Bạn có thể áp dụng từ khóa async cho các khối và hàm để chỉ định rằng chúng có thể bị gián đoạn và tiếp tục. Trong một khối async hoặc hàm async, bạn có thể sử dụng từ khóa await để đợi một future (nghĩa là, đợi nó trở nên sẵn sàng). Bất kỳ điểm nào mà bạn đợi một future trong một khối async hoặc hàm là một vị trí tiềm năng để khối async hoặc hàm đó tạm dừng và tiếp tục. Quá trình kiểm tra với một future để xem giá trị của nó đã có sẵn chưa được gọi là polling.

Một số ngôn ngữ khác, như C# và JavaScript, cũng sử dụng các từ khóa asyncawait cho lập trình bất đồng bộ. Nếu bạn quen thuộc với những ngôn ngữ đó, bạn có thể nhận thấy một số khác biệt đáng kể trong cách Rust thực hiện mọi thứ, bao gồm cách nó xử lý cú pháp. Điều đó có lý do chính đáng, như chúng ta sẽ thấy!

Khi viết Rust bất đồng bộ, chúng ta sử dụng các từ khóa asyncawait trong hầu hết thời gian. Rust biên dịch chúng thành mã tương đương sử dụng đặc tính Future, giống như nó biên dịch vòng lặp for thành mã tương đương sử dụng đặc tính Iterator. Tuy nhiên, vì Rust cung cấp đặc tính Future, bạn cũng có thể triển khai nó cho các kiểu dữ liệu của riêng bạn khi cần. Nhiều hàm mà chúng ta sẽ thấy trong suốt chương này trả về các kiểu có triển khai Future riêng của chúng. Chúng ta sẽ quay lại định nghĩa của đặc tính vào cuối chương và đi sâu vào cách nó hoạt động, nhưng đây là đủ chi tiết để giúp chúng ta tiếp tục tiến về phía trước.

Điều này có thể cảm thấy hơi trừu tượng, vì vậy hãy viết chương trình bất đồng bộ đầu tiên của chúng ta: một trình thu thập web nhỏ. Chúng ta sẽ truyền vào hai URL từ dòng lệnh, lấy cả hai cùng một lúc, và trả về kết quả của URL nào hoàn thành trước. Ví dụ này sẽ có khá nhiều cú pháp mới, nhưng đừng lo lắng — chúng ta sẽ giải thích mọi thứ bạn cần biết khi tiến hành.

Chương Trình Async Đầu Tiên Của Chúng Ta

Để giữ trọng tâm của chương này vào việc học async thay vì tung hứng các phần của hệ sinh thái, chúng ta đã tạo ra crate trpl (trpl là viết tắt của "The Rust Programming Language"). Nó tái xuất tất cả các kiểu, đặc tính và hàm mà bạn sẽ cần, chủ yếu từ các crate futurestokio. Crate futures là nơi chính thức để thử nghiệm mã async của Rust, và đó thực sự là nơi đặc tính Future được thiết kế ban đầu. Tokio là runtime async được sử dụng rộng rãi nhất trong Rust hiện nay, đặc biệt là cho các ứng dụng web. Có những runtime tuyệt vời khác, và chúng có thể phù hợp hơn cho mục đích của bạn. Chúng ta sử dụng crate tokio bên dưới cho trpl vì nó đã được kiểm tra kỹ lưỡng và được sử dụng rộng rãi.

Trong một số trường hợp, trpl cũng đổi tên hoặc bọc các API gốc để giúp bạn tập trung vào các chi tiết liên quan đến chương này. Nếu bạn muốn hiểu những gì crate làm, chúng tôi khuyến khích bạn kiểm tra mã nguồn của nó. Bạn sẽ có thể thấy mỗi tái xuất đến từ crate nào, và chúng tôi đã để lại các bình luận mở rộng giải thích những gì crate làm.

Tạo một dự án nhị phân mới có tên hello-async và thêm crate trpl làm phụ thuộc:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

Bây giờ chúng ta có thể sử dụng các phần khác nhau được cung cấp bởi trpl để viết chương trình async đầu tiên của chúng ta. Chúng ta sẽ xây dựng một công cụ dòng lệnh nhỏ lấy hai trang web, kéo phần tử <title> từ mỗi trang, và in ra tiêu đề của trang nào hoàn thành toàn bộ quá trình đó trước.

Định Nghĩa Hàm page_title

Hãy bắt đầu bằng cách viết một hàm nhận một URL trang làm tham số, gửi yêu cầu đến nó, và trả về văn bản của phần tử tiêu đề (xem Listing 17-1).

extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

Đầu tiên, chúng ta định nghĩa một hàm có tên page_title và đánh dấu nó bằng từ khóa async. Sau đó, chúng ta sử dụng hàm trpl::get để lấy bất kỳ URL nào được truyền vào và thêm từ khóa await để đợi phản hồi. Để lấy văn bản của phản hồi, chúng ta gọi phương thức text của nó, và một lần nữa đợi nó với từ khóa await. Cả hai bước này đều bất đồng bộ. Đối với hàm get, chúng ta phải đợi máy chủ gửi lại phần đầu tiên của phản hồi, bao gồm các tiêu đề HTTP, cookie, v.v., và có thể được gửi riêng biệt với phần thân phản hồi. Đặc biệt nếu phần thân rất lớn, có thể mất một thời gian để tất cả đến. Bởi vì chúng ta phải đợi toàn bộ phản hồi đến, phương thức text cũng là bất đồng bộ.

Chúng ta phải rõ ràng đợi cả hai future này, bởi vì futures trong Rust là lười biếng: chúng không làm gì cho đến khi bạn yêu cầu chúng làm việc với từ khóa await. (Thực tế, Rust sẽ hiển thị cảnh báo trình biên dịch nếu bạn không sử dụng một future.) Điều này có thể nhắc bạn về cuộc thảo luận về iterators trong Chương 13 ở phần Xử Lý Một Loạt Các Mục Với Iterators. Các iterator không làm gì trừ khi bạn gọi phương thức next của chúng — dù trực tiếp hay bằng cách sử dụng vòng lặp for hoặc các phương thức như map sử dụng next bên dưới. Tương tự, futures không làm gì trừ khi bạn rõ ràng yêu cầu chúng làm. Tính lười biếng này cho phép Rust tránh chạy mã async cho đến khi nó thực sự cần thiết.

Lưu ý: Điều này khác với hành vi chúng ta đã thấy trong chương trước khi sử dụng thread::spawn trong Tạo Một Thread Mới với spawn, nơi closure mà chúng ta chuyển cho một thread khác bắt đầu chạy ngay lập tức. Nó cũng khác với cách nhiều ngôn ngữ khác tiếp cận async. Nhưng nó quan trọng để Rust có thể cung cấp các đảm bảo hiệu suất của nó, giống như với iterators.

Khi chúng ta có response_text, chúng ta có thể phân tích nó thành một instance của kiểu Html bằng cách sử dụng Html::parse. Thay vì một chuỗi thô, bây giờ chúng ta có một kiểu dữ liệu có thể sử dụng để làm việc với HTML như một cấu trúc dữ liệu phong phú hơn. Cụ thể, chúng ta có thể sử dụng phương thức select_first để tìm instance đầu tiên của một bộ chọn CSS. Bằng cách truyền chuỗi "title", chúng ta sẽ nhận được phần tử <title> đầu tiên trong tài liệu, nếu có. Bởi vì có thể không có phần tử nào phù hợp, select_first trả về một Option<ElementRef>. Cuối cùng, chúng ta sử dụng phương thức Option::map, cho phép chúng ta làm việc với mục trong Option nếu nó hiện diện, và không làm gì nếu không. (Chúng ta cũng có thể sử dụng biểu thức match ở đây, nhưng map thường được sử dụng hơn.) Trong phần thân của hàm mà chúng ta cung cấp cho map, chúng ta gọi inner_html trên title_element để lấy nội dung của nó, đó là một String. Khi tất cả đã nói và làm, chúng ta có một Option<String>.

Lưu ý rằng từ khóa await của Rust đi sau biểu thức bạn đang đợi, không phải trước nó. Nghĩa là, nó là một từ khóa hậu tố. Điều này có thể khác với những gì bạn quen thuộc nếu bạn đã sử dụng async trong các ngôn ngữ khác, nhưng trong Rust nó làm cho chuỗi các phương thức dễ dàng làm việc hơn nhiều. Kết quả là, chúng ta có thể thay đổi phần thân của page_title để nối các lệnh gọi hàm trpl::gettext với nhau với await ở giữa, như được hiển thị trong Listing 17-2.

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

Với điều đó, chúng ta đã viết thành công hàm async đầu tiên của mình! Trước khi chúng ta thêm một số mã trong main để gọi nó, hãy nói thêm một chút về những gì chúng ta đã viết và ý nghĩa của nó.

Khi Rust thấy một khối được đánh dấu bằng từ khóa async, nó biên dịch nó thành một kiểu dữ liệu duy nhất, ẩn danh triển khai đặc tính Future. Khi Rust thấy một hàm được đánh dấu bằng async, nó biên dịch nó thành một hàm không bất đồng bộ có phần thân là một khối async. Kiểu trả về của một hàm async là kiểu của kiểu dữ liệu ẩn danh mà trình biên dịch tạo ra cho khối async đó.

Do đó, viết async fn tương đương với viết một hàm trả về một future của kiểu trả về. Đối với trình biên dịch, một định nghĩa hàm như async fn page_title trong Listing 17-1 tương đương với một hàm không bất đồng bộ được định nghĩa như sau:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

Hãy đi qua từng phần của phiên bản được chuyển đổi:

  • Nó sử dụng cú pháp impl Trait mà chúng ta đã thảo luận trở lại Chương 10 trong phần "Đặc Tính như Tham Số".
  • Đặc tính được trả về là một Future với một kiểu liên kết là Output. Lưu ý rằng kiểu OutputOption<String>, giống với kiểu trả về ban đầu từ phiên bản async fn của page_title.
  • Tất cả mã được gọi trong phần thân của hàm ban đầu được bọc trong một khối async move. Hãy nhớ rằng các khối là biểu thức. Toàn bộ khối này là biểu thức được trả về từ hàm.
  • Khối async này tạo ra một giá trị với kiểu Option<String>, như vừa mô tả. Giá trị đó khớp với kiểu Output trong kiểu trả về. Điều này giống như các khối khác bạn đã thấy.
  • Phần thân hàm mới là một khối async move vì cách nó sử dụng tham số url. (Chúng ta sẽ nói nhiều hơn về async so với async move sau trong chương.)

Bây giờ chúng ta có thể gọi page_title trong main.

Xác Định Tiêu Đề Một Trang Duy Nhất

Để bắt đầu, chúng ta sẽ chỉ lấy tiêu đề cho một trang duy nhất. Trong Listing 17-3, chúng ta theo cùng một mẫu mà chúng ta đã sử dụng trong Chương 12 để lấy đối số dòng lệnh trong phần Chấp Nhận Đối Số Dòng Lệnh. Sau đó, chúng ta truyền URL đầu tiên cho page_title và đợi kết quả. Bởi vì giá trị được tạo ra bởi future là một Option<String>, chúng ta sử dụng biểu thức match để in các thông báo khác nhau để tính đến việc liệu trang có <title> hay không.

extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

Thật không may, mã này không biên dịch. Nơi duy nhất chúng ta có thể sử dụng từ khóa await là trong các hàm hoặc khối async, và Rust sẽ không cho phép chúng ta đánh dấu hàm đặc biệt mainasync.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

Lý do main không thể được đánh dấu async là vì mã async cần một runtime: một crate Rust quản lý các chi tiết của việc thực thi mã bất đồng bộ. Hàm main của một chương trình có thể khởi tạo một runtime, nhưng nó không phải là một runtime bản thân nó. (Chúng ta sẽ thấy thêm về lý do tại sao trong một chút.) Mỗi chương trình Rust thực thi mã async có ít nhất một nơi mà nó thiết lập runtime và thực thi các futures.

Hầu hết các ngôn ngữ hỗ trợ async đều gói gọn một runtime, nhưng Rust thì không. Thay vào đó, có nhiều runtime async khác nhau có sẵn, mỗi cái thực hiện những đánh đổi khác nhau phù hợp với trường hợp sử dụng mà nó nhắm tới. Ví dụ, một máy chủ web có thông lượng cao với nhiều lõi CPU và một lượng lớn RAM có nhu cầu rất khác so với một vi điều khiển với một lõi duy nhất, một lượng nhỏ RAM, và không có khả năng cấp phát heap. Các crate cung cấp các runtime đó cũng thường cung cấp các phiên bản async của các chức năng phổ biến như I/O tệp hoặc mạng.

Ở đây, và trong suốt phần còn lại của chương này, chúng ta sẽ sử dụng hàm run từ crate trpl, lấy một future làm đối số và chạy nó đến khi hoàn thành. Đằng sau hậu trường, gọi run thiết lập một runtime được sử dụng để chạy future được truyền vào. Một khi future hoàn thành, run trả về bất cứ giá trị nào mà future đã tạo ra.

Chúng ta có thể truyền future được trả về bởi page_title trực tiếp cho run, và một khi nó hoàn thành, chúng ta có thể khớp trên Option<String> kết quả, như chúng ta đã cố gắng làm trong Listing 17-3. Tuy nhiên, đối với hầu hết các ví dụ trong chương (và hầu hết mã async trong thế giới thực), chúng ta sẽ làm nhiều hơn là chỉ một lệnh gọi hàm async, vì vậy thay vào đó chúng ta sẽ truyền một khối async và rõ ràng đợi kết quả của lệnh gọi page_title, như trong Listing 17-4.

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

Khi chúng ta chạy mã này, chúng ta nhận được hành vi mà chúng ta mong đợi ban đầu:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

Phù—cuối cùng chúng ta đã có một số mã async hoạt động! Nhưng trước khi chúng ta thêm mã để đua hai trang web với nhau, hãy nhanh chóng trở lại cách futures hoạt động.

Mỗi điểm đợi—nghĩa là, mỗi nơi mà mã sử dụng từ khóa await—đại diện cho một nơi mà quyền kiểm soát được trao lại cho runtime. Để làm cho điều đó hoạt động, Rust cần phải theo dõi trạng thái liên quan đến khối async để runtime có thể khởi động một số công việc khác và sau đó quay lại khi nó sẵn sàng để cố gắng tiếp tục cái đầu tiên. Đây là một máy trạng thái vô hình, như thể bạn đã viết một enum như thế này để lưu trạng thái hiện tại tại mỗi điểm đợi:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

Tuy nhiên, viết mã để chuyển đổi giữa mỗi trạng thái bằng tay sẽ là tẻ nhạt và dễ bị lỗi, đặc biệt là khi bạn cần thêm nhiều chức năng và nhiều trạng thái vào mã sau này. May mắn thay, trình biên dịch Rust tạo và quản lý các cấu trúc dữ liệu máy trạng thái cho mã async tự động. Các quy tắc bình thường về việc mượn và sở hữu xung quanh các cấu trúc dữ liệu vẫn áp dụng, và may mắn thay, trình biên dịch cũng xử lý việc kiểm tra những điều đó cho chúng ta và cung cấp các thông báo lỗi hữu ích. Chúng ta sẽ làm việc thông qua một vài điều đó sau trong chương.

Cuối cùng, một cái gì đó phải thực thi máy trạng thái này, và thứ đó là một runtime. (Đây là lý do tại sao bạn có thể gặp phải các tham chiếu đến executors khi tìm hiểu về runtimes: một executor là phần của runtime chịu trách nhiệm thực thi mã async.)

Bây giờ bạn có thể thấy tại sao trình biên dịch đã ngăn chúng ta làm cho main bản thân là một hàm async trở lại trong Listing 17-3. Nếu main là một hàm async, một cái gì đó khác sẽ cần quản lý máy trạng thái cho bất kỳ future nào main trả về, nhưng main là điểm bắt đầu của chương trình! Thay vào đó, chúng ta đã gọi hàm trpl::run trong main để thiết lập một runtime và chạy future được trả về bởi khối async cho đến khi nó hoàn thành.

Lưu ý: Một số runtime cung cấp các macro để bạn có thể viết một hàm main async. Những macro đó viết lại async fn main() { ... } để là một fn main bình thường, làm cùng một việc mà chúng ta đã làm bằng tay trong Listing 17-4: gọi một hàm chạy một future đến khi hoàn thành giống như trpl::run làm.

Bây giờ hãy kết hợp các phần này lại với nhau và xem cách chúng ta có thể viết mã đồng thời.

Đua Hai URL Của Chúng Ta Với Nhau

Trong Listing 17-5, chúng ta gọi page_title với hai URL khác nhau được truyền vào từ dòng lệnh và đua chúng.

extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::race(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title is: '{title}'"),
            None => println!("Its title could not be parsed."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let text = trpl::get(url).await.text().await;
    let title = Html::parse(&text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}

Chúng ta bắt đầu bằng cách gọi page_title cho mỗi URL do người dùng cung cấp. Chúng ta lưu các future kết quả là title_fut_1title_fut_2. Hãy nhớ rằng, chúng không làm gì cả, bởi vì futures là lười biếng và chúng ta chưa đợi chúng. Sau đó, chúng ta truyền các future cho trpl::race, trả về một giá trị để chỉ ra futures nào trong số các futures được truyền cho nó hoàn thành đầu tiên.

Lưu ý: Bên dưới, race được xây dựng trên một hàm tổng quát hơn, select, mà bạn sẽ gặp nhiều hơn trong mã Rust thực tế. Một hàm select có thể làm nhiều thứ mà hàm trpl::race không thể, nhưng nó cũng có một số phức tạp bổ sung mà chúng ta có thể bỏ qua bây giờ.

Một trong hai future có thể "thắng" một cách hợp pháp, vì vậy sẽ không hợp lý khi trả về một Result. Thay vào đó, race trả về một kiểu mà chúng ta chưa thấy trước đây, trpl::Either. Kiểu Either hơi giống với một Result ở chỗ nó có hai trường hợp. Không giống như Result, tuy nhiên, không có khái niệm thành công hay thất bại được nướng vào Either. Thay vào đó, nó sử dụng LeftRight để chỉ "một hoặc cái kia":

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

Hàm race trả về Left với đầu ra từ đối số future đầu tiên nếu nó kết thúc trước, hoặc Right với đầu ra của đối số future thứ hai nếu đó là cái kết thúc trước. Điều này phù hợp với thứ tự các đối số xuất hiện khi gọi hàm: đối số đầu tiên nằm ở bên trái của đối số thứ hai.

Chúng ta cũng cập nhật page_title để trả về cùng URL đã truyền vào. Bằng cách đó, nếu trang trả về đầu tiên không có <title> mà chúng ta có thể giải quyết, chúng ta vẫn có thể in một thông báo có ý nghĩa. Với thông tin đó có sẵn, chúng ta kết thúc bằng cách cập nhật đầu ra println! của chúng ta để chỉ ra cả URL nào kết thúc trước và <title> là gì, nếu có, cho trang web tại URL đó.

Bây giờ bạn đã xây dựng một trình thu thập web nhỏ hoạt động! Chọn một cặp URL và chạy công cụ dòng lệnh. Bạn có thể khám phá ra rằng một số trang web nhất quán nhanh hơn các trang khác, trong khi trong các trường hợp khác, trang nhanh hơn thay đổi từ lần chạy này sang lần chạy khác. Quan trọng hơn, bạn đã học được những điều cơ bản về làm việc với futures, vì vậy bây giờ chúng ta có thể đi sâu hơn vào những gì chúng ta có thể làm với async.

Áp dụng Đồng thời với Async

Trong phần này, chúng ta sẽ áp dụng async vào một số thách thức đồng thời giống với những gì chúng ta đã giải quyết bằng thread trong chương 16. Vì chúng ta đã thảo luận về nhiều ý tưởng chính ở đó, trong phần này chúng ta sẽ tập trung vào những điểm khác biệt giữa thread và future.

Trong nhiều trường hợp, các API để làm việc với đồng thời sử dụng async rất giống với những API dùng cho thread. Trong các trường hợp khác, chúng lại khá khác biệt. Ngay cả khi các API trông giống nhau giữa thread và async, chúng thường có hành vi khác nhau—và gần như luôn có các đặc điểm hiệu suất khác nhau.

Tạo Task Mới với spawn_task

Thao tác đầu tiên chúng ta đã giải quyết trong Tạo Thread Mới với Spawn là đếm trên hai thread riêng biệt. Hãy làm điều tương tự bằng async. Crate trpl cung cấp một hàm spawn_task trông rất giống với API thread::spawn, và một hàm sleep là phiên bản async của API thread::sleep. Chúng ta có thể sử dụng chúng cùng nhau để triển khai ví dụ đếm, như trong Listing 17-6.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}

Làm điểm bắt đầu, chúng ta thiết lập hàm main với trpl::run để hàm cấp cao nhất của chúng ta có thể là async.

Lưu ý: Từ điểm này trở đi trong chương, mọi ví dụ sẽ bao gồm mã bao bọc chính xác giống nhau với trpl::run trong main, vì vậy chúng ta thường bỏ qua nó giống như chúng ta làm với main. Đừng quên bao gồm nó trong mã của bạn!

Sau đó, chúng ta viết hai vòng lặp trong khối đó, mỗi vòng lặp chứa một lệnh gọi trpl::sleep, đợi nửa giây (500 mili giây) trước khi gửi tin nhắn tiếp theo. Chúng ta đặt một vòng lặp trong phần thân của trpl::spawn_task và vòng lặp khác trong vòng lặp for cấp cao nhất. Chúng ta cũng thêm await sau các lệnh gọi sleep.

Mã này hoạt động tương tự như triển khai dựa trên thread—bao gồm cả thực tế là bạn có thể thấy các tin nhắn xuất hiện theo thứ tự khác nhau trong terminal của bạn khi bạn chạy nó:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

Phiên bản này dừng ngay khi vòng lặp for trong phần thân của khối async chính kết thúc, vì task được tạo bởi spawn_task bị tắt khi hàm main kết thúc. Nếu bạn muốn nó chạy hết đến khi task hoàn thành, bạn sẽ cần sử dụng một join handle để đợi task đầu tiên hoàn thành. Với thread, chúng ta đã sử dụng phương thức join để "chặn" cho đến khi thread hoàn tất chạy. Trong Listing 17-7, chúng ta có thể sử dụng await để làm điều tương tự, bởi vì task handle chính nó là một future. Kiểu Output của nó là một Result, vì vậy chúng ta cũng unwrap nó sau khi await nó.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}

Phiên bản cập nhật này chạy cho đến khi cả hai vòng lặp kết thúc.

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

Cho đến nay, có vẻ như async và thread cho chúng ta cùng một kết quả cơ bản, chỉ với cú pháp khác nhau: sử dụng await thay vì gọi join trên join handle, và await các lệnh gọi sleep.

Sự khác biệt lớn hơn là chúng ta không cần phải tạo một thread hệ điều hành khác để làm điều này. Thực tế, chúng ta thậm chí không cần phải tạo một task ở đây. Bởi vì các khối async được biên dịch thành các future ẩn danh, chúng ta có thể đặt mỗi vòng lặp trong một khối async và có runtime chạy cả hai đến khi hoàn thành bằng cách sử dụng hàm trpl::join.

Trong phần Chờ Tất cả Các Thread Hoàn thành Bằng Cách Sử dụng join Handles, chúng ta đã thấy cách sử dụng phương thức join trên kiểu JoinHandle được trả về khi bạn gọi std::thread::spawn. Hàm trpl::join tương tự, nhưng dành cho future. Khi bạn đưa cho nó hai future, nó tạo ra một future mới duy nhất mà output của nó là một tuple chứa output của mỗi future bạn đã truyền vào khi cả hai đều hoàn thành. Vì vậy, trong Listing 17-8, chúng ta sử dụng trpl::join để đợi cả fut1fut2 hoàn thành. Chúng ta không await fut1fut2 mà thay vào đó là future mới được tạo ra bởi trpl::join. Chúng ta bỏ qua output, vì nó chỉ là một tuple chứa hai giá trị đơn vị.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}

Khi chúng ta chạy điều này, chúng ta thấy cả hai future chạy đến khi hoàn thành:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

Bây giờ, bạn sẽ thấy thứ tự chính xác giống nhau mỗi lần, điều này rất khác với những gì chúng ta đã thấy với thread. Đó là bởi vì hàm trpl::joincông bằng, nghĩa là nó kiểm tra mỗi future thường xuyên như nhau, luân phiên giữa chúng, và không bao giờ để một future chạy trước nếu future khác đã sẵn sàng. Với thread, hệ điều hành quyết định thread nào được kiểm tra và cho phép nó chạy bao lâu. Với async Rust, runtime quyết định task nào cần kiểm tra. (Trong thực tế, chi tiết trở nên phức tạp vì một runtime async có thể sử dụng thread hệ điều hành bên dưới như một phần của cách nó quản lý đồng thời, vì vậy việc đảm bảo tính công bằng có thể là nhiều việc hơn cho một runtime—nhưng nó vẫn có thể thực hiện được!) Các runtime không phải đảm bảo sự công bằng cho bất kỳ thao tác nào, và chúng thường cung cấp các API khác nhau để cho phép bạn chọn liệu bạn có muốn sự công bằng hay không.

Hãy thử một số biến thể này khi await các future và xem chúng làm gì:

  • Xóa bỏ khối async xung quanh một trong hai hoặc cả hai vòng lặp.
  • Await mỗi khối async ngay sau khi định nghĩa nó.
  • Chỉ bọc vòng lặp đầu tiên trong một khối async, và await future kết quả sau phần thân của vòng lặp thứ hai.

Để thử thách hơn, hãy xem liệu bạn có thể tìm ra output sẽ như thế nào trong mỗi trường hợp trước khi chạy mã!

Đếm trên Hai Task Sử dụng Truyền Thông Điệp

Chia sẻ dữ liệu giữa các future cũng sẽ quen thuộc: chúng ta sẽ sử dụng truyền thông điệp một lần nữa, nhưng lần này với các phiên bản async của các kiểu và hàm. Chúng ta sẽ đi theo một con đường hơi khác với những gì chúng ta đã làm trong Sử dụng Truyền Thông Điệp để Chuyển Dữ liệu Giữa Các Thread để minh họa một số điểm khác biệt chính giữa đồng thời dựa trên thread và dựa trên future. Trong Listing 17-9, chúng ta sẽ bắt đầu với chỉ một khối async duy nhất—không tạo một task riêng biệt như chúng ta đã tạo một thread riêng biệt.

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("Got: {received}");
    });
}

Ở đây, chúng ta sử dụng trpl::channel, một phiên bản async của API kênh đa-nhà sản xuất, đơn-người tiêu dùng mà chúng ta đã sử dụng với thread trong Chương 16. Phiên bản async của API chỉ hơi khác so với phiên bản dựa trên thread: nó sử dụng một receiver rx có thể thay đổi thay vì bất biến, và phương thức recv của nó tạo ra một future mà chúng ta cần await thay vì trực tiếp tạo ra giá trị. Bây giờ chúng ta có thể gửi tin nhắn từ sender đến receiver. Lưu ý rằng chúng ta không phải tạo một thread riêng biệt hoặc thậm chí một task; chúng ta chỉ cần await lệnh gọi rx.recv.

Phương thức đồng bộ Receiver::recv trong std::mpsc::channel chặn cho đến khi nó nhận được tin nhắn. Phương thức trpl::Receiver::recv không làm vậy, vì nó là async. Thay vì chặn, nó giao quyền kiểm soát lại cho runtime cho đến khi hoặc một tin nhắn được nhận hoặc phía gửi của kênh đóng lại. Ngược lại, chúng ta không await lệnh gọi send, vì nó không chặn. Nó không cần phải làm vậy, vì kênh mà chúng ta đang gửi vào là không giới hạn.

Lưu ý: Vì tất cả mã async này chạy trong một khối async trong một lệnh gọi trpl::run, mọi thứ trong đó có thể tránh việc chặn. Tuy nhiên, mã bên ngoài nó sẽ chặn khi hàm run trả về. Đó là toàn bộ mục đích của hàm trpl::run: nó cho phép bạn chọn nơi để chặn đối với một số mã async, và do đó là nơi để chuyển đổi giữa mã đồng bộ và async. Trong hầu hết các runtime async, run thực sự được đặt tên là block_on chính vì lý do này.

Lưu ý hai điều về ví dụ này. Đầu tiên, tin nhắn sẽ đến ngay lập tức. Thứ hai, mặc dù chúng ta sử dụng một future ở đây, nhưng chưa có sự đồng thời. Mọi thứ trong listing xảy ra tuần tự, giống như khi không có future nào tham gia.

Hãy giải quyết phần đầu tiên bằng cách gửi một loạt tin nhắn và ngủ giữa chúng, như trong Listing 17-10.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}

Ngoài việc gửi tin nhắn, chúng ta cần phải nhận chúng. Trong trường hợp này, vì chúng ta biết có bao nhiêu tin nhắn đến, chúng ta có thể làm điều đó thủ công bằng cách gọi rx.recv().await bốn lần. Tuy nhiên, trong thế giới thực, chúng ta thường đang đợi một số lượng tin nhắn không xác định, vì vậy chúng ta cần tiếp tục đợi cho đến khi chúng ta xác định rằng không còn tin nhắn nào nữa.

Trong Listing 16-10, chúng ta đã sử dụng một vòng lặp for để xử lý tất cả các mục nhận được từ một kênh đồng bộ. Tuy nhiên, Rust chưa có cách để viết một vòng lặp for trên một loạt các mục bất đồng bộ, vì vậy chúng ta cần sử dụng một vòng lặp mà chúng ta chưa thấy trước đây: vòng lặp điều kiện while let. Đây là phiên bản vòng lặp của cấu trúc if let mà chúng ta đã thấy trong phần Kiểm soát Luồng Ngắn gọn với if letlet else. Vòng lặp sẽ tiếp tục thực thi miễn là mẫu mà nó chỉ định tiếp tục khớp với giá trị.

Lệnh gọi rx.recv tạo ra một future, mà chúng ta await. Runtime sẽ tạm dừng future cho đến khi nó sẵn sàng. Khi một tin nhắn đến, future sẽ giải quyết thành Some(message) nhiều lần khi tin nhắn đến. Khi kênh đóng lại, bất kể bất kỳ tin nhắn nào đã đến, future sẽ thay vào đó giải quyết thành None để chỉ ra rằng không còn giá trị nào nữa và do đó chúng ta nên dừng polling—nghĩa là, dừng awaiting.

Vòng lặp while let kết hợp tất cả những điều này. Nếu kết quả của việc gọi rx.recv().awaitSome(message), chúng ta có quyền truy cập vào tin nhắn và chúng ta có thể sử dụng nó trong phần thân vòng lặp, giống như chúng ta có thể làm với if let. Nếu kết quả là None, vòng lặp kết thúc. Mỗi khi vòng lặp hoàn thành, nó chạm vào điểm await một lần nữa, vì vậy runtime tạm dừng nó một lần nữa cho đến khi một tin nhắn khác đến.

Mã bây giờ thành công gửi và nhận tất cả các tin nhắn. Đáng tiếc, vẫn còn một vài vấn đề. Một là, các tin nhắn không đến ở các khoảng thời gian nửa giây. Chúng đến cùng một lúc, 2 giây (2.000 mili giây) sau khi chúng ta bắt đầu chương trình. Vấn đề khác là chương trình này không bao giờ thoát! Thay vào đó, nó đợi mãi mãi cho các tin nhắn mới. Bạn sẽ cần phải tắt nó bằng ctrl-c.

Hãy bắt đầu bằng cách kiểm tra lý do tại sao các tin nhắn đến cùng một lúc sau khi trì hoãn đầy đủ, thay vì đến với độ trễ giữa mỗi tin. Trong một khối async nhất định, thứ tự mà các từ khóa await xuất hiện trong mã cũng là thứ tự mà chúng được thực thi khi chương trình chạy.

Chỉ có một khối async trong Listing 17-10, vì vậy mọi thứ trong đó chạy tuyến tính. Vẫn chưa có sự đồng thời. Tất cả các lệnh gọi tx.send xảy ra, xen kẽ với tất cả các lệnh gọi trpl::sleep và các điểm await liên quan. Chỉ sau đó vòng lặp while let mới đi qua bất kỳ điểm await nào trong các lệnh gọi recv.

Để có được hành vi mà chúng ta muốn, nơi độ trễ sleep xảy ra giữa mỗi tin nhắn, chúng ta cần đặt các thao tác txrx trong các khối async riêng của chúng, như trong Listing 17-11. Sau đó, runtime có thể thực thi từng khối riêng biệt bằng cách sử dụng trpl::join, giống như trong ví dụ đếm. Một lần nữa, chúng ta await kết quả của việc gọi trpl::join, không phải các future riêng lẻ. Nếu chúng ta await các future riêng lẻ một cách tuần tự, chúng ta sẽ chỉ quay lại một luồng tuần tự—chính xác những gì chúng ta đang cố gắng không làm.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}

Với mã đã cập nhật trong Listing 17-11, các tin nhắn được in ở khoảng thời gian 500-mili giây, thay vì tất cả cùng một lúc sau 2 giây.

Tuy nhiên, chương trình vẫn không bao giờ thoát, vì cách mà vòng lặp while let tương tác với trpl::join:

  • Future được trả về từ trpl::join chỉ hoàn thành khi cả hai future được truyền vào nó đã hoàn thành.
  • Future tx hoàn thành khi nó hoàn tất việc ngủ sau khi gửi tin nhắn cuối cùng trong vals.
  • Future rx sẽ không hoàn thành cho đến khi vòng lặp while let kết thúc.
  • Vòng lặp while let sẽ không kết thúc cho đến khi await rx.recv tạo ra None.
  • Await rx.recv sẽ chỉ trả về None khi đầu kia của kênh được đóng lại.
  • Kênh sẽ chỉ đóng khi chúng ta gọi rx.close hoặc khi phía gửi, tx, bị drop.
  • Chúng ta không gọi rx.close ở bất kỳ đâu, và tx sẽ không bị drop cho đến khi khối async ngoài cùng được truyền vào trpl::run kết thúc.
  • Khối không thể kết thúc vì nó bị chặn trên trpl::join đang hoàn thành, điều này đưa chúng ta trở lại đầu danh sách này.

Chúng ta có thể đóng rx thủ công bằng cách gọi rx.close ở đâu đó, nhưng điều đó không có nhiều ý nghĩa. Việc dừng lại sau khi xử lý một số lượng tin nhắn tùy ý sẽ làm cho chương trình tắt, nhưng chúng ta có thể bỏ lỡ các tin nhắn. Chúng ta cần một cách khác để đảm bảo rằng tx bị drop trước khi kết thúc hàm.

Hiện tại, khối async nơi chúng ta gửi tin nhắn chỉ mượn tx vì gửi tin nhắn không yêu cầu quyền sở hữu, nhưng nếu chúng ta có thể di chuyển tx vào khối async đó, nó sẽ bị drop khi khối đó kết thúc. Trong phần Chương 13 Nắm bắt Tham chiếu hoặc Di chuyển Quyền sở hữu, bạn đã học cách sử dụng từ khóa move với closure, và, như đã thảo luận trong phần Chương 16 Sử dụng Closure move với Thread, chúng ta thường cần di chuyển dữ liệu vào closure khi làm việc với thread. Cùng động lực cơ bản áp dụng cho các khối async, vì vậy từ khóa move hoạt động với các khối async giống như với closure.

Trong Listing 17-12, chúng ta thay đổi khối được sử dụng để gửi tin nhắn từ async thành async move. Khi chúng ta chạy phiên bản mã này, nó tắt một cách thanh lịch sau khi tin nhắn cuối cùng được gửi và nhận.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}

Kênh async này cũng là một kênh đa-nhà sản xuất, vì vậy chúng ta có thể gọi clone trên tx nếu chúng ta muốn gửi tin nhắn từ nhiều future, như trong Listing 17-13.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join3(tx1_fut, tx_fut, rx_fut).await;
    });
}

Đầu tiên, chúng ta sao chép tx, tạo ra tx1 bên ngoài khối async đầu tiên. Chúng ta di chuyển tx1 vào khối đó giống như chúng ta đã làm trước đây với tx. Sau đó, sau này, chúng ta di chuyển tx ban đầu vào một khối async mới, nơi chúng ta gửi thêm tin nhắn với độ trễ hơi chậm hơn. Chúng ta tình cờ đặt khối async mới này sau khối async để nhận tin nhắn, nhưng nó cũng có thể đi trước nó. Chìa khóa là thứ tự mà các future được await, không phải thứ tự chúng được tạo ra.

Cả hai khối async để gửi tin nhắn cần phải là các khối async move để cả txtx1 đều bị drop khi các khối đó kết thúc. Nếu không, chúng ta sẽ quay trở lại vòng lặp vô hạn mà chúng ta đã bắt đầu. Cuối cùng, chúng ta chuyển từ trpl::join sang trpl::join3 để xử lý future bổ sung.

Bây giờ chúng ta thấy tất cả các tin nhắn từ cả hai future gửi, và vì các future gửi sử dụng các độ trễ hơi khác nhau sau khi gửi, các tin nhắn cũng được nhận ở những khoảng thời gian khác nhau đó.

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

Đây là một khởi đầu tốt, nhưng nó giới hạn chúng ta ở chỉ một vài future: hai với join, hoặc ba với join3. Hãy xem chúng ta có thể làm việc với nhiều future hơn như thế nào.

Làm việc với Bất kỳ Số lượng Future nào

Khi chúng ta chuyển từ việc sử dụng hai future sang ba future trong phần trước, chúng ta cũng phải chuyển từ việc sử dụng join sang sử dụng join3. Sẽ rất khó chịu nếu phải gọi một hàm khác mỗi khi chúng ta thay đổi số lượng future muốn kết hợp. May mắn thay, chúng ta có dạng macro của join mà chúng ta có thể truyền vào một số lượng tham số tùy ý. Nó cũng tự xử lý việc await các future. Vì vậy, chúng ta có thể viết lại mã từ Listing 17-13 để sử dụng join! thay vì join3, như trong Listing 17-14.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}

Đây chắc chắn là một cải tiến so với việc chuyển đổi giữa joinjoin3join4 và v.v.! Tuy nhiên, ngay cả dạng macro này cũng chỉ hoạt động khi chúng ta biết số lượng future trước. Trong Rust thực tế, việc đưa các future vào một tập hợp và sau đó đợi một số hoặc tất cả các future hoàn thành là một mẫu phổ biến.

Để kiểm tra tất cả các future trong một tập hợp, chúng ta cần lặp qua và kết hợp tất cả chúng. Hàm trpl::join_all chấp nhận bất kỳ kiểu nào thực thi trait Iterator, mà bạn đã học trong The Iterator Trait and the next Method Chương 13, vì vậy nó có vẻ như đúng là thứ chúng ta cần. Hãy thử đặt các future của chúng ta vào một vector và thay thế join! bằng join_all như trong Listing 17-15.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures = vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}

Thật không may, mã này không biên dịch được. Thay vào đó, chúng ta nhận được lỗi này:

error[E0308]: mismatched types
  --> src/main.rs:45:37
   |
10 |         let tx1_fut = async move {
   |                       ---------- the expected `async` block
...
24 |         let rx_fut = async {
   |                      ----- the found `async` block
...
45 |         let futures = vec![tx1_fut, rx_fut, tx_fut];
   |                                     ^^^^^^ expected `async` block, found a different `async` block
   |
   = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
              found `async` block `{async block@src/main.rs:24:22: 24:27}`
   = note: no two async blocks, even if identical, have the same type
   = help: consider pinning your async block and casting it to a trait object

Điều này có thể gây ngạc nhiên. Rốt cuộc, không có async block nào trả về bất cứ thứ gì, nên mỗi block tạo ra một Future<Output = ()>. Hãy nhớ rằng Future là một trait, tuy nhiên, và trình biên dịch tạo ra một enum duy nhất cho mỗi async block. Bạn không thể đặt hai struct viết tay khác nhau trong một Vec, và quy tắc tương tự áp dụng cho các enum khác nhau được tạo ra bởi trình biên dịch.

Để làm cho điều này hoạt động, chúng ta cần sử dụng trait objects, giống như chúng ta đã làm trong "Returning Errors from the run function" ở Chương 12. (Chúng ta sẽ đề cập chi tiết về các trait object trong Chương 18.) Sử dụng trait object cho phép chúng ta xem xét mỗi future ẩn danh được tạo ra bởi các kiểu này là cùng một kiểu, bởi vì tất cả chúng đều thực thi trait Future.

Lưu ý: Trong Using an Enum to Store Multiple Values ở Chương 8, chúng ta đã thảo luận về một cách khác để bao gồm nhiều kiểu trong một Vec: sử dụng một enum để đại diện cho mỗi kiểu có thể xuất hiện trong vector. Chúng ta không thể làm điều đó ở đây. Một mặt, chúng ta không có cách để đặt tên cho các kiểu khác nhau, bởi vì chúng là ẩn danh. Mặt khác, lý do chúng ta chọn vector và join_all ngay từ đầu là để có thể làm việc với một tập hợp động của các future mà chúng ta chỉ quan tâm rằng chúng có cùng kiểu đầu ra.

Chúng ta bắt đầu bằng cách bọc mỗi future trong vec! trong một Box::new, như trong Listing 17-16.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

Thật không may, mã này vẫn không biên dịch. Thực tế, chúng ta nhận được cùng lỗi cơ bản mà chúng ta đã nhận trước đó cho cả hai lệnh gọi Box::new thứ hai và thứ ba, cũng như các lỗi mới đề cập đến trait Unpin. Chúng ta sẽ quay lại các lỗi Unpin trong giây lát. Trước tiên, hãy sửa các lỗi kiểu trên các lệnh gọi Box::new bằng cách chú thích rõ ràng kiểu của biến futures (xem Listing 17-17).

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

Khai báo kiểu này hơi phức tạp, vì vậy hãy cùng đi qua nó:

  1. Kiểu bên trong cùng là future. Chúng ta lưu ý rõ ràng rằng đầu ra của future là kiểu đơn vị () bằng cách viết Future<Output = ()>.
  2. Sau đó, chúng ta chú thích trait với dyn để đánh dấu nó là động.
  3. Toàn bộ tham chiếu trait được bọc trong một Box.
  4. Cuối cùng, chúng ta nêu rõ rằng futures là một Vec chứa những mục này.

Điều đó đã tạo ra một sự khác biệt lớn. Bây giờ khi chúng ta chạy trình biên dịch, chúng ta chỉ nhận được các lỗi đề cập đến Unpin. Mặc dù có ba lỗi, nhưng nội dung của chúng rất giống nhau.

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
   --> src/main.rs:49:24
    |
49  |         trpl::join_all(futures).await;
    |         -------------- ^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |         |
    |         required by a bound introduced by this call
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `join_all`
   --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:105:14
    |
102 | pub fn join_all<I>(iter: I) -> JoinAll<I::Item>
    |        -------- required by a bound in this function
...
105 |     I::Item: Future,
    |              ^^^^^^ required by this bound in `join_all`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:9
   |
49 |         trpl::join_all(futures).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:33
   |
49 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_await` (bin "async_await") due to 3 previous errors

Đó là rất nhiều thông tin để tiêu hóa, vì vậy hãy phân tích nó. Phần đầu tiên của thông báo cho chúng ta biết rằng async block đầu tiên (src/main.rs:8:23: 20:10) không thực thi trait Unpin và đề xuất sử dụng pin! hoặc Box::pin để giải quyết vấn đề. Về sau trong chương, chúng ta sẽ đi sâu vào một vài chi tiết khác về PinUnpin. Tuy nhiên, hiện tại, chúng ta có thể làm theo lời khuyên của trình biên dịch để giải quyết vấn đề. Trong Listing 17-18, chúng ta bắt đầu bằng cách import Pin từ std::pin. Tiếp theo, chúng ta cập nhật chú thích kiểu cho futures, với một Pin bao bọc mỗi Box. Cuối cùng, chúng ta sử dụng Box::pin để cố định các future.

extern crate trpl; // required for mdbook test

use std::pin::Pin;

// -- snip --

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Pin<Box<dyn Future<Output = ()>>>> =
            vec![Box::pin(tx1_fut), Box::pin(rx_fut), Box::pin(tx_fut)];

        trpl::join_all(futures).await;
    });
}

Nếu chúng ta biên dịch và chạy mã này, cuối cùng chúng ta sẽ nhận được kết quả mong muốn:

received 'hi'
received 'more'
received 'from'
received 'messages'
received 'the'
received 'for'
received 'future'
received 'you'

Xong rồi!

Có một chút điều cần khám phá thêm ở đây. Một mặt, việc sử dụng Pin<Box<T>> thêm một lượng nhỏ chi phí từ việc đưa các future này lên heap với Box—và chúng ta chỉ làm điều đó để làm cho các kiểu phù hợp. Chúng ta thực sự không cần cấp phát trên heap, sau tất cả: các future này là cục bộ cho hàm cụ thể này. Như đã lưu ý trước đó, Pin tự nó là một kiểu bao bọc, vì vậy chúng ta có thể nhận được lợi ích của việc có một kiểu duy nhất trong Vec—lý do ban đầu chúng ta chọn Box—mà không cần cấp phát heap. Chúng ta có thể sử dụng Pin trực tiếp với mỗi future, sử dụng macro std::pin::pin.

Tuy nhiên, chúng ta vẫn phải rõ ràng về kiểu của tham chiếu được ghim; nếu không, Rust vẫn không biết cách diễn giải chúng như các đối tượng trait động, đó là những gì chúng ta cần chúng trở thành trong Vec. Do đó, chúng ta thêm pin vào danh sách import của chúng ta từ std::pin. Sau đó, chúng ta có thể pin! mỗi future khi chúng ta định nghĩa nó và định nghĩa futures là một Vec chứa các tham chiếu có thể thay đổi được ghim đến kiểu future động, như trong Listing 17-19.

extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// -- snip --

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}

Chúng ta đã đi đến đây bằng cách bỏ qua thực tế rằng chúng ta có thể có các kiểu Output khác nhau. Ví dụ, trong Listing 17-20, future ẩn danh cho a thực thi Future<Output = u32>, future ẩn danh cho b thực thi Future<Output = &str>, và future ẩn danh cho c thực thi Future<Output = bool>.

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let a = async { 1u32 };
        let b = async { "Hello!" };
        let c = async { true };

        let (a_result, b_result, c_result) = trpl::join!(a, b, c);
        println!("{a_result}, {b_result}, {c_result}");
    });
}

Chúng ta có thể sử dụng trpl::join! để đợi chúng, bởi vì nó cho phép chúng ta truyền vào nhiều kiểu future và tạo ra một tuple của các kiểu đó. Chúng ta không thể sử dụng trpl::join_all, bởi vì nó yêu cầu tất cả các future được truyền vào phải có cùng kiểu. Hãy nhớ rằng, lỗi đó là những gì khiến chúng ta bắt đầu cuộc phiêu lưu này với Pin!

Đây là một sự đánh đổi cơ bản: chúng ta có thể xử lý một số lượng động của các future với join_all, miễn là tất cả chúng đều có cùng kiểu, hoặc chúng ta có thể xử lý một tập hợp cố định số lượng future với các hàm join hoặc macro join!, ngay cả khi chúng có các kiểu khác nhau. Đây là cùng một kịch bản chúng ta gặp phải khi làm việc với bất kỳ kiểu nào khác trong Rust. Future không có gì đặc biệt, mặc dù chúng ta có một số cú pháp tốt để làm việc với chúng, và đó là một điều tốt.

Đua Các Future

Khi chúng ta "join" các future với họ hàm join và các macro, chúng ta yêu cầu tất cả chúng phải hoàn thành trước khi chúng ta tiếp tục. Đôi khi, tuy nhiên, chúng ta chỉ cần một số future từ một tập hợp hoàn thành trước khi chúng ta tiếp tục—hơi giống với việc đua một future với một future khác.

Trong Listing 17-21, chúng ta một lần nữa sử dụng trpl::race để chạy hai future, slowfast, đua nhau.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            println!("'slow' started.");
            trpl::sleep(Duration::from_millis(100)).await;
            println!("'slow' finished.");
        };

        let fast = async {
            println!("'fast' started.");
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'fast' finished.");
        };

        trpl::race(slow, fast).await;
    });
}

Mỗi future in một thông báo khi nó bắt đầu chạy, tạm dừng một khoảng thời gian bằng cách gọi và đợi sleep, và sau đó in một thông báo khác khi nó hoàn thành. Sau đó, chúng ta truyền cả slowfast cho trpl::race và đợi một trong số chúng hoàn thành. (Kết quả ở đây không quá đáng ngạc nhiên: fast thắng.) Không giống như khi chúng ta sử dụng race trở lại trong "Our First Async Program", chúng ta chỉ bỏ qua thể hiện Either mà nó trả về ở đây, bởi vì tất cả các hành vi thú vị xảy ra trong phần thân của các async block.

Chú ý rằng nếu bạn đảo ngược thứ tự các đối số cho race, thứ tự của các thông báo "started" sẽ thay đổi, mặc dù future fast luôn hoàn thành trước. Đó là vì việc thực thi của hàm race cụ thể này là không công bằng. Nó luôn chạy các future được truyền vào làm đối số theo thứ tự chúng được truyền vào. Các cách thực thi khác công bằng và sẽ chọn ngẫu nhiên future nào được poll đầu tiên. Bất kể việc thực thi race mà chúng ta đang sử dụng có công bằng hay không, một trong các future sẽ chạy đến điểm await đầu tiên trong phần thân của nó trước khi một tác vụ khác có thể bắt đầu.

Nhớ lại từ Our First Async Program rằng tại mỗi điểm await, Rust cho phép runtime có cơ hội tạm dừng tác vụ và chuyển sang một tác vụ khác nếu future đang được await chưa sẵn sàng. Điều ngược lại cũng đúng: Rust chỉ tạm dừng các async block và trả quyền kiểm soát lại cho runtime tại một điểm await. Mọi thứ giữa các điểm await là đồng bộ.

Điều đó có nghĩa là nếu bạn thực hiện một loạt công việc trong một async block mà không có điểm await, future đó sẽ chặn bất kỳ future nào khác thực hiện tiến trình. Đôi khi bạn có thể nghe điều này được gọi là một future đói các future khác. Trong một số trường hợp, điều đó có thể không phải là vấn đề lớn. Tuy nhiên, nếu bạn đang thực hiện một số loại thiết lập đắt tiền hoặc công việc chạy dài, hoặc nếu bạn có một future sẽ tiếp tục thực hiện một số tác vụ cụ thể vô thời hạn, bạn sẽ cần suy nghĩ về khi nào và ở đâu để bàn giao quyền kiểm soát cho runtime.

Cũng vậy, nếu bạn có các hoạt động chặn chạy dài, async có thể là một công cụ hữu ích để cung cấp các cách cho các phần khác nhau của chương trình liên quan với nhau.

Nhưng làm thế nào bạn sẽ bàn giao quyền kiểm soát cho runtime trong những trường hợp đó?

Nhường Quyền Kiểm Soát cho Runtime

Hãy mô phỏng một hoạt động chạy dài. Listing 17-22 giới thiệu một hàm slow.

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

Mã này sử dụng std::thread::sleep thay vì trpl::sleep để khi gọi slow sẽ chặn thread hiện tại trong một số mili giây. Chúng ta có thể sử dụng slow để đại diện cho các hoạt động trong thế giới thực vừa chạy lâu vừa chặn.

Trong Listing 17-23, chúng ta sử dụng slow để mô phỏng việc thực hiện loại công việc gắn với CPU này trong một cặp future.

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

Để bắt đầu, mỗi future chỉ trả lại quyền kiểm soát cho runtime sau khi thực hiện một loạt các hoạt động chậm. Nếu bạn chạy mã này, bạn sẽ thấy kết quả này:

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

Giống như ví dụ trước đó của chúng ta, race vẫn kết thúc ngay khi a hoàn thành. Không có sự xen kẽ giữa hai future, mặc dù. Future a thực hiện tất cả công việc của nó cho đến khi lệnh gọi trpl::sleep được await, sau đó future b thực hiện tất cả công việc của nó cho đến khi lệnh gọi trpl::sleep của riêng nó được await, và cuối cùng future a hoàn thành. Để cho phép cả hai future thực hiện tiến trình giữa các tác vụ chậm của chúng, chúng ta cần các điểm await để chúng ta có thể bàn giao quyền kiểm soát cho runtime. Điều đó có nghĩa là chúng ta cần thứ gì đó mà chúng ta có thể await!

Chúng ta đã có thể thấy loại bàn giao này xảy ra trong Listing 17-23: nếu chúng ta loại bỏ trpl::sleep ở cuối future a, nó sẽ hoàn thành mà không có future b chạy chút nào. Hãy thử sử dụng hàm sleep làm điểm khởi đầu để cho phép các hoạt động luân phiên thực hiện tiến trình, như được hiển thị trong Listing 17-24.

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

Trong Listing 17-24, chúng ta thêm các lệnh gọi trpl::sleep với các điểm await giữa mỗi lệnh gọi đến slow. Bây giờ công việc của hai future được xen kẽ:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

Future a vẫn chạy một chút trước khi bàn giao quyền kiểm soát cho b, bởi vì nó gọi slow trước khi từng gọi trpl::sleep, nhưng sau đó các future hoán đổi qua lại mỗi khi một trong số chúng đạt đến một điểm await. Trong trường hợp này, chúng ta đã làm điều đó sau mỗi lệnh gọi đến slow, nhưng chúng ta có thể chia nhỏ công việc theo bất kỳ cách nào hợp lý nhất đối với chúng ta.

Tuy nhiên, chúng ta không thực sự muốn ngủ ở đây: chúng ta muốn thực hiện tiến trình nhanh nhất có thể. Chúng ta chỉ cần trả lại quyền kiểm soát cho runtime. Chúng ta có thể làm điều đó trực tiếp, sử dụng hàm yield_now. Trong Listing 17-25, chúng ta thay thế tất cả những lệnh gọi sleep bằng yield_now.

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

Mã này vừa rõ ràng hơn về ý định thực tế vừa có thể nhanh hơn đáng kể so với việc sử dụng sleep, bởi vì các bộ đếm thời gian như bộ được sử dụng bởi sleep thường có giới hạn về mức độ chi tiết mà chúng có thể có. Phiên bản sleep mà chúng ta đang sử dụng, ví dụ, sẽ luôn ngủ ít nhất một mili giây, ngay cả khi chúng ta truyền cho nó một Duration một nano giây. Một lần nữa, máy tính hiện đại nhanh: chúng có thể làm rất nhiều trong một mili giây!

Bạn có thể tự mình thấy điều này bằng cách thiết lập một benchmark nhỏ, chẳng hạn như bench trong Listing 17-26. (Đây không phải là một cách đặc biệt nghiêm ngặt để thực hiện kiểm tra hiệu suất, nhưng nó đủ để hiển thị sự khác biệt ở đây.)

extern crate trpl; // required for mdbook test

use std::time::{Duration, Instant};

fn main() {
    trpl::run(async {
        let one_ns = Duration::from_nanos(1);
        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::sleep(one_ns).await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'sleep' version finished after {} seconds.",
            time.as_secs_f32()
        );

        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::yield_now().await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'yield' version finished after {} seconds.",
            time.as_secs_f32()
        );
    });
}

Ở đây, chúng ta bỏ qua tất cả việc in trạng thái, truyền một Duration một nano giây cho trpl::sleep, và để mỗi future tự chạy, không có sự chuyển đổi giữa các future. Sau đó, chúng ta chạy 1.000 lần lặp lại và xem future sử dụng trpl::sleep mất bao lâu so với future sử dụng trpl::yield_now.

Phiên bản với yield_now nhanh hơn nhiều!

Điều này có nghĩa là async có thể hữu ích ngay cả cho các tác vụ gắn với tính toán, tùy thuộc vào những gì chương trình của bạn đang làm, bởi vì nó cung cấp một công cụ hữu ích để cấu trúc các mối quan hệ giữa các phần khác nhau của chương trình. Đây là một hình thức đa nhiệm hợp tác, trong đó mỗi future có quyền xác định khi nào nó bàn giao quyền kiểm soát thông qua các điểm await. Mỗi future do đó cũng có trách nhiệm tránh chặn quá lâu. Trong một số hệ điều hành nhúng dựa trên Rust, đây là loại duy nhất của đa nhiệm!

Trong mã thực tế, bạn thường sẽ không thay đổi các lệnh gọi hàm với các điểm await trên mỗi dòng, tất nhiên. Mặc dù nhường quyền kiểm soát theo cách này là tương đối không tốn kém, nhưng nó không miễn phí. Trong nhiều trường hợp, việc cố gắng chia nhỏ một tác vụ gắn với tính toán có thể làm cho nó chậm hơn đáng kể, vì vậy đôi khi tốt hơn cho hiệu suất tổng thể là để một hoạt động chặn ngắn. Luôn đo lường để xem thực sự các nút thắt cổ chai hiệu suất của mã của bạn là gì. Động lực cơ bản là điều quan trọng cần ghi nhớ, tuy nhiên, nếu bạn đang thấy nhiều công việc xảy ra tuần tự mà bạn mong đợi xảy ra đồng thời!

Xây Dựng Các Sự Trừu Tượng Async Của Riêng Chúng Ta

Chúng ta cũng có thể kết hợp các future lại với nhau để tạo ra các mẫu mới. Ví dụ, chúng ta có thể xây dựng một hàm timeout với các khối xây dựng async mà chúng ta đã có. Khi chúng ta hoàn thành, kết quả sẽ là một khối xây dựng khác mà chúng ta có thể sử dụng để tạo ra thêm các sự trừu tượng async.

Listing 17-27 hiển thị cách chúng ta mong đợi timeout này hoạt động với một future chậm.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_millis(100)).await;
            "I finished!"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

Hãy triển khai điều này! Để bắt đầu, hãy suy nghĩ về API cho timeout:

  • Nó cần phải là một hàm async để chúng ta có thể await nó.
  • Tham số đầu tiên của nó phải là một future để chạy. Chúng ta có thể làm cho nó generic để cho phép nó hoạt động với bất kỳ future nào.
  • Tham số thứ hai của nó sẽ là thời gian tối đa để đợi. Nếu chúng ta sử dụng một Duration, điều đó sẽ làm cho nó dễ dàng để truyền cho trpl::sleep.
  • Nó sẽ trả về một Result. Nếu future hoàn thành thành công, Result sẽ là Ok với giá trị được tạo ra bởi future. Nếu timeout hết hạn trước, Result sẽ là Err với duration mà timeout đã đợi.

Listing 17-28 hiển thị khai báo này.

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}

Điều đó đáp ứng các mục tiêu của chúng ta cho các kiểu. Bây giờ hãy suy nghĩ về hành vi mà chúng ta cần: chúng ta muốn đua future được truyền vào với duration. Chúng ta có thể sử dụng trpl::sleep để tạo một future bộ đếm thời gian từ duration, và sử dụng trpl::race để chạy bộ đếm thời gian đó với future mà người gọi truyền vào.

Chúng ta cũng biết rằng race không công bằng, polling các đối số theo thứ tự mà chúng được truyền. Do đó, chúng ta truyền future_to_try cho race trước để nó có cơ hội hoàn thành ngay cả khi max_time là một duration rất ngắn. Nếu future_to_try hoàn thành trước, race sẽ trả về Left với đầu ra từ future_to_try. Nếu timer hoàn thành trước, race sẽ trả về Right với đầu ra của timer là ().

Trong Listing 17-29, chúng ta match trên kết quả của việc await trpl::race.

extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::race(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}

Nếu future_to_try thành công và chúng ta nhận được Left(output), chúng ta trả về Ok(output). Nếu bộ đếm thời gian ngủ hết hạn thay vào đó và chúng ta nhận được Right(()), chúng ta bỏ qua () với _ và trả về Err(max_time) thay thế.

Với điều đó, chúng ta có một timeout hoạt động được xây dựng từ hai trợ giúp async khác. Nếu chúng ta chạy mã của mình, nó sẽ in chế độ thất bại sau timeout:

Failed after 2 seconds

Bởi vì future kết hợp với các future khác, bạn có thể xây dựng các công cụ thực sự mạnh mẽ sử dụng các khối xây dựng async nhỏ hơn. Ví dụ, bạn có thể sử dụng cùng cách tiếp cận này để kết hợp timeout với thử lại, và đến lượt sử dụng chúng với các hoạt động như các cuộc gọi mạng (một trong các ví dụ từ đầu chương).

Trong thực tế, bạn thường sẽ làm việc trực tiếp với asyncawait, và thứ hai là với các hàm và macro như join, join_all, race, và v.v. Bạn sẽ chỉ cần sử dụng pin thỉnh thoảng để sử dụng future với những API đó.

Bây giờ chúng ta đã thấy một số cách để làm việc với nhiều future cùng một lúc. Tiếp theo, chúng ta sẽ xem cách làm việc với nhiều future trong một chuỗi theo thời gian với streams. Dưới đây là một vài điều khác bạn có thể muốn xem xét trước, tuy nhiên:

  • Chúng ta đã sử dụng một Vec với join_all để đợi tất cả các future trong một nhóm hoàn thành. Làm thế nào bạn có thể sử dụng một Vec để xử lý một nhóm future tuần tự thay vào đó? Sự đánh đổi của việc làm điều đó là gì?

  • Hãy xem kiểu futures::stream::FuturesUnordered từ crate futures. Sử dụng nó sẽ khác với sử dụng một Vec như thế nào? (Đừng lo lắng về việc nó đến từ phần stream của crate; nó hoạt động hoàn toàn tốt với bất kỳ tập hợp future nào.)

Streams: Future theo Trình tự

Cho đến nay trong chương này, chúng ta chủ yếu đã làm việc với các future riêng lẻ. Một ngoại lệ lớn là kênh async mà chúng ta đã sử dụng. Hãy nhớ lại cách chúng ta đã sử dụng bộ thu cho kênh async của chúng ta trước đó trong chương này ở phần "Message Passing". Phương thức recv async tạo ra một chuỗi các mục theo thời gian. Đây là một ví dụ của một mẫu tổng quát hơn được gọi là stream.

Chúng ta đã thấy một chuỗi các mục trong Chương 13, khi chúng ta xem xét trait Iterator trong phần The Iterator Trait and the next Method, nhưng có hai sự khác biệt giữa iterators và bộ thu kênh async. Sự khác biệt đầu tiên là thời gian: iterators là đồng bộ, trong khi bộ thu kênh là bất đồng bộ. Thứ hai là API. Khi làm việc trực tiếp với Iterator, chúng ta gọi phương thức next đồng bộ của nó. Với stream trpl::Receiver cụ thể, chúng ta đã gọi một phương thức recv bất đồng bộ thay thế. Ngoài ra, các API này cảm thấy rất giống nhau, và sự tương đồng đó không phải là sự trùng hợp. Một stream giống như một hình thức lặp bất đồng bộ. Trong khi trpl::Receiver cụ thể đợi để nhận tin nhắn, thì API stream đa năng chung rộng hơn nhiều: nó cung cấp mục tiếp theo theo cách Iterator làm, nhưng bất đồng bộ.

Sự tương đồng giữa iterators và streams trong Rust có nghĩa là chúng ta thực sự có thể tạo một stream từ bất kỳ iterator nào. Giống như với một iterator, chúng ta có thể làm việc với một stream bằng cách gọi phương thức next của nó và sau đó đợi kết quả, như trong Listing 17-30.

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

Chúng ta bắt đầu với một mảng các số, mà chúng ta chuyển đổi thành một iterator và sau đó gọi map trên nó để nhân đôi tất cả các giá trị. Sau đó, chúng ta chuyển đổi iterator thành stream bằng cách sử dụng hàm trpl::stream_from_iter. Tiếp theo, chúng ta lặp qua các mục trong stream khi chúng xuất hiện với vòng lặp while let.

Thật không may, khi chúng ta thử chạy mã, nó không biên dịch được, mà thay vào đó báo cáo rằng không có phương thức next nào khả dụng:

error[E0599]: no method named `next` found for struct `Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = note: the full type name has been written to 'file:///projects/async-await/target/debug/deps/async_await-575db3dd3197d257.long-type-14490787947592691573.txt'
   = note: consider using `--verbose` to print the full type name to the console
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

Như kết quả này giải thích, lý do cho lỗi biên dịch là chúng ta cần trait đúng trong phạm vi để có thể sử dụng phương thức next. Dựa trên cuộc thảo luận của chúng ta cho đến nay, bạn có thể mong đợi một cách hợp lý rằng trait đó là Stream, nhưng nó thực sự là StreamExt. Viết tắt của extension, Ext là một mẫu phổ biến trong cộng đồng Rust để mở rộng một trait với một trait khác.

Chúng ta sẽ giải thích các trait StreamStreamExt chi tiết hơn một chút vào cuối chương, nhưng hiện tại tất cả những gì bạn cần biết là trait Stream định nghĩa một giao diện cấp thấp mà hiệu quả là kết hợp các trait IteratorFuture. StreamExt cung cấp một tập hợp API cấp cao hơn trên Stream, bao gồm phương thức next cũng như các phương thức tiện ích khác tương tự như những phương thức được cung cấp bởi trait Iterator. StreamStreamExt chưa phải là một phần của thư viện chuẩn của Rust, nhưng hầu hết các crate trong hệ sinh thái sử dụng cùng định nghĩa.

Cách sửa lỗi biên dịch là thêm câu lệnh use cho trpl::StreamExt, như trong Listing 17-31.

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

Với tất cả các phần đó được kết hợp lại, mã này hoạt động theo cách chúng ta muốn! Hơn nữa, bây giờ chúng ta có StreamExt trong phạm vi, chúng ta có thể sử dụng tất cả các phương thức tiện ích của nó, giống như với các iterator. Ví dụ, trong Listing 17-32, chúng ta sử dụng phương thức filter để lọc ra tất cả trừ bội số của ba và năm.

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = 1..101;
        let iter = values.map(|n| n * 2);
        let stream = trpl::stream_from_iter(iter);

        let mut filtered =
            stream.filter(|value| value % 3 == 0 || value % 5 == 0);

        while let Some(value) = filtered.next().await {
            println!("The value was: {value}");
        }
    });
}

Tất nhiên, điều này không quá thú vị, vì chúng ta có thể làm tương tự với các iterator thông thường và không cần async gì cả. Hãy xem những gì chúng ta có thể làm mà độc đáo cho streams.

Kết hợp các Streams

Nhiều khái niệm tự nhiên được biểu diễn dưới dạng streams: các mục trở nên có sẵn trong một hàng đợi, các đoạn dữ liệu được kéo dần từ hệ thống tập tin khi tập dữ liệu đầy đủ quá lớn đối với bộ nhớ của máy tính, hoặc dữ liệu đến qua mạng theo thời gian. Vì streams là futures, chúng ta có thể sử dụng chúng với bất kỳ loại future nào khác và kết hợp chúng theo những cách thú vị. Ví dụ, chúng ta có thể gộp các sự kiện để tránh kích hoạt quá nhiều cuộc gọi mạng, đặt thời gian chờ trên chuỗi các hoạt động chạy dài, hoặc hạn chế các sự kiện giao diện người dùng để tránh làm công việc không cần thiết.

Hãy bắt đầu bằng việc xây dựng một stream nhỏ gồm các tin nhắn như một thay thế cho stream dữ liệu mà chúng ta có thể thấy từ WebSocket hoặc giao thức giao tiếp thời gian thực khác, như được hiển thị trong Listing 17-33.

extern crate trpl; // required for mdbook test

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages = get_messages();

        while let Some(message) = messages.next().await {
            println!("{message}");
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}

Đầu tiên, chúng ta tạo một hàm gọi là get_messages mà trả về impl Stream<Item = String>. Đối với cách thực hiện của nó, chúng ta tạo một kênh async, lặp qua 10 chữ cái đầu tiên của bảng chữ cái tiếng Anh, và gửi chúng qua kênh.

Chúng ta cũng sử dụng một kiểu mới: ReceiverStream, nó chuyển đổi bộ thu rx từ trpl::channel thành một Stream với một phương thức next. Trở lại trong main, chúng ta sử dụng một vòng lặp while let để in tất cả các tin nhắn từ stream.

Khi chúng ta chạy mã này, chúng ta nhận được chính xác kết quả mà chúng ta mong đợi:

Message: 'a'
Message: 'b'
Message: 'c'
Message: 'd'
Message: 'e'
Message: 'f'
Message: 'g'
Message: 'h'
Message: 'i'
Message: 'j'

Một lần nữa, chúng ta có thể làm điều này với API Receiver thông thường hoặc thậm chí API Iterator thông thường, mặc dù vậy, nên hãy thêm một tính năng yêu cầu streams: thêm một timeout áp dụng cho mọi mục trong stream, và một độ trễ trên các mục mà chúng ta phát ra, như trong Listing 17-34.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};
use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}

Chúng ta bắt đầu bằng việc thêm một timeout vào stream với phương thức timeout, nó đến từ trait StreamExt. Sau đó, chúng ta cập nhật thân của vòng lặp while let, bởi vì stream bây giờ trả về một Result. Biến thể Ok chỉ ra rằng một tin nhắn đã đến kịp thời; biến thể Err chỉ ra rằng timeout đã hết hạn trước khi bất kỳ tin nhắn nào đến. Chúng ta match trên kết quả đó và hoặc in tin nhắn khi chúng ta nhận được nó thành công hoặc in một thông báo về timeout. Cuối cùng, chú ý rằng chúng ta ghim các tin nhắn sau khi áp dụng timeout cho chúng, bởi vì trợ giúp timeout tạo ra một stream cần được ghim để được poll.

Tuy nhiên, bởi vì không có độ trễ giữa các tin nhắn, timeout này không thay đổi hành vi của chương trình. Hãy thêm một độ trễ thay đổi cho các tin nhắn mà chúng ta gửi, như trong Listing 17-35.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

Trong get_messages, chúng ta sử dụng phương thức iterator enumerate với mảng messages để chúng ta có thể lấy chỉ số của mỗi mục mà chúng ta đang gửi cùng với chính mục đó. Sau đó, chúng ta áp dụng một độ trễ 100 mili giây cho các mục có chỉ số chẵn và một độ trễ 300 mili giây cho các mục có chỉ số lẻ để mô phỏng các độ trễ khác nhau mà chúng ta có thể thấy từ một stream các tin nhắn trong thế giới thực. Bởi vì timeout của chúng ta là 200 mili giây, điều này sẽ ảnh hưởng đến một nửa các tin nhắn.

Để ngủ giữa các tin nhắn trong hàm get_messages mà không chặn, chúng ta cần sử dụng async. Tuy nhiên, chúng ta không thể biến get_messages thành một hàm async, bởi vì sau đó chúng ta sẽ trả về một Future<Output = Stream<Item = String>> thay vì một Stream<Item = String>>. Người gọi sẽ phải await chính get_messages để có quyền truy cập vào stream. Nhưng hãy nhớ: mọi thứ trong một future nhất định xảy ra tuyến tính; tính đồng thời xảy ra giữa các future. Awaiting get_messages sẽ yêu cầu nó gửi tất cả các tin nhắn, bao gồm cả độ trễ sleep giữa mỗi tin nhắn, trước khi trả về stream bộ thu. Kết quả là, timeout sẽ vô dụng. Sẽ không có độ trễ trong chính stream; tất cả chúng sẽ xảy ra trước khi stream thậm chí có sẵn.

Thay vào đó, chúng ta để get_messages như một hàm thông thường trả về một stream, và chúng ta spawn một task để xử lý các lệnh gọi sleep async.

Lưu ý: Gọi spawn_task theo cách này hoạt động bởi vì chúng ta đã thiết lập runtime của mình; nếu không, nó sẽ gây ra hoảng loạn. Các cách thực hiện khác lựa chọn các sự đánh đổi khác nhau: chúng có thể spawn một runtime mới và tránh hoảng loạn nhưng cuối cùng có một chút chi phí bổ sung, hoặc chúng có thể đơn giản không cung cấp một cách độc lập để spawn các task mà không cần tham chiếu đến runtime. Hãy đảm bảo rằng bạn biết sự đánh đổi mà runtime của bạn đã chọn và viết mã của bạn tương ứng!

Bây giờ mã của chúng ta có một kết quả thú vị hơn nhiều. Giữa mỗi cặp tin nhắn khác, một lỗi Problem: Elapsed(()).

Message: 'a'
Problem: Elapsed(())
Message: 'b'
Message: 'c'
Problem: Elapsed(())
Message: 'd'
Message: 'e'
Problem: Elapsed(())
Message: 'f'
Message: 'g'
Problem: Elapsed(())
Message: 'h'
Message: 'i'
Problem: Elapsed(())
Message: 'j'

Timeout không ngăn chặn các tin nhắn đến cuối cùng. Chúng ta vẫn nhận được tất cả các tin nhắn ban đầu, bởi vì kênh của chúng ta là unbounded: nó có thể chứa nhiều tin nhắn như chúng ta có thể vừa trong bộ nhớ. Nếu tin nhắn không đến trước khi timeout, bộ xử lý stream của chúng ta sẽ tính đến điều đó, nhưng khi nó poll stream một lần nữa, tin nhắn bây giờ có thể đã đến.

Bạn có thể nhận được hành vi khác nhau nếu cần bằng cách sử dụng các loại kênh khác hoặc các loại stream khác nói chung. Hãy xem một trong những điều đó trong thực tế bằng cách kết hợp một stream các khoảng thời gian với stream tin nhắn này.

Hợp nhất các Streams

Trước tiên, hãy tạo một stream khác, nó sẽ phát ra một mục mỗi mili giây nếu chúng ta để nó chạy trực tiếp. Để đơn giản, chúng ta có thể sử dụng hàm sleep để gửi một tin nhắn trên một độ trễ và kết hợp nó với cùng cách tiếp cận mà chúng ta đã sử dụng trong get_messages của việc tạo một stream từ một kênh. Sự khác biệt là lần này, chúng ta sẽ gửi lại số lượng khoảng thời gian đã trôi qua, vì vậy kiểu trả về sẽ là impl Stream<Item = u32>, và chúng ta có thể gọi hàm get_intervals (xem Listing 17-36).

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

Chúng ta bắt đầu bằng việc định nghĩa một count trong task. (Chúng ta cũng có thể định nghĩa nó bên ngoài task, nhưng rõ ràng hơn là giới hạn phạm vi của bất kỳ biến nhất định nào.) Sau đó, chúng ta tạo một vòng lặp vô hạn. Mỗi lần lặp của vòng lặp ngủ bất đồng bộ trong một mili giây, tăng số đếm, và sau đó gửi nó qua kênh. Bởi vì tất cả điều này được bọc trong task được tạo bởi spawn_task, tất cả nó—bao gồm cả vòng lặp vô hạn—sẽ được dọn dẹp cùng với runtime.

Loại vòng lặp vô hạn này, nó chỉ kết thúc khi toàn bộ runtime bị tháo dỡ, khá phổ biến trong Rust async: nhiều chương trình cần tiếp tục chạy vô thời hạn. Với async, điều này không chặn bất cứ thứ gì khác, miễn là có ít nhất một điểm await trong mỗi lần lặp qua vòng lặp.

Bây giờ, trở lại async block của hàm main của chúng ta, chúng ta có thể cố gắng hợp nhất các stream messagesintervals, như trong Listing 17-37.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals();
        let merged = messages.merge(intervals);

        while let Some(result) = merged.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

Chúng ta bắt đầu bằng cách gọi get_intervals. Sau đó, chúng ta hợp nhất các stream messagesintervals với phương thức merge, nó kết hợp nhiều stream thành một stream mà tạo ra các mục từ bất kỳ stream nguồn nào ngay khi các mục có sẵn, mà không áp đặt bất kỳ thứ tự cụ thể nào. Cuối cùng, chúng ta lặp qua stream kết hợp đó thay vì qua messages.

Tại thời điểm này, không messages cũng không intervals cần được ghim hoặc có thể thay đổi, bởi vì cả hai sẽ được kết hợp vào stream merged duy nhất. Tuy nhiên, lệnh gọi merge này không biên dịch! (Lệnh gọi next trong vòng lặp while let cũng không, nhưng chúng ta sẽ quay lại điều đó.) Đây là bởi vì hai stream có các kiểu khác nhau. Stream messages có kiểu Timeout<impl Stream<Item = String>>, trong đó Timeout là kiểu thực thi Stream cho một lệnh gọi timeout. Stream intervals có kiểu impl Stream<Item = u32>. Để hợp nhất hai stream này, chúng ta cần chuyển đổi một trong chúng để khớp với stream kia. Chúng ta sẽ cải tạo stream intervals, bởi vì messages đã ở định dạng cơ bản mà chúng ta muốn và phải xử lý các lỗi timeout (xem Listing 17-38).

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

Đầu tiên, chúng ta có thể sử dụng phương thức trợ giúp map để chuyển đổi intervals thành một chuỗi. Thứ hai, chúng ta cần phải khớp với Timeout từ messages. Bởi vì chúng ta không thực sự muốn một timeout cho intervals, tuy nhiên, chúng ta có thể chỉ tạo một timeout dài hơn các khoảng thời gian khác mà chúng ta đang sử dụng. Ở đây, chúng ta tạo một timeout 10 giây với Duration::from_secs(10). Cuối cùng, chúng ta cần làm cho stream có thể thay đổi, để các lệnh gọi next của vòng lặp while let có thể lặp qua stream, và ghim nó để an toàn khi làm như vậy. Điều đó đưa chúng ta gần đến nơi chúng ta cần phải đến. Mọi thứ kiểm tra kiểu. Tuy nhiên, nếu bạn chạy điều này, sẽ có hai vấn đề. Đầu tiên, nó sẽ không bao giờ dừng lại! Bạn sẽ cần dừng nó với ctrl-c. Thứ hai, các tin nhắn từ bảng chữ cái tiếng Anh sẽ bị chôn vùi giữa tất cả các tin nhắn bộ đếm khoảng thời gian:

--snip--
Interval: 38
Interval: 39
Interval: 40
Message: 'a'
Interval: 41
Interval: 42
Interval: 43
--snip--

Listing 17-39 hiển thị một cách để giải quyết hai vấn đề cuối cùng này.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .throttle(Duration::from_millis(100))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

Đầu tiên, chúng ta sử dụng phương thức throttle trên stream intervals để nó không áp đảo stream messages. Throttling là một cách để giới hạn tốc độ mà một hàm sẽ được gọi—hoặc, trong trường hợp này, bao lâu một lần stream sẽ được poll. Mỗi 100 mili giây sẽ đủ, bởi vì đó là khoảng thời gian tin nhắn của chúng ta đến.

Để giới hạn số lượng mục mà chúng ta sẽ chấp nhận từ một stream, chúng ta áp dụng phương thức take cho stream merged, bởi vì chúng ta muốn giới hạn đầu ra cuối cùng, không chỉ một stream này hoặc stream kia.

Bây giờ khi chúng ta chạy chương trình, nó dừng lại sau khi kéo 20 mục từ stream, và các khoảng thời gian không áp đảo các tin nhắn. Chúng ta cũng không nhận được Interval: 100 hoặc Interval: 200 hoặc vân vân, mà thay vào đó nhận được Interval: 1, Interval: 2, và v.v.—mặc dù chúng ta có một stream nguồn có thể tạo ra một sự kiện mỗi mili giây. Đó là bởi vì lệnh gọi throttle tạo ra một stream mới bao bọc stream ban đầu để stream ban đầu chỉ được poll ở tốc độ throttle, không phải tốc độ "tự nhiên" của nó. Chúng ta không có một đống tin nhắn khoảng thời gian không xử lý mà chúng ta đang chọn bỏ qua. Thay vào đó, chúng ta không bao giờ tạo ra những tin nhắn khoảng thời gian đó ngay từ đầu! Đây là tính "lười biếng" vốn có của các future của Rust làm việc một lần nữa, cho phép chúng ta chọn các đặc điểm hiệu suất của mình.

Interval: 1
Message: 'a'
Interval: 2
Interval: 3
Problem: Elapsed(())
Interval: 4
Message: 'b'
Interval: 5
Message: 'c'
Interval: 6
Interval: 7
Problem: Elapsed(())
Interval: 8
Message: 'd'
Interval: 9
Message: 'e'
Interval: 10
Interval: 11
Problem: Elapsed(())
Interval: 12

Có một điều cuối cùng chúng ta cần xử lý: lỗi! Với cả hai stream dựa trên kênh này, các lệnh gọi send có thể thất bại khi phía bên kia của kênh đóng—và đó chỉ là một vấn đề về cách runtime thực thi các future tạo nên stream. Cho đến bây giờ, chúng ta đã bỏ qua khả năng này bằng cách gọi unwrap, nhưng trong một ứng dụng hoạt động tốt, chúng ta nên xử lý lỗi một cách rõ ràng, tối thiểu là bằng cách kết thúc vòng lặp để chúng ta không cố gắng gửi thêm tin nhắn. Listing 17-40 hiển thị một chiến lược lỗi đơn giản: in vấn đề và sau đó break khỏi các vòng lặp.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}

Như thường lệ, cách đúng để xử lý lỗi gửi tin nhắn sẽ thay đổi; chỉ cần đảm bảo bạn có một chiến lược.

Bây giờ sau khi chúng ta đã thấy rất nhiều async trong thực tế, hãy lui lại một bước và đào sâu vào một vài chi tiết về cách Future, Stream, và các trait quan trọng khác mà Rust sử dụng để làm cho async hoạt động.

Xem xét kỹ hơn về các Trait cho Async

Trong suốt chương này, chúng ta đã sử dụng các trait Future, Pin, Unpin, Stream, và StreamExt theo nhiều cách khác nhau. Tuy nhiên, cho đến nay, chúng ta đã tránh đi quá sâu vào chi tiết về cách chúng hoạt động hoặc cách chúng kết hợp với nhau, điều này phù hợp với công việc Rust hàng ngày của bạn trong hầu hết thời gian. Tuy nhiên, đôi khi, bạn sẽ gặp phải những tình huống mà bạn cần hiểu thêm một vài chi tiết này. Trong phần này, chúng ta sẽ đào sâu đủ để giúp đỡ trong những kịch bản đó, vẫn để lại việc đào sâu thực sự cho các tài liệu khác.

Trait Future

Hãy bắt đầu bằng cách xem xét kỹ hơn cách trait Future hoạt động. Đây là cách Rust định nghĩa nó:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Định nghĩa trait đó bao gồm một loạt các kiểu mới và cũng có một số cú pháp mà chúng ta chưa thấy trước đây, vì vậy hãy đi qua định nghĩa từng phần một.

Đầu tiên, kiểu kết hợp Output của Future nói lên những gì future giải quyết. Điều này tương tự như kiểu kết hợp Item của trait Iterator. Thứ hai, Future cũng có phương thức poll, phương thức này lấy một tham chiếu Pin đặc biệt cho tham số self và một tham chiếu có thể thay đổi đến kiểu Context, và trả về một Poll<Self::Output>. Chúng ta sẽ nói thêm về PinContext trong một lát. Hiện tại, hãy tập trung vào những gì phương thức trả về, kiểu Poll:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

Kiểu Poll này tương tự như một Option. Nó có một biến thể có giá trị, Ready(T), và một biến thể không có, Pending. Tuy nhiên, Poll có ý nghĩa khá khác với Option! Biến thể Pending chỉ ra rằng future vẫn còn việc phải làm, vì vậy người gọi sẽ cần kiểm tra lại sau. Biến thể Ready chỉ ra rằng future đã hoàn thành công việc của nó và giá trị T đã có sẵn.

Lưu ý: Với hầu hết các future, người gọi không nên gọi poll lại sau khi future đã trả về Ready. Nhiều future sẽ panic nếu bị poll lại sau khi đã sẵn sàng. Các future an toàn để poll lại sẽ nêu rõ điều đó trong tài liệu của chúng. Điều này tương tự như cách Iterator::next hoạt động.

Khi bạn thấy mã sử dụng await, Rust biên dịch nó bên dưới thành mã gọi poll. Nếu bạn nhìn lại Listing 17-4, nơi chúng ta in tiêu đề trang cho một URL duy nhất sau khi nó được giải quyết, Rust biên dịch nó thành thứ gì đó kiểu như (mặc dù không chính xác) như thế này:

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // But what goes here?
    }
}

Chúng ta nên làm gì khi future vẫn còn Pending? Chúng ta cần một cách nào đó để thử lại, và lại, và lại, cho đến khi future cuối cùng sẵn sàng. Nói cách khác, chúng ta cần một vòng lặp:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // continue
        }
    }
}

Tuy nhiên, nếu Rust biên dịch nó thành chính xác mã đó, thì mỗi await sẽ chặn—chính xác là điều ngược lại với những gì chúng ta đang hướng đến! Thay vào đó, Rust đảm bảo rằng vòng lặp có thể chuyển quyền kiểm soát cho thứ gì đó có thể tạm dừng công việc trên future này để làm việc trên các future khác và sau đó kiểm tra lại future này sau. Như chúng ta đã thấy, thứ đó là một runtime async, và công việc lập lịch và phối hợp này là một trong những nhiệm vụ chính của nó.

Trước đó trong chương này, chúng ta đã mô tả việc chờ đợi rx.recv. Lệnh gọi recv trả về một future, và việc await future sẽ poll nó. Chúng ta đã lưu ý rằng runtime sẽ tạm dừng future cho đến khi nó sẵn sàng với Some(message) hoặc None khi kênh đóng. Với sự hiểu biết sâu sắc hơn về trait Future, và cụ thể là Future::poll, chúng ta có thể thấy cách nó hoạt động. Runtime biết future chưa sẵn sàng khi nó trả về Poll::Pending. Ngược lại, runtime biết future đã sẵn sàng và tiến nó khi poll trả về Poll::Ready(Some(message)) hoặc Poll::Ready(None).

Chi tiết chính xác về cách runtime thực hiện điều đó nằm ngoài phạm vi của cuốn sách này, nhưng chìa khóa là thấy được cơ chế cơ bản của futures: một runtime polls mỗi future mà nó chịu trách nhiệm, đưa future trở lại trạng thái ngủ khi nó chưa sẵn sàng.

Các Trait PinUnpin

Khi chúng ta giới thiệu ý tưởng về pinning trong Listing 17-16, chúng ta đã gặp phải một thông báo lỗi rất khó hiểu. Đây là phần liên quan của nó một lần nữa:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

Thông báo lỗi này cho chúng ta biết không chỉ rằng chúng ta cần ghim các giá trị mà còn cả lý do tại sao pinning là bắt buộc. Hàm trpl::join_all trả về một struct có tên JoinAll. Struct đó là generic trên một kiểu F, bị ràng buộc để thực thi trait Future. Await trực tiếp một future với await ghim future một cách ngầm định. Đó là lý do tại sao chúng ta không cần sử dụng pin! ở mọi nơi chúng ta muốn await futures.

Tuy nhiên, chúng ta không await trực tiếp một future ở đây. Thay vào đó, chúng ta xây dựng một future mới, JoinAll, bằng cách truyền một bộ sưu tập các future vào hàm join_all. Chữ ký cho join_all yêu cầu rằng các kiểu của các mục trong bộ sưu tập đều thực thi trait Future, và Box<T> thực thi Future chỉ khi T mà nó bọc là một future thực thi trait Unpin.

Đó là rất nhiều để hấp thụ! Để thực sự hiểu nó, hãy đi sâu hơn một chút vào cách trait Future thực sự hoạt động, đặc biệt là xung quanh pinning.

Nhìn lại định nghĩa của trait Future:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Required method
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Tham số cx và kiểu Context của nó là chìa khóa để một runtime thực sự biết khi nào kiểm tra bất kỳ future nào mà vẫn lười biếng. Một lần nữa, chi tiết về cách điều đó hoạt động nằm ngoài phạm vi của chương này, và bạn thường chỉ cần suy nghĩ về điều này khi viết một cách triển khai Future tùy chỉnh. Chúng ta sẽ tập trung thay vào đó vào kiểu cho self, vì đây là lần đầu tiên chúng ta thấy một phương thức trong đó self có một chú thích kiểu. Một chú thích kiểu cho self hoạt động như chú thích kiểu cho các tham số hàm khác, nhưng với hai khác biệt chính:

  • Nó cho Rust biết self phải là kiểu gì để phương thức được gọi.

  • Nó không thể là bất kỳ kiểu nào. Nó bị giới hạn trong kiểu mà phương thức được triển khai, một tham chiếu hoặc con trỏ thông minh đến kiểu đó, hoặc một Pin bao bọc một tham chiếu đến kiểu đó.

Chúng ta sẽ thấy thêm về cú pháp này trong Chương 18. Hiện tại, điều đủ để biết là nếu chúng ta muốn poll một future để kiểm tra xem nó là Pending hay Ready(Output), chúng ta cần một tham chiếu có thể thay đổi được bọc trong Pin đến kiểu.

Pin là một wrapper cho các kiểu giống con trỏ như &, &mut, Box, và Rc. (Về mặt kỹ thuật, Pin hoạt động với các kiểu triển khai các trait Deref hoặc DerefMut, nhưng điều này hiệu quả tương đương với việc chỉ làm việc với các con trỏ.) Pin không phải là một con trỏ và không có bất kỳ hành vi nào của riêng nó như RcArc với việc đếm tham chiếu; nó thuần túy là một công cụ mà trình biên dịch có thể sử dụng để áp đặt các ràng buộc về việc sử dụng con trỏ.

Nhớ lại rằng await được triển khai dưới dạng các lệnh gọi đến poll bắt đầu giải thích thông báo lỗi chúng ta đã thấy trước đó, nhưng điều đó là về Unpin, không phải Pin. Vậy chính xác thì Pin liên quan đến Unpin như thế nào, và tại sao Future cần self là một kiểu Pin để gọi poll?

Hãy nhớ từ trước đó trong chương này, một chuỗi các điểm await trong một future được biên dịch thành một máy trạng thái, và trình biên dịch đảm bảo rằng máy trạng thái đó tuân theo tất cả các quy tắc thông thường của Rust xung quanh sự an toàn, bao gồm cả việc mượn và sở hữu. Để làm cho điều đó hoạt động, Rust xem xét dữ liệu nào là cần thiết giữa một điểm await và điểm await tiếp theo hoặc cuối của async block. Nó sau đó tạo ra một biến thể tương ứng trong máy trạng thái được biên dịch. Mỗi biến thể nhận quyền truy cập mà nó cần đến dữ liệu sẽ được sử dụng trong phần đó của mã nguồn, dù là bằng cách lấy quyền sở hữu dữ liệu đó hoặc bằng cách nhận một tham chiếu có thể thay đổi hoặc không thể thay đổi đến nó.

Cho đến nay, vẫn tốt: nếu chúng ta làm bất cứ điều gì sai về quyền sở hữu hoặc tham chiếu trong một async block nhất định, trình kiểm tra mượn sẽ cho chúng ta biết. Khi chúng ta muốn di chuyển future tương ứng với block đó—như đưa nó vào một Vec để truyền vào join_all—mọi thứ trở nên khó khăn hơn.

Khi chúng ta di chuyển một future—dù là bằng cách đẩy nó vào một cấu trúc dữ liệu để sử dụng như một iterator với join_all hoặc bằng cách trả về nó từ một hàm—điều đó thực sự có nghĩa là di chuyển máy trạng thái Rust tạo ra cho chúng ta. Và không giống như hầu hết các kiểu khác trong Rust, các future mà Rust tạo ra cho các async block có thể kết thúc với các tham chiếu đến chính nó trong các trường của bất kỳ biến thể nhất định nào, như được minh họa đơn giản trong Hình 17-4.

A single-column, three-row table representing a future, fut1, which has data values 0 and 1 in the first two rows and an arrow pointing from the third row back to the second row, representing an internal reference within the future.
Hình 17-4: Một kiểu dữ liệu tự tham chiếu.

Tuy nhiên, theo mặc định, bất kỳ đối tượng nào có tham chiếu đến chính nó đều không an toàn để di chuyển, bởi vì các tham chiếu luôn trỏ đến địa chỉ bộ nhớ thực tế của bất cứ thứ gì chúng tham chiếu đến (xem Hình 17-5). Nếu bạn di chuyển chính cấu trúc dữ liệu, những tham chiếu nội bộ đó sẽ bị để lại trỏ đến vị trí cũ. Tuy nhiên, vị trí bộ nhớ đó giờ đây không hợp lệ. Một mặt, giá trị của nó sẽ không được cập nhật khi bạn thay đổi cấu trúc dữ liệu. Mặt khác—điều quan trọng hơn—là máy tính hiện có thể tự do sử dụng lại bộ nhớ đó cho các mục đích khác! Bạn có thể kết thúc bằng việc đọc dữ liệu hoàn toàn không liên quan sau này.

Two tables, depicting two futures, fut1 and fut2, each of which has one column and three rows, representing the result of having moved a future out of fut1 into fut2. The first, fut1, is grayed out, with a question mark in each index, representing unknown memory. The second, fut2, has 0 and 1 in the first and second rows and an arrow pointing from its third row back to the second row of fut1, representing a pointer that is referencing the old location in memory of the future before it was moved.
Hình 17-5: Kết quả không an toàn của việc di chuyển một kiểu dữ liệu tự tham chiếu

Về mặt lý thuyết, trình biên dịch Rust có thể cố gắng cập nhật mọi tham chiếu đến một đối tượng bất cứ khi nào nó bị di chuyển, nhưng điều đó có thể thêm rất nhiều chi phí hiệu suất, đặc biệt nếu toàn bộ mạng lưới các tham chiếu cần cập nhật. Nếu thay vào đó chúng ta có thể đảm bảo rằng cấu trúc dữ liệu đang được đề cập không di chuyển trong bộ nhớ, chúng ta sẽ không phải cập nhật bất kỳ tham chiếu nào. Đây chính xác là những gì trình kiểm tra mượn của Rust yêu cầu: trong mã an toàn, nó ngăn bạn di chuyển bất kỳ mục nào có tham chiếu đang hoạt động đến nó.

Pin xây dựng trên điều đó để cung cấp cho chúng ta chính xác sự đảm bảo mà chúng ta cần. Khi chúng ta ghim một giá trị bằng cách bọc một con trỏ đến giá trị đó trong Pin, nó không còn có thể di chuyển. Do đó, nếu bạn có Pin<Box<SomeType>>, bạn thực sự ghim giá trị SomeType, không phải con trỏ Box. Hình 17-6 minh họa quá trình này.

<img alt="Three boxes laid out side by side. The first is labeled "Pin", the second "b1", and the third "pinned". Within "pinned" is a table labeled "fut", with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value "0", its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value "1" in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the "fut" table represents a future which is self-referential. An arrow leaves the box labeled "Pin", goes through the box labeled "b1" and has terminates inside the "pinned" box at the "fut" table." src="img/trpl17-06.svg" class="center" />

Hình 17-6: Ghim một `Box` trỏ đến một kiểu future tự tham chiếu.

Trên thực tế, con trỏ Box vẫn có thể di chuyển tự do. Hãy nhớ: chúng ta quan tâm đến việc đảm bảo rằng dữ liệu cuối cùng được tham chiếu ở yên vị trí của nó. Nếu một con trỏ di chuyển xung quanh, nhưng dữ liệu mà nó trỏ đến ở cùng một vị trí, như trong Hình 17-7, không có vấn đề tiềm ẩn nào. Như một bài tập độc lập, hãy xem tài liệu cho các kiểu cũng như module std::pin và cố gắng tìm ra cách bạn sẽ làm điều này với một Pin bao bọc một Box.) Điều quan trọng là kiểu tự tham chiếu không thể di chuyển, bởi vì nó vẫn được ghim.

<img alt="Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled "b1" and "b2", "b1" is grayed out, and the arrow from "Pin" goes through "b2" instead of "b1", indicating that the pointer has moved from "b1" to "b2", but the data in "pinned" has not moved." src="img/trpl17-07.svg" class="center" />

Hình 17-7: Di chuyển một `Box` trỏ đến một kiểu future tự tham chiếu.

Tuy nhiên, hầu hết các kiểu đều hoàn toàn an toàn để di chuyển, ngay cả khi chúng tình cờ đứng sau một wrapper Pin. Chúng ta chỉ cần nghĩ về việc ghim khi các mục có tham chiếu nội bộ. Các giá trị nguyên thủy như số và Boolean là an toàn bởi vì chúng rõ ràng không có bất kỳ tham chiếu nội bộ nào. Hầu hết các kiểu mà bạn thường làm việc trong Rust cũng vậy. Bạn có thể di chuyển một Vec, ví dụ, mà không cần lo lắng. Với những gì chúng ta đã thấy cho đến nay, nếu bạn có một Pin<Vec<String>>, bạn sẽ phải làm mọi thứ thông qua các API an toàn nhưng hạn chế được cung cấp bởi Pin, mặc dù một Vec<String> luôn an toàn để di chuyển nếu không có tham chiếu nào khác đến nó. Chúng ta cần một cách để nói với trình biên dịch rằng việc di chuyển các mục xung quanh trong những trường hợp như thế này là tốt—và đó là nơi Unpin đi vào cuộc chơi.

Unpin là một trait đánh dấu, tương tự như các trait SendSync mà chúng ta đã thấy trong Chương 16, và do đó không có chức năng của riêng nó. Các trait đánh dấu tồn tại chỉ để nói với trình biên dịch rằng việc sử dụng kiểu thực thi một trait nhất định trong một ngữ cảnh cụ thể là an toàn. Unpin thông báo cho trình biên dịch rằng một kiểu nhất định không cần duy trì bất kỳ đảm bảo nào về việc liệu giá trị đang được đề cập có thể được di chuyển an toàn.

Giống như với SendSync, trình biên dịch triển khai Unpin tự động cho tất cả các kiểu mà nó có thể chứng minh là an toàn. Một trường hợp đặc biệt, một lần nữa tương tự như SendSync, là nơi Unpin không được triển khai cho một kiểu. Ký hiệu cho điều này là impl !Unpin for SomeType, trong đó SomeType là tên của một kiểu thực sự cần duy trì những đảm bảo đó để an toàn bất cứ khi nào một con trỏ đến kiểu đó được sử dụng trong một Pin.

Nói cách khác, có hai điều cần ghi nhớ về mối quan hệ giữa PinUnpin. Đầu tiên, Unpin là trường hợp "bình thường", và !Unpin là trường hợp đặc biệt. Thứ hai, liệu một kiểu triển khai Unpin hay !Unpin chỉ quan trọng khi bạn đang sử dụng một con trỏ được ghim cho kiểu đó như Pin<&mut SomeType>.

Để làm cho điều đó cụ thể, hãy nghĩ về một String: nó có một độ dài và các ký tự Unicode tạo nên nó. Chúng ta có thể bọc một String trong Pin, như trong Hình 17-8. Tuy nhiên, String tự động triển khai Unpin, cũng như hầu hết các kiểu khác trong Rust.

Concurrent work flow
Hình 17-8: Ghim một `String`; đường đứt nét chỉ ra rằng `String` triển khai trait `Unpin`, và do đó không được ghim.

Do đó, chúng ta có thể làm những điều mà sẽ bất hợp pháp nếu String triển khai !Unpin thay thế, chẳng hạn như thay thế một chuỗi bằng một chuỗi khác tại chính xác cùng một vị trí trong bộ nhớ như trong Hình 17-9. Điều này không vi phạm hợp đồng Pin, bởi vì String không có tham chiếu nội bộ làm cho nó không an toàn để di chuyển! Đó chính xác là lý do tại sao nó triển khai Unpin chứ không phải !Unpin.

Concurrent work flow
Hình 17-9: Thay thế `String` bằng một `String` hoàn toàn khác trong bộ nhớ.

Bây giờ chúng ta biết đủ để hiểu các lỗi được báo cáo cho lệnh gọi join_all từ Listing 17-17. Ban đầu, chúng ta đã cố gắng di chuyển các future được tạo ra bởi các async block vào một Vec<Box<dyn Future<Output = ()>>>, nhưng như chúng ta đã thấy, những future đó có thể có tham chiếu nội bộ, vì vậy chúng không triển khai Unpin. Chúng cần được ghim, và sau đó chúng ta có thể truyền kiểu Pin vào Vec, tự tin rằng dữ liệu cơ bản trong các future sẽ không bị di chuyển.

PinUnpin chủ yếu quan trọng cho việc xây dựng các thư viện cấp thấp hơn, hoặc khi bạn đang xây dựng chính một runtime, hơn là cho mã Rust hàng ngày. Tuy nhiên, khi bạn thấy các trait này trong thông báo lỗi, bây giờ bạn sẽ có một hiểu biết tốt hơn về cách sửa mã của mình!

Lưu ý: Sự kết hợp này của PinUnpin làm cho nó có thể triển khai an toàn toàn bộ một lớp các kiểu phức tạp trong Rust mà nếu không sẽ chứng minh đầy thách thức bởi vì chúng tự tham chiếu. Các kiểu yêu cầu Pin xuất hiện phổ biến nhất trong async Rust ngày nay, nhưng thỉnh thoảng, bạn có thể thấy chúng trong các ngữ cảnh khác.

Các chi tiết cụ thể về cách PinUnpin hoạt động, và các quy tắc mà chúng được yêu cầu để tuân thủ, được đề cập rộng rãi trong tài liệu API cho std::pin, vì vậy nếu bạn quan tâm đến việc tìm hiểu thêm, đó là một nơi tuyệt vời để bắt đầu.

Nếu bạn muốn hiểu cách mọi thứ hoạt động bên dưới nắp capo một cách chi tiết hơn, hãy xem Chương 24 của Asynchronous Programming in Rust.

Trait Stream

Bây giờ bạn đã có một hiểu biết sâu sắc hơn về các trait Future, Pin, và Unpin, chúng ta có thể chuyển sự chú ý của mình đến trait Stream. Như bạn đã học được trước đó trong chương, streams tương tự như các iterator bất đồng bộ. Không giống như IteratorFuture, tuy nhiên, Stream không có định nghĩa trong thư viện chuẩn vào thời điểm viết bài này, nhưng một định nghĩa rất phổ biến từ crate futures được sử dụng trong toàn bộ hệ sinh thái.

Hãy xem lại các định nghĩa của các trait IteratorFuture trước khi xem xét cách một trait Stream có thể hợp nhất chúng lại với nhau. Từ Iterator, chúng ta có ý tưởng về một chuỗi: phương thức next của nó cung cấp một Option<Self::Item>. Từ Future, chúng ta có ý tưởng về sự sẵn sàng theo thời gian: phương thức poll của nó cung cấp một Poll<Self::Output>. Để đại diện cho một chuỗi các mục trở nên sẵn sàng theo thời gian, chúng ta định nghĩa một trait Stream kết hợp các tính năng đó:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Trait Stream định nghĩa một kiểu kết hợp được gọi là Item cho kiểu của các mục được tạo ra bởi stream. Điều này tương tự như Iterator, nơi có thể có từ không đến nhiều mục, và không giống như Future, nơi luôn có một Output duy nhất, ngay cả khi đó là kiểu đơn vị ().

Stream cũng định nghĩa một phương thức để lấy các mục đó. Chúng ta gọi nó là poll_next, để làm rõ rằng nó poll theo cùng cách Future::poll làm và tạo ra một chuỗi các mục theo cùng cách Iterator::next làm. Kiểu trả về của nó kết hợp Poll với Option. Kiểu bên ngoài là Poll, bởi vì nó phải được kiểm tra sự sẵn sàng, giống như một future. Kiểu bên trong là Option, bởi vì nó cần phải báo hiệu liệu có còn tin nhắn nữa hay không, giống như một iterator làm.

Một thứ gì đó rất giống với định nghĩa này sẽ có khả năng trở thành một phần của thư viện chuẩn của Rust. Trong khi đó, nó là một phần của bộ công cụ của hầu hết các runtime, vì vậy bạn có thể dựa vào nó, và mọi thứ chúng ta đề cập tiếp theo nhìn chung sẽ áp dụng!

Trong ví dụ chúng ta đã thấy trong phần về streaming, tuy nhiên, chúng ta đã không sử dụng poll_next hoặc Stream, mà thay vào đó đã sử dụng nextStreamExt. Chúng ta có thể làm việc trực tiếp với API poll_next bằng cách tự viết các máy trạng thái Stream của chúng ta, tất nhiên, giống như chúng ta có thể làm việc với futures trực tiếp thông qua phương thức poll của chúng. Tuy nhiên, sử dụng await tốt hơn nhiều, và trait StreamExt cung cấp phương thức next để chúng ta có thể làm điều đó:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

Lưu ý: Định nghĩa thực tế mà chúng ta đã sử dụng trước đó trong chương trông hơi khác với điều này, bởi vì nó hỗ trợ các phiên bản của Rust chưa hỗ trợ việc sử dụng các hàm async trong traits. Kết quả là, nó trông như thế này:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Kiểu Next đó là một struct triển khai Future và cho phép chúng ta đặt tên cho lifetime của tham chiếu đến self với Next<'_, Self>, để await có thể làm việc với phương thức này.

Trait StreamExt cũng là nơi ở của tất cả các phương thức thú vị có sẵn để sử dụng với streams. StreamExt được triển khai tự động cho mọi kiểu triển khai Stream, nhưng các trait này được định nghĩa riêng biệt để cho phép cộng đồng lặp lại trên các API tiện lợi mà không ảnh hưởng đến trait nền tảng.

Trong phiên bản của StreamExt được sử dụng trong crate trpl, trait không chỉ định nghĩa phương thức next mà còn cung cấp một triển khai mặc định của next xử lý đúng các chi tiết của việc gọi Stream::poll_next. Điều này có nghĩa là ngay cả khi bạn cần viết kiểu dữ liệu streaming của riêng mình, bạn chỉ phải triển khai Stream, và sau đó bất kỳ ai sử dụng kiểu dữ liệu của bạn có thể sử dụng StreamExt và các phương thức của nó với nó một cách tự động.

Đó là tất cả những gì chúng ta sắp đề cập cho các chi tiết cấp thấp hơn về các trait này. Để kết thúc, hãy xem xét cách các futures (bao gồm streams), tasks, và threads đều phù hợp với nhau!

Tổng hợp tất cả: Futures, Tasks, và Threads

Như chúng ta đã thấy trong Chương 16, threads cung cấp một cách tiếp cận để thực hiện đồng thời. Chúng ta đã thấy một cách tiếp cận khác trong chương này: sử dụng async với futures và streams. Nếu bạn đang tự hỏi khi nào nên chọn phương pháp này thay vì phương pháp kia, câu trả lời là: tùy trường hợp! Và trong nhiều trường hợp, sự lựa chọn không phải là threads hoặc async mà là threads async.

Nhiều hệ điều hành đã cung cấp các mô hình đồng thời dựa trên threading trong hàng thập kỷ qua, và nhiều ngôn ngữ lập trình hỗ trợ chúng do đó. Tuy nhiên, các mô hình này không phải không có những đánh đổi. Trên nhiều hệ điều hành, chúng sử dụng một lượng khá lớn bộ nhớ cho mỗi thread, và chúng đi kèm với một số chi phí cho việc khởi động và tắt. Thread cũng chỉ là một lựa chọn khi hệ điều hành và phần cứng của bạn hỗ trợ chúng. Không giống như máy tính để bàn và di động phổ biến, một số hệ thống nhúng hoàn toàn không có hệ điều hành, vì vậy chúng cũng không có threads.

Mô hình async cung cấp một tập hợp các đánh đổi khác—và cuối cùng là bổ sung. Trong mô hình async, các hoạt động đồng thời không yêu cầu thread riêng của chúng. Thay vào đó, chúng có thể chạy trên các task, như khi chúng ta sử dụng trpl::spawn_task để khởi động công việc từ một hàm đồng bộ trong phần streams. Một task tương tự như một thread, nhưng thay vì được quản lý bởi hệ điều hành, nó được quản lý bởi mã cấp thư viện: runtime.

Trong phần trước, chúng ta đã thấy rằng chúng ta có thể xây dựng một stream bằng cách sử dụng kênh async và tạo ra một task async mà chúng ta có thể gọi từ mã đồng bộ. Chúng ta có thể làm điều tương tự với một thread. Trong Listing 17-40, chúng ta đã sử dụng trpl::spawn_tasktrpl::sleep. Trong Listing 17-41, chúng ta thay thế chúng bằng các API thread::spawnthread::sleep từ thư viện chuẩn trong hàm get_intervals.

extern crate trpl; // required for mdbook test

use std::{pin::pin, thread, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    // This is *not* `trpl::spawn` but `std::thread::spawn`!
    thread::spawn(move || {
        let mut count = 0;
        loop {
            // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`!
            thread::sleep(Duration::from_millis(1));
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}

Nếu bạn chạy mã này, đầu ra giống hệt với của Listing 17-40. Và chú ý có rất ít thay đổi ở đây từ góc độ của mã gọi. Hơn nữa, mặc dù một trong các hàm của chúng ta tạo ra một task async trên runtime và hàm kia tạo ra một thread của hệ điều hành, các stream kết quả không bị ảnh hưởng bởi sự khác biệt.

Mặc dù có những điểm tương đồng, hai cách tiếp cận này hoạt động rất khác nhau, mặc dù chúng ta có thể khó đo lường được điều đó trong ví dụ rất đơn giản này. Chúng ta có thể tạo ra hàng triệu task async trên bất kỳ máy tính cá nhân hiện đại nào. Nếu chúng ta cố gắng làm điều đó với threads, chúng ta sẽ thực sự hết bộ nhớ!

Tuy nhiên, có lý do khiến các API này rất giống nhau. Threads hoạt động như một ranh giới cho các tập hợp các hoạt động đồng bộ; tính đồng thời là có thể giữa các thread. Tasks hoạt động như một ranh giới cho các tập hợp hoạt động bất đồng bộ; tính đồng thời là có thể cả giữatrong các task, bởi vì một task có thể chuyển đổi giữa các future trong phần thân của nó. Cuối cùng, future là đơn vị đồng thời nhỏ nhất của Rust, và mỗi future có thể đại diện cho một cây các future khác. Runtime—cụ thể là trình thực thi của nó—quản lý tasks, và tasks quản lý futures. Theo nghĩa đó, tasks tương tự như các thread nhẹ, được quản lý bởi runtime với các khả năng bổ sung đến từ việc được quản lý bởi runtime thay vì bởi hệ điều hành.

Điều này không có nghĩa là task async luôn tốt hơn threads (hoặc ngược lại). Tính đồng thời với threads theo một số cách là một mô hình lập trình đơn giản hơn tính đồng thời với async. Điều đó có thể là một điểm mạnh hoặc một điểm yếu. Threads hơi "phóng và quên"; chúng không có tương đương tự nhiên với future, vì vậy chúng chỉ đơn giản chạy đến khi hoàn thành mà không bị gián đoạn ngoại trừ bởi chính hệ điều hành. Tức là, chúng không có hỗ trợ tích hợp cho tính đồng thời nội tác vụ như cách futures có. Threads trong Rust cũng không có cơ chế cho việc hủy bỏ—một chủ đề chúng ta chưa đề cập rõ ràng trong chương này nhưng đã được ngụ ý bởi thực tế rằng bất cứ khi nào chúng ta kết thúc một future, trạng thái của nó được dọn dẹp một cách chính xác.

Những hạn chế này cũng khiến threads khó kết hợp hơn futures. Ví dụ, khó khăn hơn nhiều để sử dụng threads để xây dựng các trợ giúp như các phương thức timeoutthrottle mà chúng ta đã xây dựng trước đó trong chương này. Việc futures là các cấu trúc dữ liệu phong phú hơn có nghĩa là chúng có thể được kết hợp với nhau một cách tự nhiên hơn, như chúng ta đã thấy.

Vậy tasks cho chúng ta kiểm soát bổ sung đối với futures, cho phép chúng ta chọn nơi và cách nhóm chúng. Và hóa ra threads và tasks thường hoạt động rất tốt cùng nhau, bởi vì tasks có thể (ít nhất là trong một số runtime) được di chuyển quanh giữa các thread. Thực tế, bên dưới, runtime mà chúng ta đã sử dụng—bao gồm các hàm spawn_blockingspawn_task—là đa luồng theo mặc định! Nhiều runtime sử dụng một cách tiếp cận được gọi là đánh cắp công việc để di chuyển các task một cách trong suốt giữa các thread, dựa trên cách các thread hiện đang được sử dụng, để cải thiện hiệu suất tổng thể của hệ thống. Cách tiếp cận đó thực sự yêu cầu cả threads tasks, và do đó là futures.

Khi suy nghĩ về phương pháp nào để sử dụng khi nào, hãy xem xét những quy tắc chung sau:

  • Nếu công việc rất có thể song song hóa, chẳng hạn như xử lý một lượng lớn dữ liệu mà mỗi phần có thể được xử lý riêng biệt, threads là lựa chọn tốt hơn.
  • Nếu công việc rất đồng thời, chẳng hạn như xử lý tin nhắn từ một loạt nguồn khác nhau có thể đến theo các khoảng thời gian hoặc tốc độ khác nhau, async là lựa chọn tốt hơn.

Và nếu bạn cần cả tính song song và tính đồng thời, bạn không phải chọn giữa threads và async. Bạn có thể sử dụng chúng cùng nhau tự do, để mỗi cái đóng vai trò mà nó làm tốt nhất. Ví dụ, Listing 17-42 hiển thị một ví dụ khá phổ biến về loại kết hợp này trong mã Rust thực tế.

extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::run(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}

Chúng ta bắt đầu bằng cách tạo một kênh async, sau đó tạo ra một thread lấy quyền sở hữu của phía gửi của kênh. Trong thread, chúng ta gửi các số từ 1 đến 10, ngủ một giây giữa mỗi số. Cuối cùng, chúng ta chạy một future được tạo với một async block được truyền vào trpl::run giống như chúng ta đã làm trong suốt chương. Trong future đó, chúng ta đợi những tin nhắn đó, giống như trong các ví dụ truyền tin nhắn khác mà chúng ta đã thấy.

Quay trở lại kịch bản chúng ta đã mở đầu chương, hãy tưởng tượng chạy một tập hợp các tác vụ mã hóa video sử dụng một thread chuyên dụng (bởi vì mã hóa video là tính toán nặng) nhưng thông báo cho UI rằng các hoạt động đó đã hoàn thành với một kênh async. Có vô số ví dụ về các loại kết hợp này trong các trường hợp sử dụng thực tế.

Tóm tắt

Đây không phải là lần cuối bạn sẽ thấy tính đồng thời trong cuốn sách này. Dự án trong Chương 21 sẽ áp dụng các khái niệm này trong một tình huống thực tế hơn so với các ví dụ đơn giản hơn đã thảo luận ở đây và so sánh việc giải quyết vấn đề với threading đối với tasks một cách trực tiếp hơn.

Bất kể bạn chọn cách tiếp cận nào trong số này, Rust cung cấp cho bạn các công cụ bạn cần để viết mã đồng thời an toàn, nhanh chóng— cho dù là cho một máy chủ web thông lượng cao hay một hệ điều hành nhúng.

Tiếp theo, chúng ta sẽ nói về các cách thành ngữ để mô hình hóa vấn đề và cấu trúc giải pháp khi các chương trình Rust của bạn trở nên lớn hơn. Ngoài ra, chúng ta sẽ thảo luận cách các thành ngữ của Rust liên quan đến những thành ngữ mà bạn có thể quen thuộc từ lập trình hướng đối tượng.

Các Tính Năng Lập Trình Hướng Đối Tượng

Lập trình hướng đối tượng (OOP) là một cách mô hình hóa chương trình. Đối tượng như một khái niệm lập trình được giới thiệu trong ngôn ngữ lập trình Simula vào những năm 1960. Những đối tượng đó đã ảnh hưởng đến kiến trúc lập trình của Alan Kay, trong đó các đối tượng truyền thông điệp cho nhau. Để mô tả kiến trúc này, ông đã đặt ra thuật ngữ lập trình hướng đối tượng vào năm 1967. Có nhiều định nghĩa cạnh tranh mô tả OOP là gì, và theo một số định nghĩa này Rust là hướng đối tượng nhưng theo những định nghĩa khác thì không. Trong chương này, chúng ta sẽ khám phá những đặc điểm nhất định thường được coi là hướng đối tượng và cách những đặc điểm đó được chuyển thành Rust theo phong cách đặc trưng. Sau đó, chúng ta sẽ chỉ cho bạn cách triển khai một mẫu thiết kế hướng đối tượng trong Rust và thảo luận về những đánh đổi khi làm như vậy so với việc triển khai một giải pháp sử dụng một số điểm mạnh của Rust.

Đặc điểm của Ngôn ngữ Hướng đối tượng

Không có sự đồng thuận trong cộng đồng lập trình về việc một ngôn ngữ cần có những tính năng gì để được coi là hướng đối tượng. Rust chịu ảnh hưởng từ nhiều mô hình lập trình, bao gồm cả OOP; ví dụ, chúng ta đã khám phá các tính năng từ lập trình hàm trong Chương 13. Có thể nói, các ngôn ngữ OOP chia sẻ một số đặc điểm chung nhất định, cụ thể là đối tượng, tính đóng gói và tính kế thừa. Hãy xem xét ý nghĩa của từng đặc điểm đó và liệu Rust có hỗ trợ chúng hay không.

Đối tượng Chứa Dữ liệu và Hành vi

Cuốn sách Design Patterns: Elements of Reusable Object-Oriented Software của Erich Gamma, Richard Helm, Ralph Johnson, và John Vlissides (Addison-Wesley, 1994), thường được gọi là cuốn sách Gang of Four (Bộ tứ), là một danh mục các mẫu thiết kế hướng đối tượng. Nó định nghĩa OOP theo cách này:

Các chương trình hướng đối tượng được tạo thành từ các đối tượng. Một đối tượng đóng gói cả dữ liệu và các thủ tục hoạt động trên dữ liệu đó. Các thủ tục này thường được gọi là phương thức hoặc hoạt động.

Theo định nghĩa này, Rust là hướng đối tượng: các struct và enum có dữ liệu, và các khối impl cung cấp phương thức cho struct và enum. Mặc dù struct và enum với các phương thức không được gọi là đối tượng, chúng cung cấp cùng một chức năng, theo định nghĩa về đối tượng của Gang of Four.

Tính Đóng gói Ẩn Chi tiết Triển khai

Một khía cạnh khác thường được liên kết với OOP là ý tưởng về tính đóng gói, có nghĩa là chi tiết triển khai của một đối tượng không thể truy cập bởi mã sử dụng đối tượng đó. Do đó, cách duy nhất để tương tác với một đối tượng là thông qua API công khai của nó; mã sử dụng đối tượng không nên có khả năng truy cập vào bên trong đối tượng và thay đổi dữ liệu hoặc hành vi trực tiếp. Điều này cho phép lập trình viên thay đổi và tái cấu trúc bên trong của một đối tượng mà không cần phải thay đổi mã sử dụng đối tượng đó.

Chúng ta đã thảo luận về cách kiểm soát tính đóng gói trong Chương 7: chúng ta có thể sử dụng từ khóa pub để quyết định module, kiểu, hàm và phương thức nào trong mã của chúng ta nên được công khai, và theo mặc định mọi thứ khác đều là riêng tư. Ví dụ, chúng ta có thể định nghĩa một struct AveragedCollection có một trường chứa một vector các giá trị i32. Struct này cũng có thể có một trường chứa giá trị trung bình của các giá trị trong vector, nghĩa là giá trị trung bình không cần phải được tính toán theo yêu cầu mỗi khi ai đó cần nó. Nói cách khác, AveragedCollection sẽ lưu trữ giá trị trung bình đã tính toán cho chúng ta. Listing 18-1 có định nghĩa của struct AveragedCollection:

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

Struct được đánh dấu pub để mã khác có thể sử dụng nó, nhưng các trường trong struct vẫn là riêng tư. Điều này quan trọng trong trường hợp này vì chúng ta muốn đảm bảo rằng bất cứ khi nào một giá trị được thêm vào hoặc xóa khỏi danh sách, giá trị trung bình cũng được cập nhật. Chúng ta thực hiện điều này bằng cách triển khai các phương thức add, removeaverage trên struct, như được hiển thị trong Listing 18-2:

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

Các phương thức công khai add, removeaverage là những cách duy nhất để truy cập hoặc sửa đổi dữ liệu trong một thể hiện của AveragedCollection. Khi một mục được thêm vào list bằng phương thức add hoặc bị xóa bằng phương thức remove, các triển khai của mỗi phương thức gọi phương thức riêng tư update_average xử lý việc cập nhật trường average.

Chúng ta để các trường listaverage là riêng tư để không có cách nào cho mã bên ngoài thêm hoặc xóa các mục vào hoặc khỏi trường list trực tiếp; nếu không, trường average có thể không đồng bộ khi list thay đổi. Phương thức average trả về giá trị trong trường average, cho phép mã bên ngoài đọc average nhưng không thể sửa đổi nó.

Bởi vì chúng ta đã đóng gói các chi tiết triển khai của struct AveragedCollection, chúng ta có thể dễ dàng thay đổi các khía cạnh, chẳng hạn như cấu trúc dữ liệu, trong tương lai. Ví dụ, chúng ta có thể sử dụng HashSet<i32> thay vì Vec<i32> cho trường list. Miễn là chữ ký của các phương thức công khai add, removeaverage không thay đổi, mã sử dụng AveragedCollection sẽ không cần phải thay đổi. Nếu chúng ta làm cho list công khai, điều này không nhất thiết là trường hợp: HashSet<i32>Vec<i32> có các phương thức khác nhau để thêm và xóa các mục, vì vậy mã bên ngoài có thể sẽ phải thay đổi nếu nó đang sửa đổi list trực tiếp.

Nếu tính đóng gói là một khía cạnh bắt buộc để một ngôn ngữ được coi là hướng đối tượng, thì Rust đáp ứng yêu cầu đó. Tùy chọn sử dụng pub hoặc không cho các phần khác nhau của mã cho phép đóng gói các chi tiết triển khai.

Tính kế thừa như một Hệ thống Kiểu và như Chia sẻ Mã

Tính kế thừa là một cơ chế mà một đối tượng có thể kế thừa các phần tử từ định nghĩa của một đối tượng khác, do đó có được dữ liệu và hành vi của đối tượng cha mà không cần phải định nghĩa lại chúng.

Nếu một ngôn ngữ phải có tính kế thừa để được coi là hướng đối tượng, thì Rust không phải là một ngôn ngữ như vậy. Không có cách nào để định nghĩa một struct kế thừa các trường của struct cha và triển khai phương thức mà không sử dụng macro.

Tuy nhiên, nếu bạn quen với việc có tính kế thừa trong bộ công cụ lập trình của mình, bạn có thể sử dụng các giải pháp khác trong Rust, tùy thuộc vào lý do bạn chọn tính kế thừa từ đầu.

Bạn sẽ chọn tính kế thừa vì hai lý do chính. Một là để tái sử dụng mã: bạn có thể triển khai một hành vi cụ thể cho một kiểu, và tính kế thừa cho phép bạn tái sử dụng triển khai đó cho một kiểu khác. Bạn có thể làm điều này một cách hạn chế trong mã Rust bằng cách sử dụng triển khai phương thức trait mặc định, mà bạn đã thấy trong Listing 10-14 khi chúng ta thêm một triển khai mặc định của phương thức summarize trên trait Summary. Bất kỳ kiểu nào triển khai trait Summary sẽ có sẵn phương thức summarize mà không cần thêm mã nào nữa. Điều này tương tự như một lớp cha có một triển khai của một phương thức và một lớp con kế thừa cũng có triển khai của phương thức đó. Chúng ta cũng có thể ghi đè triển khai mặc định của phương thức summarize khi chúng ta triển khai trait Summary, điều này tương tự như một lớp con ghi đè triển khai của một phương thức kế thừa từ lớp cha.

Lý do khác để sử dụng tính kế thừa liên quan đến hệ thống kiểu: để cho phép một kiểu con được sử dụng ở những nơi mà kiểu cha được sử dụng. Điều này còn được gọi là tính đa hình, có nghĩa là bạn có thể thay thế nhiều đối tượng cho nhau trong thời gian chạy nếu chúng chia sẻ một số đặc điểm nhất định.

Tính đa hình

Đối với nhiều người, tính đa hình đồng nghĩa với tính kế thừa. Nhưng thực tế nó là một khái niệm tổng quát hơn, đề cập đến mã có thể làm việc với dữ liệu của nhiều kiểu. Đối với tính kế thừa, những kiểu đó thường là các lớp con.

Thay vào đó, Rust sử dụng generics để trừu tượng hóa các kiểu khác nhau có thể có và các ràng buộc trait để áp đặt các ràng buộc về những gì các kiểu đó phải cung cấp. Điều này đôi khi được gọi là tính đa hình tham số có giới hạn (bounded parametric polymorphism).

Tính kế thừa gần đây đã bị mất đi sự ưa chuộng như một giải pháp thiết kế lập trình trong nhiều ngôn ngữ lập trình vì nó thường có nguy cơ chia sẻ nhiều mã hơn mức cần thiết. Các lớp con không phải lúc nào cũng nên chia sẻ tất cả các đặc điểm của lớp cha của chúng nhưng sẽ làm như vậy với tính kế thừa. Điều này có thể làm cho thiết kế của một chương trình kém linh hoạt hơn. Nó cũng tạo ra khả năng gọi các phương thức trên các lớp con mà không hợp lý hoặc gây ra lỗi vì các phương thức không áp dụng cho lớp con. Ngoài ra, một số ngôn ngữ chỉ cho phép đơn kế thừa (có nghĩa là một lớp con chỉ có thể kế thừa từ một lớp), hạn chế hơn nữa tính linh hoạt của thiết kế chương trình.

Vì những lý do này, Rust sử dụng cách tiếp cận khác bằng cách sử dụng các đối tượng trait thay vì tính kế thừa. Hãy xem cách các đối tượng trait cho phép tính đa hình trong Rust.

Sử dụng Đối tượng Trait Cho phép Các Giá trị của Nhiều Kiểu Khác nhau

Trong Chương 8, chúng ta đã đề cập rằng một hạn chế của vector là chúng chỉ có thể lưu trữ các phần tử của cùng một kiểu. Chúng ta đã tạo ra một giải pháp thay thế trong Listing 8-9, trong đó chúng ta định nghĩa một enum SpreadsheetCell có các biến thể để chứa số nguyên, số thực và văn bản. Điều này có nghĩa là chúng ta có thể lưu trữ các kiểu dữ liệu khác nhau trong mỗi ô và vẫn có một vector đại diện cho một hàng các ô. Đây là một giải pháp hoàn toàn phù hợp khi các mục có thể thay thế của chúng ta là một tập hợp các kiểu cố định mà chúng ta biết khi mã của chúng ta được biên dịch.

Tuy nhiên, đôi khi chúng ta muốn người dùng thư viện của mình có thể mở rộng tập hợp các kiểu hợp lệ trong một tình huống cụ thể. Để minh họa cách chúng ta có thể thực hiện điều này, chúng ta sẽ tạo một ví dụ về công cụ giao diện người dùng đồ họa (GUI) lặp qua danh sách các mục, gọi phương thức draw trên mỗi mục để vẽ nó lên màn hình—một kỹ thuật phổ biến cho các công cụ GUI. Chúng ta sẽ tạo một crate thư viện có tên gui chứa cấu trúc của thư viện GUI. Crate này có thể bao gồm một số kiểu cho mọi người sử dụng, chẳng hạn như Button hoặc TextField. Ngoài ra, người dùng gui sẽ muốn tạo các kiểu riêng của họ có thể được vẽ: ví dụ, một lập trình viên có thể thêm Image và một lập trình viên khác có thể thêm SelectBox.

Chúng ta sẽ không triển khai một thư viện GUI đầy đủ cho ví dụ này, nhưng sẽ cho thấy các phần sẽ kết hợp với nhau như thế nào. Tại thời điểm viết thư viện, chúng ta không thể biết và định nghĩa tất cả các kiểu mà các lập trình viên khác có thể muốn tạo. Nhưng chúng ta biết rằng gui cần theo dõi nhiều giá trị của các kiểu khác nhau và cần gọi phương thức draw trên mỗi giá trị có kiểu khác nhau này. Nó không cần biết chính xác điều gì sẽ xảy ra khi chúng ta gọi phương thức draw, chỉ cần biết rằng giá trị đó sẽ có phương thức sẵn sàng để gọi.

Để thực hiện điều này trong ngôn ngữ có tính kế thừa, chúng ta có thể định nghĩa một lớp có tên Component có phương thức tên là draw. Các lớp khác, chẳng hạn như Button, ImageSelectBox, sẽ kế thừa từ Component và do đó kế thừa phương thức draw. Mỗi lớp có thể ghi đè phương thức draw để định nghĩa hành vi tùy chỉnh của nó, nhưng framework có thể xử lý tất cả các kiểu như thể chúng là các thể hiện của Component và gọi draw trên chúng. Nhưng vì Rust không có tính kế thừa, chúng ta cần một cách khác để cấu trúc thư viện gui để cho phép người dùng mở rộng nó với các kiểu mới.

Định nghĩa một Trait cho Hành vi Chung

Để triển khai hành vi mà chúng ta muốn gui có, chúng ta sẽ định nghĩa một trait có tên Draw có một phương thức tên draw. Sau đó, chúng ta có thể định nghĩa một vector nhận một đối tượng trait. Một đối tượng trait trỏ đến cả một thể hiện của một kiểu triển khai trait được chỉ định của chúng ta và một bảng được sử dụng để tra cứu các phương thức trait trên kiểu đó tại thời điểm chạy. Chúng ta tạo một đối tượng trait bằng cách chỉ định một loại con trỏ, chẳng hạn như tham chiếu & hoặc con trỏ thông minh Box<T>, sau đó là từ khóa dyn, và sau đó chỉ định trait liên quan. (Chúng ta sẽ nói về lý do đối tượng trait phải sử dụng con trỏ trong "Các kiểu Kích thước Động và Trait Sized" trong Chương 20.) Chúng ta có thể sử dụng đối tượng trait thay thế cho kiểu generic hoặc kiểu cụ thể. Bất cứ khi nào chúng ta sử dụng đối tượng trait, hệ thống kiểu của Rust sẽ đảm bảo tại thời điểm biên dịch rằng bất kỳ giá trị nào được sử dụng trong ngữ cảnh đó sẽ triển khai trait của đối tượng trait. Do đó, chúng ta không cần phải biết tất cả các kiểu có thể có tại thời điểm biên dịch.

Chúng ta đã đề cập rằng, trong Rust, chúng ta tránh gọi các struct và enum là "đối tượng" để phân biệt chúng với đối tượng của các ngôn ngữ khác. Trong một struct hoặc enum, dữ liệu trong các trường struct và hành vi trong các khối impl được tách biệt, trong khi ở các ngôn ngữ khác, dữ liệu và hành vi kết hợp thành một khái niệm thường được gọi là đối tượng. Tuy nhiên, đối tượng trait giống hơn với đối tượng trong các ngôn ngữ khác ở chỗ chúng kết hợp dữ liệu và hành vi. Nhưng đối tượng trait khác với đối tượng truyền thống ở chỗ chúng ta không thể thêm dữ liệu vào đối tượng trait. Đối tượng trait không hữu ích một cách tổng quát như đối tượng trong các ngôn ngữ khác: mục đích cụ thể của chúng là cho phép trừu tượng hóa qua hành vi chung.

Listing 18-3 cho thấy cách định nghĩa một trait có tên Draw với một phương thức có tên draw.

pub trait Draw {
    fn draw(&self);
}

Cú pháp này có thể quen thuộc từ các cuộc thảo luận của chúng ta về cách định nghĩa trait trong Chương 10. Tiếp theo là một cú pháp mới: Listing 18-4 định nghĩa một struct có tên Screen chứa một vector có tên components. Vector này có kiểu Box<dyn Draw>, là một đối tượng trait; nó là một đại diện cho bất kỳ kiểu nào bên trong một Box triển khai trait Draw.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Trên struct Screen, chúng ta sẽ định nghĩa một phương thức có tên run sẽ gọi phương thức draw trên mỗi components của nó, như được hiển thị trong Listing 18-5.

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Điều này hoạt động khác với việc định nghĩa một struct sử dụng tham số kiểu generic với ràng buộc trait. Một tham số kiểu generic chỉ có thể được thay thế bằng một kiểu cụ thể tại một thời điểm, trong khi đối tượng trait cho phép nhiều kiểu cụ thể điền vào đối tượng trait tại thời điểm chạy. Ví dụ, chúng ta có thể đã định nghĩa struct Screen sử dụng kiểu generic và ràng buộc trait như trong Listing 18-6:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

Điều này giới hạn chúng ta vào một thể hiện Screen có danh sách các thành phần tất cả đều có kiểu Button hoặc tất cả đều có kiểu TextField. Nếu bạn chỉ muốn có các bộ sưu tập đồng nhất, sử dụng generic và ràng buộc trait là tốt hơn vì các định nghĩa sẽ được đơn hình hóa tại thời điểm biên dịch để sử dụng các kiểu cụ thể.

Mặt khác, với phương pháp sử dụng đối tượng trait, một thể hiện Screen có thể chứa một Vec<T> bao gồm cả Box<Button>Box<TextField>. Hãy xem cách điều này hoạt động, và sau đó chúng ta sẽ nói về các hệ quả hiệu suất thời gian chạy.

Triển khai Trait

Bây giờ chúng ta sẽ thêm một số kiểu triển khai trait Draw. Chúng ta sẽ cung cấp kiểu Button. Một lần nữa, việc thực sự triển khai một thư viện GUI nằm ngoài phạm vi của cuốn sách này, vì vậy phương thức draw sẽ không có bất kỳ triển khai hữu ích nào trong phần thân của nó. Để hình dung triển khai có thể trông như thế nào, một struct Button có thể có các trường cho width, heightlabel, như được hiển thị trong Listing 18-7:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Các trường width, heightlabel trên Button sẽ khác với các trường trên các thành phần khác; ví dụ, một kiểu TextField có thể có những trường giống nhau cộng với trường placeholder. Mỗi kiểu mà chúng ta muốn vẽ trên màn hình sẽ triển khai trait Draw nhưng sẽ sử dụng mã khác nhau trong phương thức draw để định nghĩa cách vẽ kiểu cụ thể đó, như Button đã làm ở đây (không có mã GUI thực tế, như đã đề cập). Kiểu Button, ví dụ, có thể có một khối impl bổ sung chứa các phương thức liên quan đến những gì xảy ra khi người dùng nhấp vào nút. Những loại phương thức này sẽ không áp dụng cho các kiểu như TextField.

Nếu ai đó sử dụng thư viện của chúng ta quyết định triển khai một struct SelectBox có các trường width, heightoptions, họ cũng sẽ triển khai trait Draw trên kiểu SelectBox, như được hiển thị trong Listing 18-8.

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}

Người dùng thư viện của chúng ta bây giờ có thể viết hàm main của họ để tạo một thể hiện Screen. Với thể hiện Screen, họ có thể thêm một SelectBox và một Button bằng cách đặt mỗi cái vào một Box<T> để trở thành một đối tượng trait. Sau đó, họ có thể gọi phương thức run trên thể hiện Screen, sẽ gọi draw trên mỗi thành phần. Listing 18-9 cho thấy triển khai này:

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

Khi chúng ta viết thư viện, chúng ta không biết rằng ai đó có thể thêm kiểu SelectBox, nhưng triển khai Screen của chúng ta có thể hoạt động với kiểu mới và vẽ nó vì SelectBox triển khai trait Draw, có nghĩa là nó triển khai phương thức draw.

Khái niệm này—chỉ quan tâm đến các thông điệp mà một giá trị phản hồi thay vì kiểu cụ thể của giá trị—tương tự như khái niệm duck typing trong các ngôn ngữ kiểu động: nếu nó đi như một con vịt và kêu quạc quạc như một con vịt, thì nó phải là một con vịt! Trong triển khai của run trên Screen trong Listing 18-5, run không cần biết kiểu cụ thể của mỗi thành phần là gì. Nó không kiểm tra liệu một thành phần có phải là một thể hiện của Button hay SelectBox, nó chỉ gọi phương thức draw trên thành phần. Bằng cách chỉ định Box<dyn Draw> là kiểu của các giá trị trong vector components, chúng ta đã định nghĩa Screen cần các giá trị mà chúng ta có thể gọi phương thức draw trên đó.

Lợi thế của việc sử dụng đối tượng trait và hệ thống kiểu của Rust để viết mã tương tự như mã sử dụng duck typing là chúng ta không bao giờ phải kiểm tra liệu một giá trị có triển khai một phương thức cụ thể nào đó tại thời điểm chạy hoặc lo lắng về việc gặp lỗi nếu một giá trị không triển khai một phương thức nhưng chúng ta vẫn gọi nó. Rust sẽ không biên dịch mã của chúng ta nếu các giá trị không triển khai các trait mà đối tượng trait cần.

Ví dụ, Listing 18-10 cho thấy điều gì xảy ra nếu chúng ta cố gắng tạo một Screen với một String làm thành phần.

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

Chúng ta sẽ nhận được lỗi này vì String không triển khai trait Draw:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

Lỗi này cho chúng ta biết rằng hoặc là chúng ta đang truyền một thứ gì đó cho Screen mà chúng ta không có ý định truyền và do đó nên truyền một kiểu khác, hoặc chúng ta nên triển khai Draw trên String để Screen có thể gọi draw trên nó.

Đối tượng Trait Thực hiện Điều phối Động

Nhớ lại trong "Hiệu suất của Mã Sử dụng Generic" trong Chương 10, chúng ta đã thảo luận về quá trình đơn hình hóa được thực hiện trên generic bởi trình biên dịch: trình biên dịch tạo ra các triển khai không generic của các hàm và phương thức cho mỗi kiểu cụ thể mà chúng ta sử dụng thay thế cho tham số kiểu generic. Mã kết quả từ quá trình đơn hình hóa đang thực hiện điều phối tĩnh, đó là khi trình biên dịch biết phương thức nào bạn đang gọi tại thời điểm biên dịch. Điều này trái ngược với điều phối động, là khi trình biên dịch không thể biết tại thời điểm biên dịch phương thức nào bạn đang gọi. Trong các trường hợp điều phối động, trình biên dịch tạo ra mã mà tại thời điểm chạy sẽ tìm ra phương thức nào để gọi.

Khi chúng ta sử dụng đối tượng trait, Rust phải sử dụng điều phối động. Trình biên dịch không biết tất cả các kiểu có thể được sử dụng với mã đang sử dụng đối tượng trait, vì vậy nó không biết phương thức nào được triển khai trên kiểu nào để gọi. Thay vào đó, tại thời điểm chạy, Rust sử dụng các con trỏ bên trong đối tượng trait để biết phương thức nào cần gọi. Việc tra cứu này phát sinh chi phí thời gian chạy không xảy ra với điều phối tĩnh. Điều phối động cũng ngăn trình biên dịch chọn để nội tuyến mã của một phương thức, từ đó ngăn chặn một số tối ưu hóa, và Rust có một số quy tắc, được gọi là khả năng tương thích dyn, về nơi bạn có thể và không thể sử dụng điều phối động. Những quy tắc đó nằm ngoài phạm vi của cuộc thảo luận này, nhưng bạn có thể đọc thêm về chúng trong tài liệu tham khảo. Tuy nhiên, chúng ta đã có được sự linh hoạt bổ sung trong mã mà chúng ta đã viết trong Listing 18-5 và có thể hỗ trợ trong Listing 18-9, vì vậy đó là một sự đánh đổi cần cân nhắc.

Triển khai một Mẫu Thiết kế Hướng đối tượng

Mẫu trạng thái (state pattern) là một mẫu thiết kế hướng đối tượng. Trọng tâm của mẫu này là chúng ta định nghĩa một tập hợp các trạng thái mà một giá trị có thể có bên trong. Các trạng thái được biểu diễn bởi một tập hợp các đối tượng trạng thái, và hành vi của giá trị thay đổi dựa trên trạng thái của nó. Chúng ta sẽ tiến hành thực hiện một ví dụ về một struct bài đăng blog có một trường để lưu trữ trạng thái của nó, trường này sẽ là một đối tượng trạng thái từ tập hợp "nháp", "đang xét duyệt", hoặc "đã xuất bản".

Các đối tượng trạng thái chia sẻ chức năng: trong Rust, tất nhiên, chúng ta sử dụng struct và trait thay vì đối tượng và tính kế thừa. Mỗi đối tượng trạng thái chịu trách nhiệm cho hành vi của chính nó và cho việc quản lý khi nào nó nên chuyển sang trạng thái khác. Giá trị chứa đối tượng trạng thái không biết gì về các hành vi khác nhau của các trạng thái hoặc khi nào chuyển đổi giữa các trạng thái.

Lợi thế của việc sử dụng mẫu trạng thái là khi các yêu cầu nghiệp vụ của chương trình thay đổi, chúng ta sẽ không cần phải thay đổi mã của giá trị đang giữ trạng thái hoặc mã sử dụng giá trị đó. Chúng ta sẽ chỉ cần cập nhật mã bên trong một trong các đối tượng trạng thái để thay đổi quy tắc của nó hoặc có thể thêm nhiều đối tượng trạng thái hơn.

Đầu tiên, chúng ta sẽ triển khai mẫu trạng thái theo cách hướng đối tượng truyền thống hơn, sau đó chúng ta sẽ sử dụng một cách tiếp cận tự nhiên hơn một chút trong Rust. Hãy đi sâu vào việc triển khai dần dần một quy trình làm việc của bài đăng blog bằng cách sử dụng mẫu trạng thái.

Chức năng cuối cùng sẽ trông như thế này:

  1. Một bài đăng blog bắt đầu như một bản nháp trống.
  2. Khi bản nháp hoàn thành, một đánh giá của bài đăng được yêu cầu.
  3. Khi bài đăng được phê duyệt, nó được xuất bản.
  4. Chỉ có các bài đăng blog đã xuất bản mới trả về nội dung để in, vì vậy các bài đăng chưa được phê duyệt không thể vô tình được xuất bản.

Bất kỳ thay đổi nào khác được thử trên một bài đăng sẽ không có hiệu lực. Ví dụ, nếu chúng ta cố gắng phê duyệt một bài đăng blog nháp trước khi chúng ta yêu cầu đánh giá, bài đăng vẫn sẽ là một bản nháp chưa xuất bản.

Danh sách 18-11 hiển thị quy trình làm việc này dưới dạng mã: đây là một ví dụ về việc sử dụng API mà chúng ta sẽ triển khai trong một thư viện crate có tên là blog. Điều này sẽ không biên dịch được vì chúng ta chưa triển khai crate blog.

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Chúng ta muốn cho phép người dùng tạo một bài đăng blog nháp mới với Post::new. Chúng ta muốn cho phép thêm văn bản vào bài đăng blog. Nếu chúng ta cố gắng lấy nội dung của bài đăng ngay lập tức, trước khi phê duyệt, chúng ta sẽ không nhận được bất kỳ văn bản nào vì bài đăng vẫn đang ở trạng thái nháp. Chúng ta đã thêm assert_eq! trong mã để minh họa. Một bài kiểm tra đơn vị xuất sắc cho trường hợp này sẽ khẳng định rằng một bài đăng blog nháp trả về một chuỗi rỗng từ phương thức content, nhưng chúng ta sẽ không viết kiểm tra cho ví dụ này.

Tiếp theo, chúng ta muốn cho phép yêu cầu đánh giá bài đăng, và chúng ta muốn content trả về một chuỗi rỗng trong khi chờ đánh giá. Khi bài đăng nhận được sự chấp thuận, nó sẽ được xuất bản, nghĩa là văn bản của bài đăng sẽ được trả về khi gọi content.

Lưu ý rằng kiểu duy nhất mà chúng ta tương tác từ crate là kiểu Post. Kiểu này sẽ sử dụng mẫu trạng thái và sẽ chứa một giá trị sẽ là một trong ba đối tượng trạng thái đại diện cho các trạng thái khác nhau mà một bài đăng có thể có—nháp, đang xét duyệt, hoặc đã xuất bản. Việc thay đổi từ trạng thái này sang trạng thái khác sẽ được quản lý nội bộ trong kiểu Post. Các trạng thái thay đổi để đáp ứng với các phương thức được gọi bởi người dùng thư viện của chúng ta trên thực thể Post, nhưng họ không phải quản lý các thay đổi trạng thái trực tiếp. Ngoài ra, người dùng không thể mắc lỗi với các trạng thái, chẳng hạn như xuất bản một bài đăng trước khi nó được xem xét.

Định nghĩa Post và Tạo một Thực thể Mới ở Trạng thái Nháp

Hãy bắt đầu với việc triển khai thư viện! Chúng ta biết rằng chúng ta cần một struct Post công khai chứa một số nội dung, vì vậy chúng ta sẽ bắt đầu với định nghĩa của struct và một hàm new công khai liên quan để tạo một thực thể của Post, như trong Danh sách 18-12. Chúng ta cũng sẽ tạo một trait State riêng tư sẽ định nghĩa hành vi mà tất cả các đối tượng trạng thái cho một Post phải có.

Sau đó, Post sẽ chứa một đối tượng trait của Box<dyn State> bên trong một Option<T> trong một trường riêng tư có tên là state để lưu trữ đối tượng trạng thái. Bạn sẽ thấy tại sao Option<T> là cần thiết sau một chút.

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

Trait State định nghĩa hành vi được chia sẻ bởi các trạng thái bài đăng khác nhau. Các đối tượng trạng thái là Draft, PendingReview, và Published, và tất cả chúng sẽ triển khai trait State. Hiện tại, trait không có bất kỳ phương thức nào, và chúng ta sẽ bắt đầu bằng cách định nghĩa chỉ trạng thái Draft vì đó là trạng thái mà chúng ta muốn một bài đăng bắt đầu.

Khi chúng ta tạo một Post mới, chúng ta đặt trường state của nó thành một giá trị Some chứa một Box. Box này trỏ đến một thực thể mới của struct Draft. Điều này đảm bảo rằng bất cứ khi nào chúng ta tạo một thực thể mới của Post, nó sẽ bắt đầu như một bản nháp. Vì trường state của Post là riêng tư, không có cách nào để tạo một Post ở bất kỳ trạng thái nào khác! Trong hàm Post::new, chúng ta đặt trường content thành một String rỗng, mới.

Lưu trữ Văn bản của Nội dung Bài đăng

Chúng ta đã thấy trong Danh sách 18-11 rằng chúng ta muốn có thể gọi một phương thức có tên là add_text và truyền cho nó một &str để sau đó được thêm vào như là nội dung văn bản của bài đăng blog. Chúng ta triển khai điều này như một phương thức, thay vì để lộ trường content dưới dạng pub, để sau này chúng ta có thể triển khai một phương thức sẽ kiểm soát cách dữ liệu của trường content được đọc. Phương thức add_text khá đơn giản, vì vậy hãy thêm triển khai trong Danh sách 18-13 vào khối impl Post.

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

Phương thức add_text lấy một tham chiếu có thể thay đổi đến self vì chúng ta đang thay đổi thực thể Post mà chúng ta đang gọi add_text trên đó. Sau đó, chúng ta gọi push_str trên String trong content và truyền đối số text để thêm vào content đã lưu. Hành vi này không phụ thuộc vào trạng thái mà bài đăng đang ở, vì vậy nó không phải là một phần của mẫu trạng thái. Phương thức add_text không tương tác với trường state chút nào, nhưng nó là một phần của hành vi mà chúng ta muốn hỗ trợ.

Đảm bảo Nội dung của một Bài đăng Nháp là Trống

Ngay cả sau khi chúng ta đã gọi add_text và thêm một số nội dung vào bài đăng của chúng ta, chúng ta vẫn muốn phương thức content trả về một lát cắt chuỗi rỗng vì bài đăng vẫn đang ở trạng thái nháp, như được hiển thị trên dòng 7 của Danh sách 18-11. Hiện tại, hãy triển khai phương thức content với thứ đơn giản nhất sẽ hoàn thành yêu cầu này: luôn trả về một lát cắt chuỗi rỗng. Chúng ta sẽ thay đổi điều này sau khi chúng ta triển khai khả năng thay đổi trạng thái của một bài đăng để nó có thể được xuất bản. Cho đến nay, các bài đăng chỉ có thể ở trạng thái nháp, vì vậy nội dung bài đăng sẽ luôn trống. Danh sách 18-14 hiển thị triển khai giữ chỗ này.

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

Với phương thức content đã thêm này, mọi thứ trong Danh sách 18-11 cho đến dòng 7 hoạt động như dự định.

Yêu cầu Đánh giá Thay đổi Trạng thái của Bài đăng

Tiếp theo, chúng ta cần thêm chức năng để yêu cầu đánh giá một bài đăng, điều này sẽ thay đổi trạng thái của nó từ Draft sang PendingReview. Danh sách 18-15 hiển thị mã này.

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

Chúng ta cung cấp cho Post một phương thức công khai có tên là request_review sẽ lấy một tham chiếu có thể thay đổi đến self. Sau đó, chúng ta gọi một phương thức request_review nội bộ trên trạng thái hiện tại của Post, và phương thức request_review thứ hai này tiêu thụ trạng thái hiện tại và trả về một trạng thái mới.

Chúng ta thêm phương thức request_review vào trait State; tất cả các kiểu triển khai trait sẽ cần phải triển khai phương thức request_review. Lưu ý rằng thay vì có self, &self, hoặc &mut self làm tham số đầu tiên của phương thức, chúng ta có self: Box<Self>. Cú pháp này có nghĩa là phương thức chỉ hợp lệ khi được gọi trên một Box chứa kiểu. Cú pháp này lấy quyền sở hữu của Box<Self>, làm mất hiệu lực trạng thái cũ để giá trị trạng thái của Post có thể chuyển đổi thành một trạng thái mới.

Để tiêu thụ trạng thái cũ, phương thức request_review cần lấy quyền sở hữu của giá trị trạng thái. Đây là nơi mà Option trong trường state của Post xuất hiện: chúng ta gọi phương thức take để lấy giá trị Some ra khỏi trường state và để lại một None trong vị trí của nó vì Rust không cho phép chúng ta có các trường không được điền trong struct. Điều này cho phép chúng ta di chuyển giá trị state ra khỏi Post thay vì mượn nó. Sau đó, chúng ta sẽ đặt giá trị state của bài đăng thành kết quả của thao tác này.

Chúng ta cần đặt state thành None tạm thời thay vì đặt nó trực tiếp với mã như self.state = self.state.request_review(); để lấy quyền sở hữu của giá trị state. Điều này đảm bảo Post không thể sử dụng giá trị state cũ sau khi chúng ta đã chuyển đổi nó thành một trạng thái mới.

Phương thức request_review trên Draft trả về một thực thể mới, được đóng hộp của một struct PendingReview mới, đại diện cho trạng thái khi một bài đăng đang chờ đánh giá. Struct PendingReview cũng triển khai phương thức request_review nhưng không thực hiện bất kỳ chuyển đổi nào. Thay vào đó, nó trả về chính nó vì khi chúng ta yêu cầu đánh giá một bài đăng đã ở trạng thái PendingReview, nó nên tiếp tục ở trạng thái PendingReview.

Bây giờ chúng ta có thể bắt đầu thấy những lợi thế của mẫu trạng thái: phương thức request_review trên Post giống nhau bất kể giá trị state của nó là gì. Mỗi trạng thái chịu trách nhiệm cho các quy tắc của riêng nó.

Chúng ta sẽ để phương thức content trên Post như hiện tại, trả về một lát cắt chuỗi rỗng. Bây giờ chúng ta có thể có một Post ở trạng thái PendingReview cũng như ở trạng thái Draft, nhưng chúng ta muốn có hành vi giống nhau ở trạng thái PendingReview. Danh sách 18-11 hiện hoạt động cho đến dòng 10!

Thêm approve để Thay đổi Hành vi của content

Phương thức approve sẽ tương tự như phương thức request_review: nó sẽ đặt state thành giá trị mà trạng thái hiện tại nói rằng nó nên có khi trạng thái đó được phê duyệt, như được hiển thị trong Danh sách 18-16:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

Chúng ta thêm phương thức approve vào trait State và thêm một struct mới triển khai State, trạng thái Published.

Tương tự như cách request_review trên PendingReview hoạt động, nếu chúng ta gọi phương thức approve trên một Draft, nó sẽ không có hiệu lực gì vì approve sẽ trả về self. Khi chúng ta gọi approve trên PendingReview, nó trả về một thực thể mới, được đóng hộp của struct Published. Struct Published triển khai trait State, và đối với cả phương thức request_review và phương thức approve, nó trả về chính nó, vì bài đăng nên ở trong trạng thái Published trong các trường hợp đó.

Bây giờ chúng ta cần cập nhật phương thức content trên Post. Chúng ta muốn giá trị trả về từ content phụ thuộc vào trạng thái hiện tại của Post, vì vậy chúng ta sẽ để Post ủy quyền cho một phương thức content được định nghĩa trên state của nó, như được hiển thị trong Danh sách 18-17:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

Vì mục tiêu là giữ tất cả các quy tắc này bên trong các struct triển khai State, chúng ta gọi một phương thức content trên giá trị trong state và truyền thực thể bài đăng (tức là self) như một đối số. Sau đó, chúng ta trả về giá trị được trả về từ việc sử dụng phương thức content trên giá trị state.

Chúng ta gọi phương thức as_ref trên Option vì chúng ta muốn một tham chiếu đến giá trị bên trong Option chứ không phải quyền sở hữu của giá trị. Vì state là một Option<Box<dyn State>>, khi chúng ta gọi as_ref, một Option<&Box<dyn State>> được trả về. Nếu chúng ta không gọi as_ref, chúng ta sẽ gặp lỗi vì chúng ta không thể di chuyển state ra khỏi &self được mượn của tham số hàm.

Sau đó, chúng ta gọi phương thức unwrap, mà chúng ta biết sẽ không bao giờ gây panic, vì chúng ta biết các phương thức trên Post đảm bảo rằng state sẽ luôn chứa một giá trị Some khi các phương thức đó hoàn thành. Đây là một trong những trường hợp chúng ta đã nói đến trong "Các trường hợp Trong Đó Bạn Có Thêm Thông tin Hơn Trình biên dịch" trong Chương 9 khi chúng ta biết rằng một giá trị None không bao giờ có thể xảy ra, mặc dù trình biên dịch không thể hiểu điều đó.

Ở thời điểm này, khi chúng ta gọi content trên &Box<dyn State>, ép buộc giải tham chiếu sẽ có hiệu lực trên &Box để phương thức content cuối cùng sẽ được gọi trên kiểu triển khai trait State. Điều đó có nghĩa là chúng ta cần thêm content vào định nghĩa trait State, và đó là nơi chúng ta sẽ đặt logic cho nội dung nào sẽ trả về tùy thuộc vào trạng thái nào chúng ta có, như được hiển thị trong Danh sách 18-18:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

Chúng ta thêm một triển khai mặc định cho phương thức content trả về một lát cắt chuỗi rỗng. Điều đó có nghĩa là chúng ta không cần triển khai content trên các struct DraftPendingReview. Struct Published sẽ ghi đè phương thức content và trả về giá trị trong post.content.

Lưu ý rằng chúng ta cần chú thích thời gian sống cho phương thức này, như chúng ta đã thảo luận trong Chương 10. Chúng ta đang lấy một tham chiếu đến một post làm đối số và trả về một tham chiếu đến một phần của post đó, vì vậy thời gian sống của tham chiếu trả về có liên quan đến thời gian sống của đối số post.

Và chúng ta đã hoàn thành—tất cả Danh sách 18-11 hiện hoạt động! Chúng ta đã triển khai mẫu trạng thái với các quy tắc của quy trình làm việc bài đăng blog. Logic liên quan đến các quy tắc nằm trong các đối tượng trạng thái thay vì bị phân tán khắp Post.

Tại sao Không Phải Một Enum?

Bạn có thể đã tự hỏi tại sao chúng ta không sử dụng một enum với các biến thể trạng thái bài đăng khác nhau. Đó chắc chắn là một giải pháp có thể; hãy thử nó và so sánh kết quả cuối cùng để xem bạn thích cái nào hơn! Một nhược điểm của việc sử dụng enum là mỗi nơi kiểm tra giá trị của enum sẽ cần một biểu thức match hoặc tương tự để xử lý mọi biến thể có thể. Điều này có thể trở nên lặp lại hơn so với giải pháp đối tượng trait này.

Nhược điểm của Mẫu Trạng thái

Chúng ta đã chỉ ra rằng Rust có khả năng triển khai mẫu trạng thái hướng đối tượng để đóng gói các loại hành vi khác nhau mà một bài đăng nên có trong mỗi trạng thái. Các phương thức trên Post không biết gì về các hành vi khác nhau. Cách chúng ta tổ chức mã, chúng ta chỉ phải nhìn vào một nơi để biết các cách khác nhau mà một bài đăng đã xuất bản có thể hành xử: triển khai của trait State trên struct Published.

Nếu chúng ta tạo một triển khai thay thế không sử dụng mẫu trạng thái, thay vào đó chúng ta có thể sử dụng các biểu thức match trong các phương thức trên Post hoặc thậm chí trong mã main kiểm tra trạng thái của bài đăng và thay đổi hành vi ở những nơi đó. Điều đó có nghĩa là chúng ta sẽ phải nhìn vào nhiều nơi để hiểu tất cả các hàm ý của việc một bài đăng ở trạng thái đã xuất bản! Điều này sẽ chỉ tăng lên khi chúng ta thêm nhiều trạng thái hơn: mỗi biểu thức match đó sẽ cần thêm một nhánh.

Với mẫu trạng thái, các phương thức Post và những nơi chúng ta sử dụng Post không cần các biểu thức match, và để thêm một trạng thái mới, chúng ta chỉ cần thêm một struct mới và triển khai các phương thức trait trên struct đó.

Việc triển khai sử dụng mẫu trạng thái rất dễ mở rộng để thêm chức năng. Để thấy sự đơn giản của việc duy trì mã sử dụng mẫu trạng thái, hãy thử một vài gợi ý này:

  • Thêm một phương thức reject thay đổi trạng thái của bài đăng từ PendingReview trở lại Draft.
  • Yêu cầu hai lần gọi approve trước khi trạng thái có thể được thay đổi thành Published.
  • Chỉ cho phép người dùng thêm nội dung văn bản khi bài đăng ở trạng thái Draft. Gợi ý: để đối tượng trạng thái chịu trách nhiệm cho những gì có thể thay đổi về nội dung nhưng không chịu trách nhiệm sửa đổi Post.

Một nhược điểm của mẫu trạng thái là, vì các trạng thái triển khai các chuyển đổi giữa các trạng thái, một số trạng thái được kết nối với nhau. Nếu chúng ta thêm một trạng thái khác giữa PendingReviewPublished, chẳng hạn như Scheduled, chúng ta sẽ phải thay đổi mã trong PendingReview để chuyển sang Scheduled thay vì Published. Sẽ ít công việc hơn nếu PendingReview không cần thay đổi với việc thêm một trạng thái mới, nhưng điều đó có nghĩa là chuyển sang một mẫu thiết kế khác.

Một nhược điểm khác là chúng ta đã lặp lại một số logic. Để loại bỏ một số sự lặp lại, chúng ta có thể thử tạo các triển khai mặc định cho các phương thức request_reviewapprove trên trait State trả về self; tuy nhiên, điều này sẽ không hoạt động: khi sử dụng State như một đối tượng trait, trait không biết self cụ thể sẽ là gì chính xác, vì vậy kiểu trả về không được biết tại thời điểm biên dịch. (Đây là một trong những quy tắc tương thích dyn được đề cập trước đó.)

Sự lặp lại khác bao gồm các triển khai tương tự của các phương thức request_reviewapprove trên Post. Cả hai phương thức đều sử dụng Option::take với trường state của Post, và nếu stateSome, chúng ủy quyền cho triển khai của cùng một phương thức trong giá trị được bọc và đặt giá trị mới của trường state thành kết quả. Nếu chúng ta có nhiều phương thức trên Post tuân theo mẫu này, chúng ta có thể cân nhắc định nghĩa một macro để loại bỏ sự lặp lại (xem "Macro" trong Chương 20).

Bằng cách triển khai mẫu trạng thái chính xác như được định nghĩa cho các ngôn ngữ hướng đối tượng, chúng ta không tận dụng hết các điểm mạnh của Rust như chúng ta có thể. Hãy xem xét một số thay đổi chúng ta có thể thực hiện đối với crate blog có thể biến các trạng thái và chuyển đổi không hợp lệ thành các lỗi thời điểm biên dịch.

Mã hóa Trạng thái và Hành vi dưới dạng Kiểu

Chúng ta sẽ chỉ cho bạn cách suy nghĩ lại về mẫu trạng thái để có được một tập hợp các đánh đổi khác. Thay vì đóng gói hoàn toàn các trạng thái và chuyển đổi để mã bên ngoài không có kiến thức về chúng, chúng ta sẽ mã hóa các trạng thái thành các kiểu khác nhau. Do đó, hệ thống kiểm tra kiểu của Rust sẽ ngăn chặn các nỗ lực sử dụng bài đăng nháp ở những nơi chỉ có bài đăng đã xuất bản được phép bằng cách đưa ra lỗi trình biên dịch.

Hãy xem xét phần đầu tiên của main trong Danh sách 18-11:

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Chúng ta vẫn cho phép tạo bài đăng mới ở trạng thái nháp bằng Post::new và khả năng thêm văn bản vào nội dung của bài đăng. Nhưng thay vì có một phương thức content trên bài đăng nháp trả về một chuỗi rỗng, chúng ta sẽ làm cho bài đăng nháp không có phương thức content chút nào. Bằng cách đó, nếu chúng ta cố gắng lấy nội dung của một bài đăng nháp, chúng ta sẽ nhận được lỗi trình biên dịch cho chúng ta biết rằng phương thức không tồn tại. Kết quả là, sẽ không thể cho chúng ta vô tình hiển thị nội dung bài đăng nháp trong sản xuất vì mã đó thậm chí sẽ không biên dịch. Danh sách 18-19 hiển thị định nghĩa của một struct Post và một struct DraftPost, cũng như các phương thức trên mỗi struct.

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

Cả hai struct PostDraftPost đều có một trường riêng tư content lưu trữ văn bản của bài đăng blog. Các struct không còn có trường state vì chúng ta đang di chuyển việc mã hóa trạng thái vào các kiểu của struct. Struct Post sẽ đại diện cho một bài đăng đã xuất bản, và nó có một phương thức content trả về content.

Chúng ta vẫn có một hàm Post::new, nhưng thay vì trả về một thực thể của Post, nó trả về một thực thể của DraftPost. Vì content là riêng tư và không có bất kỳ hàm nào trả về Post, hiện tại không thể tạo một thực thể của Post.

Struct DraftPost có một phương thức add_text, vì vậy chúng ta có thể thêm văn bản vào content như trước, nhưng lưu ý rằng DraftPost không có phương thức content được định nghĩa! Vì vậy, bây giờ chương trình đảm bảo tất cả các bài đăng bắt đầu là bài đăng nháp, và bài đăng nháp không có nội dung của chúng sẵn có để hiển thị. Bất kỳ nỗ lực nào để vượt qua các ràng buộc này sẽ dẫn đến lỗi trình biên dịch.

Triển khai Chuyển đổi dưới dạng Chuyển đổi thành Các Kiểu Khác nhau

Vậy làm thế nào để chúng ta có được một bài đăng đã xuất bản? Chúng ta muốn thực thi quy tắc rằng một bài đăng nháp phải được xem xét và phê duyệt trước khi nó có thể được xuất bản. Một bài đăng trong trạng thái chờ xem xét vẫn không nên hiển thị bất kỳ nội dung nào. Hãy triển khai các ràng buộc này bằng cách thêm một struct khác, PendingReviewPost, định nghĩa phương thức request_review trên DraftPost để trả về một PendingReviewPost và định nghĩa một phương thức approve trên PendingReviewPost để trả về một Post, như được hiển thị trong Danh sách 18-20.

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}

Các phương thức request_reviewapprove lấy quyền sở hữu của self, do đó tiêu thụ các thực thể DraftPostPendingReviewPost và chuyển đổi chúng thành một PendingReviewPost và một Post đã xuất bản, tương ứng. Bằng cách này, chúng ta sẽ không có bất kỳ thực thể DraftPost nào còn tồn tại sau khi chúng ta đã gọi request_review trên chúng, và tương tự. Struct PendingReviewPost không có phương thức content được định nghĩa trên nó, vì vậy việc cố gắng đọc nội dung của nó dẫn đến lỗi trình biên dịch, cũng giống như với DraftPost. Vì cách duy nhất để có được một thực thể Post đã xuất bản có phương thức content được định nghĩa là gọi phương thức approve trên một PendingReviewPost, và cách duy nhất để có được một PendingReviewPost là gọi phương thức request_review trên một DraftPost, chúng ta đã mã hóa quy trình làm việc của bài đăng blog vào hệ thống kiểu.

Nhưng chúng ta cũng phải thực hiện một số thay đổi nhỏ đối với main. Các phương thức request_reviewapprove trả về các thực thể mới thay vì sửa đổi struct mà chúng được gọi trên, vì vậy chúng ta cần thêm nhiều phép gán let post = che khuất để lưu các thực thể trả về. Chúng ta cũng không thể có các xác nhận về nội dung của bài đăng nháp và bài đăng đang chờ xem xét là các chuỗi rỗng, và chúng ta cũng không cần chúng: chúng ta không thể biên dịch mã cố gắng sử dụng nội dung của bài đăng ở những trạng thái đó nữa. Mã đã cập nhật trong main được hiển thị trong Danh sách 18-21.

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

Các thay đổi chúng ta cần thực hiện đối với main để gán lại post có nghĩa là việc triển khai này không hoàn toàn tuân theo mẫu trạng thái hướng đối tượng nữa: các chuyển đổi giữa các trạng thái không còn được đóng gói hoàn toàn trong triển khai Post. Tuy nhiên, lợi ích của chúng ta là các trạng thái không hợp lệ hiện không thể xảy ra nhờ vào hệ thống kiểu và việc kiểm tra kiểu xảy ra tại thời điểm biên dịch! Điều này đảm bảo rằng các lỗi nhất định, chẳng hạn như hiển thị nội dung của một bài đăng chưa xuất bản, sẽ được phát hiện trước khi chúng xuất hiện trong sản xuất.

Thử các nhiệm vụ được đề xuất ở đầu phần này trên crate blog như nó là sau Danh sách 18-21 để xem bạn nghĩ gì về thiết kế của phiên bản mã này. Lưu ý rằng một số nhiệm vụ có thể đã được hoàn thành trong thiết kế này.

Chúng ta đã thấy rằng mặc dù Rust có khả năng triển khai các mẫu thiết kế hướng đối tượng, các mẫu khác, chẳng hạn như mã hóa trạng thái vào hệ thống kiểu, cũng có sẵn trong Rust. Các mẫu này có các đánh đổi khác nhau. Mặc dù bạn có thể rất quen thuộc với các mẫu hướng đối tượng, việc suy nghĩ lại về vấn đề để tận dụng các tính năng của Rust có thể mang lại lợi ích, chẳng hạn như ngăn chặn một số lỗi tại thời điểm biên dịch. Các mẫu hướng đối tượng sẽ không phải lúc nào cũng là giải pháp tốt nhất trong Rust do một số tính năng nhất định, như quyền sở hữu, mà các ngôn ngữ hướng đối tượng không có.

Tóm tắt

Bất kể việc bạn có cho rằng Rust là một ngôn ngữ hướng đối tượng sau khi đọc chương này hay không, bây giờ bạn biết rằng bạn có thể sử dụng các đối tượng trait để có được một số tính năng hướng đối tượng trong Rust. Điều phối động có thể cung cấp cho mã của bạn một số tính linh hoạt để đổi lấy một chút hiệu suất thời gian chạy. Bạn có thể sử dụng tính linh hoạt này để triển khai các mẫu hướng đối tượng có thể giúp cải thiện khả năng bảo trì của mã. Rust cũng có các tính năng khác, như quyền sở hữu, mà các ngôn ngữ hướng đối tượng không có. Một mẫu hướng đối tượng sẽ không phải lúc nào cũng là cách tốt nhất để tận dụng các điểm mạnh của Rust, nhưng đó là một tùy chọn có sẵn.

Tiếp theo, chúng ta sẽ xem xét các mẫu, là một tính năng khác của Rust cho phép nhiều tính linh hoạt. Chúng ta đã xem xét chúng một cách ngắn gọn trong suốt cuốn sách nhưng chưa thấy được khả năng đầy đủ của chúng. Hãy tiếp tục!

Mẫu và Khớp mẫu

Mẫu (pattern) là một cú pháp đặc biệt trong Rust để so khớp với cấu trúc của các kiểu dữ liệu, từ đơn giản đến phức tạp. Sử dụng mẫu kết hợp với biểu thức match và các cấu trúc khác cung cấp cho bạn nhiều kiểm soát hơn đối với luồng điều khiển chương trình. Một mẫu bao gồm một số kết hợp của các thành phần sau:

  • Các giá trị nghĩa đen (Literals)
  • Phân rã mảng, enum, struct, hoặc tuple
  • Biến
  • Ký tự đại diện (Wildcards)
  • Vị trí giữ chỗ (Placeholders)

Một số ví dụ về mẫu bao gồm x, (a, 3), và Some(Color::Red). Trong các ngữ cảnh mà mẫu có giá trị, những thành phần này mô tả hình dạng của dữ liệu. Chương trình của chúng ta sau đó so khớp giá trị với mẫu để xác định liệu nó có đúng hình dạng dữ liệu để tiếp tục chạy một phần mã cụ thể không.

Để sử dụng một mẫu, chúng ta so sánh nó với một giá trị nào đó. Nếu mẫu khớp với giá trị, chúng ta sử dụng các phần của giá trị trong mã của mình. Hãy nhớ lại biểu thức match trong Chương 6 đã sử dụng mẫu, chẳng hạn như ví dụ về máy phân loại tiền xu. Nếu giá trị phù hợp với hình dạng của mẫu, chúng ta có thể sử dụng các phần được đặt tên. Nếu nó không phù hợp, mã liên kết với mẫu sẽ không chạy.

Chương này là tài liệu tham khảo về tất cả các vấn đề liên quan đến mẫu. Chúng ta sẽ đề cập đến những nơi hợp lệ để sử dụng mẫu, sự khác biệt giữa mẫu có thể bác bỏ và không thể bác bỏ, và các loại cú pháp mẫu khác nhau mà bạn có thể thấy. Đến cuối chương, bạn sẽ biết cách sử dụng mẫu để biểu đạt nhiều khái niệm một cách rõ ràng.

Tất cả những nơi có thể sử dụng Mẫu

Mẫu xuất hiện ở nhiều nơi trong Rust, và bạn đã sử dụng chúng rất nhiều mà không nhận ra! Phần này sẽ thảo luận về tất cả những nơi mà mẫu có hiệu lực.

Các nhánh của match

Như đã thảo luận trong Chương 6, chúng ta sử dụng mẫu trong các nhánh của biểu thức match. Chính thức thì, biểu thức match được định nghĩa bằng từ khóa match, một giá trị để so khớp, và một hoặc nhiều nhánh khớp bao gồm một mẫu và một biểu thức để chạy nếu giá trị khớp với mẫu của nhánh đó, như thế này:

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

Ví dụ, đây là biểu thức match từ Listing 6-5 khớp với một giá trị Option<i32> trong biến x:

match x {
    None => None,
    Some(i) => Some(i + 1),
}

Các mẫu trong biểu thức match này là NoneSome(i) ở bên trái của mỗi mũi tên.

Một yêu cầu đối với biểu thức match là chúng cần phải toàn diện theo nghĩa là tất cả các khả năng cho giá trị trong biểu thức match phải được tính đến. Một cách để đảm bảo bạn đã bao quát mọi khả năng là có một mẫu bao trùm tất cả cho nhánh cuối cùng: ví dụ, một tên biến khớp với bất kỳ giá trị nào không bao giờ thất bại và do đó bao gồm mọi trường hợp còn lại.

Mẫu đặc biệt _ sẽ khớp với bất cứ thứ gì, nhưng nó không bao giờ gắn kết với một biến, vì vậy nó thường được sử dụng trong nhánh match cuối cùng. Mẫu _ có thể hữu ích khi bạn muốn bỏ qua bất kỳ giá trị nào không được chỉ định, ví dụ. Chúng ta sẽ đề cập đến mẫu _ chi tiết hơn trong "Bỏ qua các giá trị trong Mẫu" sau trong chương này.

Biểu thức điều kiện if let

Trong Chương 6, chúng ta đã thảo luận về cách sử dụng biểu thức if let chủ yếu như một cách ngắn hơn để viết tương đương với một match chỉ khớp một trường hợp. Tùy chọn, if let có thể có một else tương ứng chứa mã để chạy nếu mẫu trong if let không khớp.

Listing 19-1 cho thấy rằng cũng có thể kết hợp if let, else if, và else if let. Làm như vậy giúp chúng ta linh hoạt hơn so với biểu thức match mà chúng ta chỉ có thể biểu thị một giá trị để so sánh với các mẫu. Ngoài ra, Rust không yêu cầu các điều kiện trong một chuỗi if let, else if, else if let phải liên quan đến nhau.

Mã trong Listing 19-1 xác định màu nền dựa trên một loạt các kiểm tra cho một số điều kiện. Đối với ví dụ này, chúng tôi đã tạo các biến với giá trị cứng mà một chương trình thực sự có thể nhận từ người dùng đầu vào.

fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}

Nếu người dùng chỉ định một màu yêu thích, màu đó được sử dụng làm nền. Nếu không có màu yêu thích nào được chỉ định và hôm nay là thứ Ba, màu nền là xanh lá cây. Nếu không, nếu người dùng chỉ định tuổi của họ dưới dạng chuỗi và chúng ta có thể phân tích nó thành một số thành công, màu sắc sẽ là tím hoặc cam tùy thuộc vào giá trị của số đó. Nếu không có điều kiện nào áp dụng, màu nền là xanh dương.

Cấu trúc điều kiện này cho phép chúng ta hỗ trợ các yêu cầu phức tạp. Với các giá trị cứng mà chúng ta có ở đây, ví dụ này sẽ in ra Using purple as the background color.

Bạn có thể thấy rằng if let cũng có thể giới thiệu các biến mới che khuất các biến hiện có theo cách tương tự như các nhánh match: dòng if let Ok(age) = age giới thiệu một biến age mới chứa giá trị bên trong biến thể Ok, che khuất biến age hiện có. Điều này có nghĩa là chúng ta cần đặt điều kiện if age > 30 trong khối đó: chúng ta không thể kết hợp hai điều kiện này thành if let Ok(age) = age && age > 30. Biến age mới mà chúng ta muốn so sánh với 30 không hợp lệ cho đến khi phạm vi mới bắt đầu với dấu ngoặc nhọn.

Nhược điểm của việc sử dụng biểu thức if let là trình biên dịch không kiểm tra tính toàn diện, trong khi với biểu thức match thì có. Nếu chúng ta bỏ qua khối else cuối cùng và do đó bỏ lỡ việc xử lý một số trường hợp, trình biên dịch sẽ không cảnh báo chúng ta về lỗi logic có thể xảy ra.

Vòng lặp điều kiện while let

Tương tự như cấu trúc if let, vòng lặp điều kiện while let cho phép vòng lặp while chạy miễn là một mẫu tiếp tục khớp. Trong Listing 19-2 chúng ta thấy một vòng lặp while let đợi các tin nhắn được gửi giữa các luồng, nhưng trong trường hợp này kiểm tra một Result thay vì một Option.

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}

Ví dụ này in ra 1, 2, và sau đó 3. Phương thức recv lấy tin nhắn đầu tiên từ phía nhận của kênh và trả về Ok(value). Khi chúng ta lần đầu tiên thấy recv trở lại trong Chương 16, chúng ta đã unwrap lỗi trực tiếp, hoặc tương tác với nó như một iterator sử dụng vòng lặp for. Như Listing 19-2 cho thấy, tuy nhiên, chúng ta cũng có thể sử dụng while let, vì phương thức recv trả về Ok mỗi lần một tin nhắn đến, miễn là người gửi tồn tại, và sau đó tạo ra một Err khi phía người gửi ngắt kết nối.

Vòng lặp for

Trong vòng lặp for, giá trị theo sau từ khóa for là một mẫu. Ví dụ, trong for x in y, x là mẫu. Listing 19-3 minh họa cách sử dụng mẫu trong vòng lặp for để phân rã, hoặc chia nhỏ, một tuple như một phần của vòng lặp for.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}

Mã trong Listing 19-3 sẽ in ra như sau:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

Chúng ta điều chỉnh một iterator bằng phương thức enumerate để nó tạo ra một giá trị và chỉ số cho giá trị đó, đặt vào một tuple. Giá trị đầu tiên được tạo ra là tuple (0, 'a'). Khi giá trị này được khớp với mẫu (index, value), index sẽ là 0value sẽ là 'a', in ra dòng đầu tiên của đầu ra.

Câu lệnh let

Trước chương này, chúng ta chỉ thảo luận rõ ràng về việc sử dụng mẫu với matchif let, nhưng trên thực tế, chúng ta đã sử dụng mẫu ở những nơi khác nữa, bao gồm cả câu lệnh let. Ví dụ, hãy xem xét việc gán biến đơn giản này với let:

#![allow(unused)]
fn main() {
let x = 5;
}

Mỗi lần bạn sử dụng câu lệnh let như thế này, bạn đã sử dụng mẫu, mặc dù bạn có thể không nhận ra điều đó! Chính thức hơn, câu lệnh let trông như thế này:

let PATTERN = EXPRESSION;

Trong các câu lệnh như let x = 5; với tên biến trong vị trí PATTERN, tên biến chỉ là một dạng đặc biệt đơn giản của mẫu. Rust so sánh biểu thức với mẫu và gán bất kỳ tên nào nó tìm thấy. Vì vậy, trong ví dụ let x = 5;, x là một mẫu có nghĩa là "gán cái gì khớp ở đây vào biến x." Bởi vì tên x là toàn bộ mẫu, mẫu này về cơ bản có nghĩa là "gán mọi thứ vào biến x, bất kể giá trị là gì."

Để thấy rõ hơn khía cạnh khớp mẫu của let, hãy xem xét Listing 19-4, sử dụng một mẫu với let để phân rã một tuple.

fn main() {
    let (x, y, z) = (1, 2, 3);
}

Ở đây, chúng ta khớp một tuple với một mẫu. Rust so sánh giá trị (1, 2, 3) với mẫu (x, y, z) và thấy rằng giá trị khớp với mẫu, vì số lượng phần tử là giống nhau trong cả hai, nên Rust gắn 1 với x, 2 với y, và 3 với z. Bạn có thể xem mẫu tuple này như là lồng ba mẫu biến cá nhân bên trong nó.

Nếu số lượng phần tử trong mẫu không khớp với số lượng phần tử trong tuple, kiểu tổng thể sẽ không khớp và chúng ta sẽ gặp lỗi trình biên dịch. Ví dụ, Listing 19-5 cho thấy một nỗ lực phân rã một tuple với ba phần tử thành hai biến, điều này sẽ không hoạt động.

fn main() {
    let (x, y) = (1, 2, 3);
}

Cố gắng biên dịch mã này dẫn đến lỗi kiểu này:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

Để sửa lỗi, chúng ta có thể bỏ qua một hoặc nhiều giá trị trong tuple bằng cách sử dụng _ hoặc .., như bạn sẽ thấy trong phần "Bỏ qua các giá trị trong Mẫu". Nếu vấn đề là chúng ta có quá nhiều biến trong mẫu, giải pháp là làm cho các kiểu khớp bằng cách loại bỏ các biến để số lượng biến bằng với số lượng phần tử trong tuple.

Tham số hàm

Tham số hàm cũng có thể là mẫu. Mã trong Listing 19-6, khai báo một hàm tên foo nhận một tham số tên x kiểu i32, giờ đây có lẽ đã trông quen thuộc.

fn foo(x: i32) {
    // code goes here
}

fn main() {}

Phần x là một mẫu! Giống như với let, chúng ta có thể khớp một tuple trong đối số của một hàm với mẫu. Listing 19-7 chia các giá trị trong một tuple khi chúng ta truyền nó vào một hàm.

fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}

Mã này in ra Current location: (3, 5). Các giá trị &(3, 5) khớp với mẫu &(x, y), vì vậy x là giá trị 3y là giá trị 5.

Chúng ta cũng có thể sử dụng mẫu trong danh sách tham số closure giống như trong danh sách tham số hàm vì closure tương tự như hàm, như đã thảo luận trong Chương 13.

Ở thời điểm này, bạn đã thấy một số cách sử dụng mẫu, nhưng mẫu không hoạt động giống nhau ở mọi nơi chúng ta có thể sử dụng chúng. Ở một số nơi, các mẫu phải không thể bác bỏ; trong các trường hợp khác, chúng có thể bị bác bỏ. Chúng ta sẽ thảo luận về hai khái niệm này tiếp theo.

Tính bác bỏ: Liệu một Mẫu Có Thể Không Khớp

Các mẫu có hai dạng: bác bỏ được (refutable) và không bác bỏ được (irrefutable). Các mẫu sẽ khớp với bất kỳ giá trị có thể nào được truyền vào được gọi là không bác bỏ được. Một ví dụ là x trong câu lệnh let x = 5; bởi vì x khớp với bất cứ thứ gì và do đó không thể thất bại khi khớp. Các mẫu có thể không khớp với một số giá trị có thể có được gọi là bác bỏ được. Một ví dụ là Some(x) trong biểu thức if let Some(x) = a_value bởi vì nếu giá trị trong biến a_valueNone thay vì Some, thì mẫu Some(x) sẽ không khớp.

Tham số hàm, câu lệnh let, và vòng lặp for chỉ có thể chấp nhận các mẫu không bác bỏ được bởi vì chương trình không thể làm bất cứ điều gì có ý nghĩa khi giá trị không khớp. Các biểu thức if letwhile let và câu lệnh let...else chấp nhận cả mẫu bác bỏ được và không bác bỏ được, nhưng trình biên dịch cảnh báo đối với các mẫu không bác bỏ được bởi vì, theo định nghĩa, chúng được dùng để xử lý khả năng thất bại: chức năng của một điều kiện nằm ở khả năng thực hiện khác nhau tùy thuộc vào thành công hay thất bại.

Nhìn chung, bạn không nên phải lo lắng về sự khác biệt giữa các mẫu bác bỏ được và không bác bỏ được; tuy nhiên, bạn cần phải làm quen với khái niệm tính bác bỏ để có thể phản ứng khi bạn thấy nó trong thông báo lỗi. Trong những trường hợp đó, bạn sẽ cần thay đổi hoặc mẫu hoặc cấu trúc mà bạn đang sử dụng với mẫu, tùy thuộc vào hành vi dự định của mã.

Hãy xem một ví dụ về điều gì xảy ra khi chúng ta cố gắng sử dụng một mẫu bác bỏ được trong khi Rust yêu cầu một mẫu không bác bỏ được và ngược lại. Listing 19-8 cho thấy một câu lệnh let, nhưng đối với mẫu, chúng ta đã chỉ định Some(x), một mẫu bác bỏ được. Như bạn có thể mong đợi, mã này sẽ không biên dịch.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}

Nếu some_option_value là một giá trị None, nó sẽ không khớp với mẫu Some(x), có nghĩa là mẫu là bác bỏ được. Tuy nhiên, câu lệnh let chỉ có thể chấp nhận một mẫu không bác bỏ được vì không có điều gì hợp lệ mà mã có thể làm với một giá trị None. Tại thời điểm biên dịch, Rust sẽ phàn nàn rằng chúng ta đã cố gắng sử dụng một mẫu bác bỏ được ở nơi yêu cầu một mẫu không bác bỏ được:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

For more information about this error, try `rustc --explain E0005`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

Bởi vì chúng ta không bao quát (và không thể bao quát!) mọi giá trị hợp lệ với mẫu Some(x), Rust đúng đắn tạo ra một lỗi biên dịch.

Nếu chúng ta có một mẫu bác bỏ được ở nơi cần một mẫu không bác bỏ được, chúng ta có thể sửa nó bằng cách thay đổi mã sử dụng mẫu: thay vì sử dụng let, chúng ta có thể sử dụng if let. Sau đó nếu mẫu không khớp, mã sẽ bỏ qua phần mã trong ngoặc nhọn, cung cấp cho nó một cách để tiếp tục một cách hợp lệ. Listing 19-9 cho thấy cách sửa mã trong Listing 19-8.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}

Chúng ta đã cho mã một lối thoát! Mã này bây giờ hoàn toàn hợp lệ. Tuy nhiên, nếu chúng ta cung cấp cho if let một mẫu không bác bỏ được (một mẫu sẽ luôn khớp), chẳng hạn như x, như được hiển thị trong Listing 19-10, trình biên dịch sẽ đưa ra cảnh báo.

fn main() {
    let x = 5 else {
        return;
    };
}

Rust phàn nàn rằng việc sử dụng if let với một mẫu không bác bỏ được là không hợp lý:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern will always match, so the `else` clause is useless
  = help: consider removing the `else` clause
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

Vì lý do này, các nhánh của match phải sử dụng các mẫu bác bỏ được, ngoại trừ nhánh cuối cùng, nên khớp với bất kỳ giá trị còn lại nào bằng một mẫu không bác bỏ được. Rust cho phép chúng ta sử dụng một mẫu không bác bỏ được trong một match với chỉ một nhánh, nhưng cú pháp này không đặc biệt hữu ích và có thể được thay thế bằng một câu lệnh let đơn giản hơn.

Bây giờ bạn đã biết nơi sử dụng các mẫu và sự khác biệt giữa các mẫu bác bỏ được và không bác bỏ được, hãy cùng xem tất cả các cú pháp mà chúng ta có thể sử dụng để tạo mẫu.

Cú Pháp Mẫu

Trong phần này, chúng ta tập hợp tất cả các cú pháp hợp lệ trong các mẫu và thảo luận về lý do và thời điểm bạn có thể muốn sử dụng mỗi cú pháp.

Khớp với Giá Trị Cụ Thể

Như bạn đã thấy trong Chương 6, bạn có thể khớp các mẫu trực tiếp với các giá trị cụ thể. Đoạn mã sau đây đưa ra một số ví dụ:

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

Đoạn mã này in ra one vì giá trị trong x là 1. Cú pháp này hữu ích khi bạn muốn mã của mình thực hiện một hành động nếu nó nhận được một giá trị cụ thể.

Khớp với Biến Có Tên

Các biến có tên là các mẫu không thể bác bỏ (irrefutable) khớp với bất kỳ giá trị nào, và chúng ta đã sử dụng chúng nhiều lần trong cuốn sách này. Tuy nhiên, có một phức tạp khi bạn sử dụng các biến có tên trong biểu thức match, if let hoặc while let. Bởi vì mỗi loại biểu thức này bắt đầu một phạm vi mới, các biến được khai báo như một phần của mẫu bên trong biểu thức sẽ che khuất (shadow) các biến có cùng tên bên ngoài, như trường hợp với tất cả các biến. Trong Listing 19-11, chúng ta khai báo một biến có tên x với giá trị Some(5) và một biến y với giá trị 10. Sau đó, chúng ta tạo một biểu thức match dựa trên giá trị x. Hãy nhìn vào các mẫu trong các arm của match và println! ở cuối, và thử tìm hiểu xem đoạn mã sẽ in ra gì trước khi chạy mã này hoặc đọc tiếp.

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}

Hãy cùng xem xét những gì xảy ra khi biểu thức match chạy. Mẫu trong arm match đầu tiên không khớp với giá trị đã định nghĩa của x, vì vậy mã tiếp tục.

Mẫu trong arm match thứ hai giới thiệu một biến mới có tên y sẽ khớp với bất kỳ giá trị nào bên trong một giá trị Some. Vì chúng ta đang ở trong một phạm vi mới bên trong biểu thức match, đây là một biến y mới, không phải biến y mà chúng ta đã khai báo ở đầu với giá trị 10. Ràng buộc y mới này sẽ khớp với bất kỳ giá trị nào bên trong một Some, đó chính là những gì chúng ta có trong x. Do đó, y mới này ràng buộc với giá trị bên trong của Some trong x. Giá trị đó là 5, vì vậy biểu thức cho arm đó thực thi và in ra Matched, y = 5.

Nếu x đã là giá trị None thay vì Some(5), thì các mẫu trong hai arm đầu tiên sẽ không khớp, vì vậy giá trị sẽ khớp với dấu gạch dưới. Chúng ta không giới thiệu biến x trong mẫu của arm dấu gạch dưới, vì vậy x trong biểu thức vẫn là x bên ngoài chưa bị che khuất. Trong trường hợp giả định này, phép match sẽ in ra Default case, x = None.

Khi biểu thức match kết thúc, phạm vi của nó cũng kết thúc, và phạm vi của y bên trong cũng vậy. println! cuối cùng tạo ra at the end: x = Some(5), y = 10.

Để tạo một biểu thức match so sánh giá trị của xy bên ngoài, thay vì giới thiệu một biến mới che khuất biến y hiện có, chúng ta cần sử dụng một điều kiện bảo vệ match (match guard) thay thế. Chúng ta sẽ nói về match guard sau trong phần "Điều Kiện Bổ Sung với Match Guards".

Nhiều Mẫu

Bạn có thể khớp nhiều mẫu bằng cách sử dụng cú pháp |, đó là toán tử hoặc trong mẫu. Ví dụ, trong đoạn mã sau, chúng ta so khớp giá trị của x với các arm match, arm đầu tiên có một tùy chọn hoặc, có nghĩa là nếu giá trị của x khớp với bất kỳ giá trị nào trong arm đó, thì mã của arm đó sẽ chạy:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

Đoạn mã này in ra one or two.

Khớp với Phạm Vi Giá Trị bằng ..=

Cú pháp ..= cho phép chúng ta khớp với một phạm vi giá trị bao gồm (inclusive). Trong đoạn mã sau, khi một mẫu khớp với bất kỳ giá trị nào trong phạm vi đã cho, arm đó sẽ thực thi:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

Nếu x1, 2, 3, 4, hoặc 5, arm đầu tiên sẽ khớp. Cú pháp này thuận tiện hơn cho nhiều giá trị khớp so với việc sử dụng toán tử | để biểu thị cùng một ý tưởng; nếu chúng ta sử dụng |, chúng ta sẽ phải chỉ định 1 | 2 | 3 | 4 | 5. Việc chỉ định một phạm vi ngắn gọn hơn nhiều, đặc biệt là nếu chúng ta muốn khớp, chẳng hạn, bất kỳ số nào từ 1 đến 1.000!

Trình biên dịch kiểm tra xem phạm vi có trống không tại thời điểm biên dịch, và vì các loại duy nhất mà Rust có thể xác định xem một phạm vi có trống hay không là các giá trị char và số, nên các phạm vi chỉ được phép với các giá trị số hoặc char.

Đây là một ví dụ sử dụng phạm vi của các giá trị char:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust có thể biết rằng 'c' nằm trong phạm vi của mẫu đầu tiên và in ra early ASCII letter.

Phá Vỡ để Tách Các Giá Trị

Chúng ta cũng có thể sử dụng các mẫu để phá vỡ các structs, enums và tuples để sử dụng các phần khác nhau của các giá trị này. Hãy cùng xem xét từng giá trị.

Phá Vỡ Structs

Listing 19-12 cho thấy một struct Point với hai trường, xy, mà chúng ta có thể tách ra bằng cách sử dụng một mẫu với một câu lệnh let.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}

Đoạn mã này tạo ra các biến ab khớp với các giá trị của các trường xy của struct p. Ví dụ này cho thấy rằng tên của các biến trong mẫu không cần phải khớp với tên của các trường của struct. Tuy nhiên, thông thường người ta đặt tên biến trùng với tên trường để dễ nhớ biến nào đến từ trường nào. Vì cách sử dụng phổ biến này, và vì việc viết let Point { x: x, y: y } = p; chứa nhiều sự lặp lại, Rust có một cách viết ngắn gọn cho các mẫu khớp với các trường struct: bạn chỉ cần liệt kê tên của trường struct, và các biến được tạo từ mẫu sẽ có cùng tên. Listing 19-13 hoạt động giống như đoạn mã trong Listing 19-12, nhưng các biến được tạo trong mẫu letxy thay vì ab.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}

Đoạn mã này tạo ra các biến xy khớp với các trường xy của biến p. Kết quả là các biến xy chứa các giá trị từ struct p.

Chúng ta cũng có thể phá vỡ với các giá trị cụ thể như một phần của mẫu struct thay vì tạo các biến cho tất cả các trường. Làm như vậy cho phép chúng ta kiểm tra một số trường cho các giá trị cụ thể trong khi tạo các biến để phá vỡ các trường khác.

Trong Listing 19-14, chúng ta có một biểu thức match phân tách các giá trị Point thành ba trường hợp: các điểm nằm trực tiếp trên trục x (điều này đúng khi y = 0), trên trục y (x = 0), hoặc không nằm trên trục nào.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}

Arm đầu tiên sẽ khớp với bất kỳ điểm nào nằm trên trục x bằng cách chỉ định rằng trường y khớp nếu giá trị của nó khớp với giá trị cụ thể 0. Mẫu vẫn tạo ra một biến x mà chúng ta có thể sử dụng trong mã cho arm này.

Tương tự, arm thứ hai khớp với bất kỳ điểm nào trên trục y bằng cách chỉ định rằng trường x khớp nếu giá trị của nó là 0 và tạo ra một biến y cho giá trị của trường y. Arm thứ ba không chỉ định bất kỳ giá trị cụ thể nào, vì vậy nó khớp với bất kỳ Point nào khác và tạo ra các biến cho cả trường xy.

Trong ví dụ này, giá trị p khớp với arm thứ hai bởi vì x chứa một 0, vì vậy đoạn mã này sẽ in ra On the y axis at 7.

Hãy nhớ rằng biểu thức match dừng kiểm tra các arm ngay khi nó tìm thấy mẫu khớp đầu tiên, vì vậy mặc dù Point { x: 0, y: 0} nằm trên cả trục x và trục y, đoạn mã này sẽ chỉ in ra On the x axis at 0.

Phá Vỡ Enums

Chúng ta đã phá vỡ enums trong cuốn sách này (ví dụ, Listing 6-5), nhưng chúng ta chưa thảo luận rõ ràng rằng mẫu để phá vỡ một enum tương ứng với cách dữ liệu được lưu trữ trong enum được định nghĩa. Ví dụ, trong Listing 19-15 chúng ta sử dụng enum Message từ Listing 6-2 và viết một match với các mẫu sẽ phá vỡ từng giá trị bên trong.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}

Đoạn mã này sẽ in ra Change color to red 0, green 160, and blue 255. Hãy thử thay đổi giá trị của msg để xem mã từ các arm khác chạy.

Đối với các biến thể enum không có dữ liệu, như Message::Quit, chúng ta không thể phá vỡ giá trị thêm nữa. Chúng ta chỉ có thể khớp với giá trị cụ thể Message::Quit, và không có biến nào trong mẫu đó.

Đối với các biến thể enum giống struct, như Message::Move, chúng ta có thể sử dụng một mẫu tương tự như mẫu chúng ta chỉ định để khớp với các struct. Sau tên biến thể, chúng ta đặt dấu ngoặc nhọn và sau đó liệt kê các trường với các biến để chúng ta tách các phần ra để sử dụng trong mã cho arm này. Ở đây chúng ta sử dụng dạng ngắn gọn như chúng ta đã làm trong Listing 19-13.

Đối với các biến thể enum giống tuple, như Message::Write chứa một tuple với một phần tử và Message::ChangeColor chứa một tuple với ba phần tử, mẫu tương tự như mẫu chúng ta chỉ định để khớp với các tuple. Số lượng biến trong mẫu phải khớp với số lượng phần tử trong biến thể mà chúng ta đang khớp.

Phá Vỡ Các Structs và Enums Lồng Nhau

Cho đến nay, các ví dụ của chúng ta đều khớp với các struct hoặc enum một cấp độ sâu, nhưng việc khớp cũng có thể hoạt động trên các mục lồng nhau! Ví dụ, chúng ta có thể tái cấu trúc mã trong Listing 19-15 để hỗ trợ các màu RGB và HSV trong thông điệp ChangeColor, như được hiển thị trong Listing 19-16.

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}

Mẫu của arm đầu tiên trong biểu thức match khớp với một biến thể enum Message::ChangeColor chứa một biến thể Color::Rgb; sau đó mẫu ràng buộc với ba giá trị i32 bên trong. Mẫu của arm thứ hai cũng khớp với một biến thể enum Message::ChangeColor, nhưng enum bên trong khớp với Color::Hsv thay thế. Chúng ta có thể chỉ định các điều kiện phức tạp này trong một biểu thức match, ngay cả khi hai enum có liên quan.

Phá Vỡ Structs và Tuples

Chúng ta có thể kết hợp, khớp, và lồng các mẫu phá vỡ theo những cách phức tạp hơn. Ví dụ sau đây cho thấy một phép phá vỡ phức tạp trong đó chúng ta lồng các struct và tuple bên trong một tuple và phá vỡ tất cả các giá trị nguyên thủy:

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

Đoạn mã này cho phép chúng ta phá vỡ các loại phức tạp thành các thành phần của chúng để chúng ta có thể sử dụng riêng biệt các giá trị mà chúng ta quan tâm.

Phá vỡ với các mẫu là một cách thuận tiện để sử dụng các phần của giá trị, chẳng hạn như giá trị từ mỗi trường trong một struct, tách biệt với nhau.

Bỏ Qua Giá Trị trong Mẫu

Đôi khi việc bỏ qua các giá trị trong một mẫu rất hữu ích, chẳng hạn như trong arm cuối cùng của match, để có một catchall không thực sự làm gì nhưng tính đến tất cả các giá trị còn lại có thể có. Có một vài cách để bỏ qua toàn bộ giá trị hoặc các phần của giá trị trong một mẫu: sử dụng mẫu _ (mà bạn đã thấy), sử dụng mẫu _ trong một mẫu khác, sử dụng một tên bắt đầu bằng dấu gạch dưới, hoặc sử dụng .. để bỏ qua các phần còn lại của một giá trị. Hãy khám phá cách và lý do sử dụng từng mẫu này.

Toàn Bộ Giá Trị với _

Chúng ta đã sử dụng dấu gạch dưới làm mẫu đại diện (wildcard) sẽ khớp với bất kỳ giá trị nào nhưng không ràng buộc với giá trị. Điều này đặc biệt hữu ích làm arm cuối cùng trong biểu thức match, nhưng chúng ta cũng có thể sử dụng nó trong bất kỳ mẫu nào, bao gồm cả tham số hàm, như được hiển thị trong Listing 19-17.

fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}

Đoạn mã này sẽ hoàn toàn bỏ qua giá trị 3 được truyền làm đối số đầu tiên và sẽ in ra This code only uses the y parameter: 4.

Trong hầu hết các trường hợp khi bạn không còn cần một tham số hàm cụ thể nào đó, bạn sẽ thay đổi chữ ký sao cho nó không bao gồm tham số không sử dụng. Việc bỏ qua một tham số hàm có thể đặc biệt hữu ích trong trường hợp, ví dụ, bạn đang triển khai một trait khi bạn cần một chữ ký loại nhất định nhưng thân hàm trong triển khai của bạn không cần một trong các tham số. Khi đó bạn tránh được cảnh báo của trình biên dịch về các tham số hàm không sử dụng, như bạn sẽ gặp nếu bạn sử dụng một tên thay thế.

Các Phần của Giá Trị với _ Lồng Nhau

Chúng ta cũng có thể sử dụng _ bên trong một mẫu khác để bỏ qua chỉ một phần của một giá trị, ví dụ, khi chúng ta muốn kiểm tra chỉ một phần của một giá trị nhưng không sử dụng các phần khác trong mã tương ứng mà chúng ta muốn chạy. Listing 19-18 hiển thị mã chịu trách nhiệm quản lý giá trị của một cài đặt. Yêu cầu kinh doanh là người dùng không được phép ghi đè một tùy chỉnh hiện có của cài đặt, nhưng có thể bỏ cài đặt và cung cấp giá trị cho nó nếu hiện tại nó chưa được đặt.

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}

Đoạn mã này sẽ in ra Can't overwrite an existing customized value và sau đó là setting is Some(5). Trong arm match đầu tiên, chúng ta không cần phải khớp hoặc sử dụng các giá trị bên trong cả hai biến thể Some, nhưng chúng ta cần kiểm tra trường hợp khi setting_valuenew_setting_value là biến thể Some. Trong trường hợp đó, chúng ta in ra lý do không thay đổi setting_value, và nó không bị thay đổi.

Trong tất cả các trường hợp khác (nếu setting_value hoặc new_setting_valueNone) được biểu thị bởi mẫu _ trong arm thứ hai, chúng ta muốn cho phép new_setting_value trở thành setting_value.

Chúng ta cũng có thể sử dụng dấu gạch dưới ở nhiều vị trí trong một mẫu để bỏ qua các giá trị cụ thể. Listing 19-19 cho thấy một ví dụ về việc bỏ qua giá trị thứ hai và thứ tư trong một tuple gồm năm mục.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}

Đoạn mã này sẽ in ra Some numbers: 2, 8, 32, và các giá trị 416 sẽ bị bỏ qua.

Một Biến Không Sử Dụng bằng Cách Bắt Đầu Tên Của Nó với _

Nếu bạn tạo một biến nhưng không sử dụng nó ở bất kỳ đâu, Rust thường sẽ đưa ra cảnh báo vì một biến không sử dụng có thể là một lỗi. Tuy nhiên, đôi khi việc tạo một biến mà bạn sẽ không sử dụng ngay là hữu ích, chẳng hạn như khi bạn đang tạo nguyên mẫu hoặc chỉ mới bắt đầu một dự án. Trong tình huống này, bạn có thể bảo Rust không cảnh báo bạn về biến không sử dụng bằng cách bắt đầu tên của biến bằng dấu gạch dưới. Trong Listing 19-20, chúng ta tạo hai biến không sử dụng, nhưng khi chúng ta biên dịch mã này, chúng ta chỉ nhận được cảnh báo về một trong số chúng.

fn main() {
    let _x = 5;
    let y = 10;
}

Ở đây, chúng ta nhận được cảnh báo về việc không sử dụng biến y, nhưng chúng ta không nhận được cảnh báo về việc không sử dụng _x.

Lưu ý rằng có sự khác biệt tinh tế giữa việc chỉ sử dụng _ và sử dụng một tên bắt đầu bằng dấu gạch dưới. Cú pháp _x vẫn ràng buộc giá trị với biến, trong khi _ hoàn toàn không ràng buộc. Để hiển thị một trường hợp mà sự khác biệt này quan trọng, Listing 19-21 sẽ cung cấp cho chúng ta một lỗi.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}

Chúng ta sẽ nhận được lỗi vì giá trị s vẫn sẽ được chuyển vào _s, điều này ngăn chúng ta sử dụng lại s. Tuy nhiên, việc sử dụng dấu gạch dưới một mình không bao giờ ràng buộc với giá trị. Listing 19-22 sẽ biên dịch mà không có bất kỳ lỗi nào vì s không bị chuyển vào _.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}

Đoạn mã này hoạt động tốt vì chúng ta không bao giờ ràng buộc s với bất cứ thứ gì; nó không bị chuyển đi.

Các Phần Còn Lại của Giá Trị với ..

Với các giá trị có nhiều phần, chúng ta có thể sử dụng cú pháp .. để sử dụng các phần cụ thể và bỏ qua phần còn lại, tránh cần liệt kê dấu gạch dưới cho mỗi giá trị bị bỏ qua. Mẫu .. bỏ qua bất kỳ phần nào của giá trị mà chúng ta chưa khớp rõ ràng trong phần còn lại của mẫu. Trong Listing 19-23, chúng ta có một struct Point lưu trữ một tọa độ trong không gian ba chiều. Trong biểu thức match, chúng ta chỉ muốn hoạt động trên tọa độ x và bỏ qua các giá trị trong các trường yz.

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}

Chúng ta liệt kê giá trị x và sau đó chỉ bao gồm mẫu ... Điều này nhanh hơn việc phải liệt kê y: _z: _, đặc biệt là khi chúng ta làm việc với các struct có nhiều trường trong tình huống chỉ một hoặc hai trường là liên quan.

Cú pháp .. sẽ mở rộng tới nhiều giá trị khi cần thiết. Listing 19-24 cho thấy cách sử dụng .. với một tuple.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}

Trong đoạn mã này, giá trị đầu tiên và cuối cùng được khớp với firstlast. .. sẽ khớp và bỏ qua mọi thứ ở giữa.

Tuy nhiên, việc sử dụng .. phải rõ ràng không mơ hồ. Nếu không rõ ràng giá trị nào dùng để khớp và giá trị nào nên bị bỏ qua, Rust sẽ đưa ra lỗi cho chúng ta. Listing 19-25 hiển thị một ví dụ về việc sử dụng .. một cách mơ hồ, vì vậy nó sẽ không biên dịch.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}

Khi chúng ta biên dịch ví dụ này, chúng ta nhận được lỗi này:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

Không thể nào để Rust xác định được có bao nhiêu giá trị trong tuple cần bỏ qua trước khi khớp một giá trị với second và sau đó còn bao nhiêu giá trị nữa cần bỏ qua sau đó. Đoạn mã này có thể có nghĩa là chúng ta muốn bỏ qua 2, ràng buộc second với 4, rồi bỏ qua 8, 1632; hoặc rằng chúng ta muốn bỏ qua 24, ràng buộc second với 8, rồi bỏ qua 1632; và vân vân. Tên biến second không có ý nghĩa đặc biệt gì đối với Rust, vì vậy chúng ta nhận được lỗi biên dịch vì việc sử dụng .. ở hai nơi như thế này là mơ hồ.

Điều Kiện Bổ Sung với Match Guard

Match guard là một điều kiện if bổ sung, được chỉ định sau mẫu trong một arm match, cũng phải khớp để arm đó được chọn. Match guard hữu ích để biểu thị các ý tưởng phức tạp hơn những gì chỉ riêng một mẫu cho phép. Tuy nhiên, lưu ý rằng chúng chỉ có sẵn trong biểu thức match, không có trong biểu thức if let hoặc while let.

Điều kiện có thể sử dụng các biến được tạo trong mẫu. Listing 19-26 hiển thị một match trong đó arm đầu tiên có mẫu Some(x) và cũng có một match guard là if x % 2 == 0 (sẽ là true nếu số là chẵn).

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}

Ví dụ này sẽ in ra The number 4 is even. Khi num được so sánh với mẫu trong arm đầu tiên, nó khớp vì Some(4) khớp với Some(x). Sau đó match guard kiểm tra xem phần dư của việc chia x cho 2 có bằng 0 không, và vì vậy, arm đầu tiên được chọn.

Nếu num đã là Some(5) thay vào đó, match guard trong arm đầu tiên sẽ là false vì phần dư của 5 chia cho 2 là 1, không bằng 0. Rust sau đó sẽ đi đến arm thứ hai, match do arm thứ hai không có match guard và do đó khớp với bất kỳ biến thể Some nào.

Không có cách nào để biểu thị điều kiện if x % 2 == 0 trong một mẫu, vì vậy match guard cho chúng ta khả năng biểu thị logic này. Nhược điểm của tính biểu đạt bổ sung này là trình biên dịch không cố gắng kiểm tra tính đầy đủ khi các biểu thức match guard có liên quan.

Trong Listing 19-11, chúng ta đã đề cập rằng chúng ta có thể sử dụng match guard để giải quyết vấn đề che khuất (shadowing) mẫu. Hãy nhớ rằng chúng ta đã tạo một biến mới bên trong mẫu trong biểu thức match thay vì sử dụng biến bên ngoài match. Biến mới đó có nghĩa là chúng ta không thể kiểm tra giá trị của biến bên ngoài. Listing 19-27 cho thấy cách chúng ta có thể sử dụng match guard để sửa lỗi này.

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}

Đoạn mã này sẽ in ra Default case, x = Some(5). Mẫu trong arm match thứ hai không giới thiệu một biến mới y sẽ che khuất biến y bên ngoài, có nghĩa là chúng ta có thể sử dụng y bên ngoài trong match guard. Thay vì chỉ định mẫu là Some(y), sẽ che khuất y bên ngoài, chúng ta chỉ định Some(n). Điều này tạo ra một biến mới n không che khuất bất cứ thứ gì vì không có biến n nào bên ngoài match.

Match guard if n == y không phải là một mẫu và do đó không giới thiệu các biến mới. y này y bên ngoài chứ không phải một y mới che khuất nó, và chúng ta có thể tìm kiếm một giá trị có cùng giá trị với y bên ngoài bằng cách so sánh n với y.

Bạn cũng có thể sử dụng toán tử hoặc | trong một match guard để chỉ định nhiều mẫu; điều kiện match guard sẽ áp dụng cho tất cả các mẫu. Listing 19-28 hiển thị sự ưu tiên khi kết hợp một mẫu sử dụng | với một match guard. Phần quan trọng của ví dụ này là match guard if y áp dụng cho 4, 5, 6, mặc dù nó có vẻ như if y chỉ áp dụng cho 6.

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}

Điều kiện match nêu rõ rằng arm chỉ khớp nếu giá trị của x bằng 4, 5, hoặc 6 nếu ytrue. Khi đoạn mã này chạy, mẫu của arm đầu tiên khớp vì x4, nhưng match guard if yfalse, vì vậy arm đầu tiên không được chọn. Đoạn mã chuyển sang arm thứ hai, khớp, và chương trình này in ra no. Lý do là điều kiện if áp dụng cho toàn bộ mẫu 4 | 5 | 6, không chỉ cho giá trị cuối cùng 6. Nói cách khác, ưu tiên của một match guard trong mối quan hệ với một mẫu hoạt động như thế này:

(4 | 5 | 6) if y => ...

chứ không phải như thế này:

4 | 5 | (6 if y) => ...

Sau khi chạy đoạn mã, hành vi ưu tiên là rõ ràng: nếu match guard chỉ được áp dụng cho giá trị cuối cùng trong danh sách các giá trị được chỉ định bằng toán tử |, thì arm đã khớp và chương trình đã in ra yes.

Ràng Buộc @

Toán tử at @ cho phép chúng ta tạo một biến giữ một giá trị đồng thời kiểm tra giá trị đó để khớp với một mẫu. Trong Listing 19-29, chúng ta muốn kiểm tra rằng trường id của Message::Hello nằm trong phạm vi 3..=7. Chúng ta cũng muốn ràng buộc giá trị với biến id_variable để chúng ta có thể sử dụng nó trong mã liên kết với arm. Chúng ta có thể đặt tên biến này là id, giống như tên trường, nhưng trong ví dụ này chúng ta sẽ sử dụng một tên khác.

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello {
            id: id_variable @ 3..=7,
        } => println!("Found an id in range: {id_variable}"),
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}

Ví dụ này sẽ in ra Found an id in range: 5. Bằng cách chỉ định id_variable @ trước phạm vi 3..=7, chúng ta đang bắt bất kỳ giá trị nào khớp với phạm vi đồng thời kiểm tra rằng giá trị đó khớp với mẫu phạm vi.

Trong arm thứ hai, nơi chúng ta chỉ có một phạm vi được chỉ định trong mẫu, mã liên kết với arm không có biến chứa giá trị thực tế của trường id. Giá trị của trường id có thể là 10, 11, hoặc 12, nhưng mã đi kèm với mẫu đó không biết nó là gì. Mã mẫu không thể sử dụng giá trị từ trường id, vì chúng ta chưa lưu giá trị id trong một biến.

Trong arm cuối cùng, nơi chúng ta đã chỉ định một biến mà không có phạm vi, chúng ta có giá trị có sẵn để sử dụng trong mã của arm trong một biến có tên là id. Lý do là chúng ta đã sử dụng cú pháp rút gọn trường struct. Nhưng chúng ta chưa áp dụng bất kỳ kiểm tra nào cho giá trị trong trường id trong arm này, như chúng ta đã làm với hai arm đầu tiên: bất kỳ giá trị nào cũng sẽ khớp với mẫu này.

Sử dụng @ cho phép chúng ta kiểm tra một giá trị và lưu nó trong một biến trong một mẫu.

Tóm Tắt

Các mẫu của Rust rất hữu ích trong việc phân biệt giữa các loại dữ liệu khác nhau. Khi được sử dụng trong biểu thức match, Rust đảm bảo rằng các mẫu của bạn bao gồm mọi giá trị có thể có, nếu không chương trình của bạn sẽ không biên dịch. Các mẫu trong câu lệnh let và tham số hàm làm cho các cấu trúc đó hữu ích hơn, cho phép phá vỡ các giá trị thành các phần nhỏ hơn đồng thời gán các phần đó cho các biến. Chúng ta có thể tạo các mẫu đơn giản hoặc phức tạp để phù hợp với nhu cầu của chúng ta.

Tiếp theo, ở chương gần cuối của cuốn sách, chúng ta sẽ xem xét một số khía cạnh nâng cao của nhiều tính năng của Rust.

Các Tính Năng Nâng Cao

Đến lúc này, bạn đã học được những phần thường được sử dụng nhất của ngôn ngữ lập trình Rust. Trước khi chúng ta thực hiện một dự án nữa trong Chương 21, chúng ta sẽ xem xét một số khía cạnh của ngôn ngữ mà bạn có thể gặp phải thỉnh thoảng, nhưng có thể không sử dụng hàng ngày. Bạn có thể sử dụng chương này như một tài liệu tham khảo khi bạn gặp phải những điều chưa biết. Các tính năng được đề cập ở đây rất hữu ích trong những tình huống rất cụ thể. Mặc dù bạn có thể không sử dụng chúng thường xuyên, chúng tôi muốn đảm bảo rằng bạn nắm được tất cả các tính năng mà Rust cung cấp.

Trong chương này, chúng ta sẽ đề cập đến:

  • Unsafe Rust: cách từ bỏ một số đảm bảo của Rust và tự chịu trách nhiệm về việc duy trì những đảm bảo đó một cách thủ công
  • Traits nâng cao: các kiểu liên kết, tham số kiểu mặc định, cú pháp đầy đủ, supertraits, và mẫu newtype liên quan đến traits
  • Kiểu dữ liệu nâng cao: thêm về mẫu newtype, bí danh kiểu, kiểu never, và các kiểu có kích thước động
  • Hàm và closure nâng cao: con trỏ hàm và trả về closures
  • Macro: các cách để định nghĩa mã nguồn định nghĩa thêm mã nguồn ở thời điểm biên dịch

Đây là một tập hợp đa dạng các tính năng của Rust với điều gì đó cho tất cả mọi người! Hãy bắt đầu!

Unsafe Rust

Tất cả mã nguồn mà chúng ta đã thảo luận cho đến nay đều có các đảm bảo về an toàn bộ nhớ của Rust được thực thi tại thời điểm biên dịch. Tuy nhiên, Rust có một ngôn ngữ thứ hai ẩn bên trong nó không thực thi các đảm bảo an toàn bộ nhớ này: nó được gọi là unsafe Rust và hoạt động giống như Rust thông thường, nhưng cung cấp cho chúng ta các siêu năng lực bổ sung.

Unsafe Rust tồn tại bởi vì, về bản chất, phân tích tĩnh là bảo thủ. Khi trình biên dịch cố gắng xác định liệu mã có duy trì các đảm bảo hay không, tốt hơn là nó từ chối một số chương trình hợp lệ còn hơn là chấp nhận một số chương trình không hợp lệ. Mặc dù mã có thể ổn, nhưng nếu trình biên dịch Rust không có đủ thông tin để tự tin, nó sẽ từ chối mã. Trong những trường hợp này, bạn có thể sử dụng mã unsafe để nói với trình biên dịch rằng, "Hãy tin tôi, tôi biết mình đang làm gì." Tuy nhiên, hãy cảnh giác, rằng bạn sử dụng unsafe Rust với rủi ro của riêng mình: nếu bạn sử dụng mã unsafe không chính xác, các vấn đề có thể xảy ra do không an toàn bộ nhớ, chẳng hạn như giải tham chiếu con trỏ null.

Một lý do khác khiến Rust có một phiên bản khác là unsafe là vì phần cứng máy tính cơ bản vốn không an toàn. Nếu Rust không cho phép bạn thực hiện các hoạt động không an toàn, bạn không thể thực hiện một số tác vụ nhất định. Rust cần cho phép bạn thực hiện lập trình hệ thống cấp thấp, chẳng hạn như tương tác trực tiếp với hệ điều hành hoặc thậm chí viết hệ điều hành của riêng bạn. Làm việc với lập trình hệ thống cấp thấp là một trong những mục tiêu của ngôn ngữ này. Hãy khám phá những gì chúng ta có thể làm với unsafe Rust và cách thực hiện.

Siêu Năng Lực Unsafe

Để chuyển sang unsafe Rust, sử dụng từ khóa unsafe và sau đó bắt đầu một khối mới chứa mã unsafe. Bạn có thể thực hiện năm hành động trong unsafe Rust mà bạn không thể thực hiện trong safe Rust, mà chúng ta gọi là siêu năng lực unsafe. Những siêu năng lực đó bao gồm khả năng:

  • Giải tham chiếu một con trỏ thô
  • Gọi một hàm hoặc phương thức unsafe
  • Truy cập hoặc sửa đổi một biến tĩnh có thể thay đổi
  • Triển khai một trait unsafe
  • Truy cập các trường của một union

Điều quan trọng cần hiểu là unsafe không tắt trình kiểm tra mượn hoặc vô hiệu hóa bất kỳ kiểm tra an toàn nào khác của Rust: nếu bạn sử dụng một tham chiếu trong mã unsafe, nó vẫn sẽ được kiểm tra. Từ khóa unsafe chỉ cung cấp cho bạn quyền truy cập vào năm tính năng này, sau đó không được trình biên dịch kiểm tra về an toàn bộ nhớ. Bạn vẫn sẽ có một mức độ an toàn nhất định bên trong một khối unsafe.

Ngoài ra, unsafe không có nghĩa là mã bên trong khối nhất thiết phải nguy hiểm hoặc chắc chắn sẽ có vấn đề về an toàn bộ nhớ: ý định là với tư cách là lập trình viên, bạn sẽ đảm bảo rằng mã bên trong khối unsafe sẽ truy cập bộ nhớ theo cách hợp lệ.

Con người có thể mắc lỗi và lỗi sẽ xảy ra, nhưng bằng cách yêu cầu năm hoạt động unsafe này phải nằm trong các khối được chú thích với unsafe, bạn sẽ biết rằng bất kỳ lỗi nào liên quan đến an toàn bộ nhớ phải nằm trong một khối unsafe. Giữ các khối unsafe nhỏ; bạn sẽ biết ơn sau này khi điều tra các lỗi bộ nhớ.

Để cô lập mã unsafe càng nhiều càng tốt, tốt nhất là bao bọc mã đó trong một trừu tượng an toàn và cung cấp một API an toàn, điều mà chúng ta sẽ thảo luận sau trong chương khi chúng ta xem xét các hàm và phương thức unsafe. Các phần của thư viện tiêu chuẩn được triển khai như các trừu tượng an toàn trên mã unsafe đã được kiểm tra. Bao bọc mã unsafe trong một trừu tượng an toàn ngăn việc sử dụng unsafe rò rỉ ra tất cả các nơi mà bạn hoặc người dùng của bạn có thể muốn sử dụng chức năng được triển khai với mã unsafe, bởi vì việc sử dụng một trừu tượng an toàn là an toàn.

Hãy xem xét từng siêu năng lực unsafe một. Chúng ta cũng sẽ xem xét một số trừu tượng cung cấp giao diện an toàn cho mã unsafe.

Giải Tham Chiếu một Con Trỏ Thô

Trong "Tham Chiếu Đang Treo" ở Chương 4, chúng ta đã đề cập rằng trình biên dịch đảm bảo các tham chiếu luôn hợp lệ. Unsafe Rust có hai loại mới được gọi là con trỏ thô tương tự như tham chiếu. Giống như với các tham chiếu, con trỏ thô có thể bất biến hoặc có thể thay đổi và được viết là *const T*mut T, tương ứng. Dấu hoa thị không phải là toán tử giải tham chiếu; nó là một phần của tên kiểu. Trong bối cảnh của con trỏ thô, bất biến có nghĩa là con trỏ không thể được gán trực tiếp sau khi được giải tham chiếu.

Khác với tham chiếu và con trỏ thông minh, con trỏ thô:

  • Được phép bỏ qua các quy tắc mượn bằng cách có cả con trỏ bất biến và có thể thay đổi hoặc nhiều con trỏ có thể thay đổi đến cùng một vị trí
  • Không được đảm bảo trỏ đến bộ nhớ hợp lệ
  • Được phép là null
  • Không triển khai bất kỳ quá trình dọn dẹp tự động nào

Bằng cách chọn không để Rust thực thi các đảm bảo này, bạn có thể từ bỏ an toàn được đảm bảo để đổi lấy hiệu suất cao hơn hoặc khả năng giao tiếp với ngôn ngữ hoặc phần cứng khác nơi mà các đảm bảo của Rust không áp dụng.

Listing 20-1 cho thấy cách tạo một con trỏ bất biến và một con trỏ có thể thay đổi.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}

Lưu ý rằng chúng ta không đưa từ khóa unsafe vào mã này. Chúng ta có thể tạo con trỏ thô trong mã an toàn; chúng ta chỉ không thể giải tham chiếu con trỏ thô bên ngoài một khối unsafe, như bạn sẽ thấy sau đây.

Chúng ta đã tạo con trỏ thô bằng cách sử dụng các toán tử mượn thô: &raw const num tạo một con trỏ thô bất biến *const i32, và &raw mut num tạo một con trỏ thô có thể thay đổi *mut i32. Bởi vì chúng ta tạo chúng trực tiếp từ một biến cục bộ, chúng ta biết các con trỏ thô cụ thể này là hợp lệ, nhưng chúng ta không thể đưa ra giả định đó về bất kỳ con trỏ thô nào.

Để minh họa điều này, tiếp theo chúng ta sẽ tạo một con trỏ thô mà tính hợp lệ của nó chúng ta không thể chắc chắn, sử dụng as để ép kiểu một giá trị thay vì sử dụng các toán tử mượn thô. Listing 20-2 cho thấy cách tạo một con trỏ thô đến một vị trí bộ nhớ tùy ý. Việc cố gắng sử dụng bộ nhớ tùy ý là không xác định: có thể có dữ liệu tại địa chỉ đó hoặc có thể không, trình biên dịch có thể tối ưu hóa mã để không có truy cập bộ nhớ, hoặc chương trình có thể kết thúc với lỗi phân đoạn. Thông thường, không có lý do tốt để viết mã như thế này, đặc biệt là trong các trường hợp mà bạn có thể sử dụng toán tử mượn thô thay thế, nhưng điều đó là có thể.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

Nhớ rằng chúng ta có thể tạo con trỏ thô trong mã an toàn, nhưng chúng ta không thể giải tham chiếu con trỏ thô và đọc dữ liệu đang được trỏ đến. Trong Listing 20-3, chúng ta sử dụng toán tử giải tham chiếu * trên một con trỏ thô đòi hỏi một khối unsafe.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

Việc tạo một con trỏ không gây hại gì; chỉ khi chúng ta cố gắng truy cập giá trị mà nó trỏ đến, chúng ta mới có thể phải đối mặt với một giá trị không hợp lệ.

Cũng lưu ý rằng trong Listing 20-1 và 20-3, chúng ta đã tạo các con trỏ thô *const i32*mut i32 đều trỏ đến cùng một vị trí bộ nhớ, nơi num được lưu trữ. Nếu thay vào đó, chúng ta cố gắng tạo một tham chiếu bất biến và một tham chiếu có thể thay đổi đến num, mã sẽ không biên dịch được vì các quy tắc sở hữu của Rust không cho phép một tham chiếu có thể thay đổi cùng lúc với bất kỳ tham chiếu bất biến nào. Với con trỏ thô, chúng ta có thể tạo một con trỏ có thể thay đổi và một con trỏ bất biến đến cùng một vị trí và thay đổi dữ liệu thông qua con trỏ có thể thay đổi, có khả năng tạo ra cuộc đua dữ liệu. Hãy cẩn thận!

Với tất cả những nguy hiểm này, tại sao bạn lại sử dụng con trỏ thô? Một trường hợp sử dụng chính là khi giao tiếp với mã C, như bạn sẽ thấy trong phần tiếp theo, "Gọi Hàm hoặc Phương Thức Unsafe." Một trường hợp khác là khi xây dựng các trừu tượng an toàn mà trình kiểm tra mượn không hiểu. Chúng ta sẽ giới thiệu các hàm unsafe và sau đó xem xét một ví dụ về một trừu tượng an toàn sử dụng mã unsafe.

Gọi Hàm hoặc Phương Thức Unsafe

Loại hoạt động thứ hai mà bạn có thể thực hiện trong một khối unsafe là gọi các hàm unsafe. Các hàm và phương thức unsafe trông giống như các hàm và phương thức thông thường, nhưng chúng có thêm unsafe trước phần còn lại của định nghĩa. Từ khóa unsafe trong bối cảnh này chỉ ra rằng hàm có các yêu cầu mà chúng ta cần duy trì khi gọi hàm này, bởi vì Rust không thể đảm bảo rằng chúng ta đã đáp ứng các yêu cầu này. Bằng cách gọi một hàm unsafe trong một khối unsafe, chúng ta đang nói rằng chúng ta đã đọc tài liệu của hàm này và chúng ta chịu trách nhiệm duy trì các hợp đồng của hàm.

Đây là một hàm unsafe có tên dangerous không làm gì trong thân hàm:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

Chúng ta phải gọi hàm dangerous trong một khối unsafe riêng biệt. Nếu chúng ta cố gắng gọi dangerous mà không có khối unsafe, chúng ta sẽ gặp lỗi:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Với khối unsafe, chúng ta đang khẳng định với Rust rằng chúng ta đã đọc tài liệu của hàm, chúng ta hiểu cách sử dụng nó đúng cách và chúng ta đã xác minh rằng chúng ta đang thực hiện hợp đồng của hàm.

Để thực hiện các hoạt động unsafe trong thân của một hàm unsafe, bạn vẫn cần sử dụng một khối unsafe, giống như trong một hàm thông thường, và trình biên dịch sẽ cảnh báo bạn nếu bạn quên. Điều này giúp giữ cho các khối unsafe càng nhỏ càng tốt, vì các hoạt động unsafe có thể không cần thiết trên toàn bộ thân hàm.

Tạo một Trừu Tượng An Toàn trên Mã Unsafe

Chỉ vì một hàm chứa mã unsafe không có nghĩa là chúng ta cần đánh dấu toàn bộ hàm là unsafe. Trên thực tế, việc bao bọc mã unsafe trong một hàm an toàn là một trừu tượng phổ biến. Ví dụ, hãy nghiên cứu hàm split_at_mut từ thư viện tiêu chuẩn, hàm này yêu cầu một số mã unsafe. Chúng ta sẽ khám phá cách chúng ta có thể triển khai nó. Phương thức an toàn này được định nghĩa trên các slice có thể thay đổi: nó lấy một slice và tạo ra hai slice bằng cách chia slice tại chỉ số được đưa vào dưới dạng đối số. Listing 20-4 cho thấy cách sử dụng split_at_mut.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}

Chúng ta không thể triển khai hàm này chỉ bằng Rust an toàn. Một nỗ lực có thể trông giống như Listing 20-5, nhưng sẽ không biên dịch được. Để đơn giản, chúng ta sẽ triển khai split_at_mut dưới dạng một hàm thay vì một phương thức và chỉ cho các slice của giá trị i32 thay vì cho một kiểu chung T.

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

Hàm này đầu tiên lấy tổng độ dài của slice. Sau đó, nó khẳng định rằng chỉ số được đưa vào dưới dạng tham số nằm trong slice bằng cách kiểm tra xem nó có nhỏ hơn hoặc bằng độ dài hay không. Sự khẳng định có nghĩa là nếu chúng ta truyền một chỉ số lớn hơn độ dài để chia slice, hàm sẽ panic trước khi nó cố gắng sử dụng chỉ số đó.

Sau đó, chúng ta trả về hai slice có thể thay đổi trong một tuple: một từ đầu slice ban đầu đến chỉ số mid và một từ mid đến cuối slice.

Khi chúng ta cố gắng biên dịch mã trong Listing 20-5, chúng ta sẽ gặp lỗi.

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Trình kiểm tra mượn của Rust không thể hiểu rằng chúng ta đang mượn các phần khác nhau của slice; nó chỉ biết rằng chúng ta đang mượn từ cùng một slice hai lần. Việc mượn các phần khác nhau của một slice về cơ bản là ổn vì hai slice không chồng chéo nhau, nhưng Rust không đủ thông minh để biết điều này. Khi chúng ta biết mã là ổn, nhưng Rust không biết, đó là lúc chúng ta cần sử dụng mã unsafe.

Listing 20-6 cho thấy cách sử dụng khối unsafe, con trỏ thô và một số lệnh gọi đến các hàm unsafe để làm cho việc triển khai split_at_mut hoạt động.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

Nhớ lại từ "Kiểu Slice" trong Chương 4 rằng slice là một con trỏ đến một số dữ liệu và độ dài của slice. Chúng ta sử dụng phương thức len để lấy độ dài của một slice và phương thức as_mut_ptr để truy cập con trỏ thô của một slice. Trong trường hợp này, vì chúng ta có một slice có thể thay đổi cho các giá trị i32, as_mut_ptr trả về một con trỏ thô với kiểu *mut i32, mà chúng ta đã lưu trữ trong biến ptr.

Chúng ta giữ khẳng định rằng chỉ số mid nằm trong slice. Sau đó, chúng ta đi đến mã unsafe: hàm slice::from_raw_parts_mut nhận một con trỏ thô và một độ dài, và nó tạo ra một slice. Chúng ta sử dụng nó để tạo một slice bắt đầu từ ptr và dài mid phần tử. Sau đó, chúng ta gọi phương thức add trên ptr với mid là đối số để có được một con trỏ thô bắt đầu tại mid, và chúng ta tạo một slice sử dụng con trỏ đó và số phần tử còn lại sau mid làm độ dài.

Hàm slice::from_raw_parts_mut là unsafe vì nó nhận một con trỏ thô và phải tin rằng con trỏ này là hợp lệ. Phương thức add trên các con trỏ thô cũng là unsafe vì nó phải tin rằng vị trí offset cũng là một con trỏ hợp lệ. Do đó, chúng ta phải đặt một khối unsafe xung quanh các lệnh gọi của chúng ta đến slice::from_raw_parts_mutadd để chúng ta có thể gọi chúng. Bằng cách xem xét mã và thêm khẳng định rằng mid phải nhỏ hơn hoặc bằng len, chúng ta có thể nói rằng tất cả các con trỏ thô được sử dụng trong khối unsafe sẽ là các con trỏ hợp lệ đến dữ liệu trong slice. Đây là một cách sử dụng unsafe có thể chấp nhận và thích hợp.

Lưu ý rằng chúng ta không cần phải đánh dấu hàm split_at_mut kết quả là unsafe, và chúng ta có thể gọi hàm này từ Rust an toàn. Chúng ta đã tạo một trừu tượng an toàn cho mã unsafe với một triển khai của hàm sử dụng mã unsafe theo cách an toàn, bởi vì nó chỉ tạo các con trỏ hợp lệ từ dữ liệu mà hàm này có quyền truy cập.

Ngược lại, việc sử dụng slice::from_raw_parts_mut trong Listing 20-7 có thể sẽ gây crash khi slice được sử dụng. Mã này lấy một vị trí bộ nhớ tùy ý và tạo một slice dài 10.000 phần tử.

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

Chúng ta không sở hữu bộ nhớ tại vị trí tùy ý này, và không có đảm bảo rằng slice mà mã này tạo ra chứa các giá trị i32 hợp lệ. Cố gắng sử dụng values như thể nó là một slice hợp lệ dẫn đến hành vi không xác định.

Sử dụng Hàm extern để Gọi Mã Bên Ngoài

Đôi khi, mã Rust của bạn có thể cần tương tác với mã được viết bằng một ngôn ngữ khác. Đối với điều này, Rust có từ khóa extern tạo điều kiện cho việc tạo và sử dụng một Giao Diện Hàm Ngoại (FFI). FFI là một cách để một ngôn ngữ lập trình định nghĩa các hàm và cho phép một ngôn ngữ lập trình khác (ngoại) gọi các hàm đó.

Listing 20-8 minh họa cách thiết lập tích hợp với hàm abs từ thư viện tiêu chuẩn C. Các hàm được khai báo trong các khối extern thường không an toàn khi gọi từ mã Rust, vì vậy các khối extern cũng phải được đánh dấu là unsafe. Lý do là vì các ngôn ngữ khác không thực thi các quy tắc và đảm bảo của Rust, và Rust không thể kiểm tra chúng, vì vậy trách nhiệm thuộc về lập trình viên để đảm bảo an toàn.

unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

Trong khối unsafe extern "C", chúng ta liệt kê tên và chữ ký của các hàm bên ngoài từ một ngôn ngữ khác mà chúng ta muốn gọi. Phần "C" định nghĩa giao diện nhị phân ứng dụng (ABI) mà hàm bên ngoài sử dụng: ABI định nghĩa cách gọi hàm ở cấp độ assembly. ABI "C" là phổ biến nhất và tuân theo ABI của ngôn ngữ lập trình C. Thông tin về tất cả các ABI mà Rust hỗ trợ có sẵn trong Tài liệu tham khảo Rust.

Mỗi mục được khai báo trong một khối unsafe extern đều được ngầm hiểu là unsafe. Tuy nhiên, một số hàm FFI thực sự an toàn để gọi. Ví dụ, hàm abs từ thư viện tiêu chuẩn của C không có bất kỳ cân nhắc an toàn bộ nhớ nào và chúng ta biết nó có thể được gọi với bất kỳ giá trị i32 nào. Trong những trường hợp như thế này, chúng ta có thể sử dụng từ khóa safe để nói rằng hàm cụ thể này an toàn để gọi ngay cả khi nó nằm trong một khối unsafe extern. Sau khi chúng ta thực hiện thay đổi đó, việc gọi nó không còn yêu cầu một khối unsafe, như thể hiện trong Listing 20-9.

unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}

Việc đánh dấu một hàm là safe không tự nhiên làm cho nó an toàn! Thay vào đó, nó giống như một lời hứa mà bạn đang tạo với Rust rằng nó an toàn. Vẫn là trách nhiệm của bạn để đảm bảo lời hứa đó được giữ!

Gọi Hàm Rust từ Các Ngôn Ngữ Khác

Chúng ta cũng có thể sử dụng extern để tạo một giao diện cho phép các ngôn ngữ khác gọi các hàm Rust. Thay vì tạo một khối extern hoàn chỉnh, chúng ta thêm từ khóa extern và chỉ định ABI để sử dụng ngay trước từ khóa fn cho hàm liên quan. Chúng ta cũng cần thêm chú thích #[unsafe(no_mangle)] để nói với trình biên dịch Rust không làm rối tên của hàm này. Làm rối là khi một trình biên dịch thay đổi tên mà chúng ta đã đặt cho một hàm thành một tên khác chứa thêm thông tin cho các phần khác của quá trình biên dịch sử dụng nhưng ít dễ đọc hơn đối với con người. Mỗi trình biên dịch ngôn ngữ lập trình làm rối tên hơi khác nhau, vì vậy để một hàm Rust có thể được đặt tên bởi các ngôn ngữ khác, chúng ta phải vô hiệu hóa việc làm rối tên của trình biên dịch Rust. Điều này là không an toàn vì có thể có xung đột tên giữa các thư viện mà không có cơ chế làm rối tên tích hợp, vì vậy đó là trách nhiệm của chúng ta để đảm bảo tên mà chúng ta chọn là an toàn để xuất mà không làm rối.

Trong ví dụ sau, chúng ta làm cho hàm call_from_c có thể truy cập từ mã C, sau khi nó được biên dịch thành một thư viện chia sẻ và được liên kết từ C:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

Cách sử dụng extern này chỉ yêu cầu unsafe trong thuộc tính, không phải trên khối extern.

Truy Cập hoặc Sửa Đổi một Biến Tĩnh Có Thể Thay Đổi

Trong sách này, chúng ta vẫn chưa nói về các biến toàn cục, mà Rust có hỗ trợ nhưng có thể gây ra vấn đề với các quy tắc sở hữu của Rust. Nếu hai luồng đang truy cập cùng một biến toàn cục có thể thay đổi, nó có thể gây ra đua dữ liệu.

Trong Rust, các biến toàn cục được gọi là biến tĩnh. Listing 20-10 cho thấy một ví dụ về khai báo và sử dụng một biến tĩnh với một slice chuỗi làm giá trị.

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}

Các biến tĩnh tương tự như các hằng số, mà chúng ta đã thảo luận trong "Hằng Số" trong Chương 3. Tên của các biến tĩnh là SCREAMING_SNAKE_CASE theo quy ước. Các biến tĩnh chỉ có thể lưu trữ các tham chiếu với thời gian sống 'static, có nghĩa là trình biên dịch Rust có thể tính toán thời gian sống và chúng ta không cần phải chú thích nó một cách rõ ràng. Truy cập một biến tĩnh bất biến là an toàn.

Một sự khác biệt tinh tế giữa các hằng số và các biến tĩnh bất biến là các giá trị trong một biến tĩnh có một địa chỉ cố định trong bộ nhớ. Việc sử dụng giá trị sẽ luôn truy cập cùng một dữ liệu. Các hằng số, mặt khác, được phép nhân bản dữ liệu của chúng bất cứ khi nào chúng được sử dụng. Một sự khác biệt khác là các biến tĩnh có thể có thể thay đổi. Truy cập và sửa đổi các biến tĩnh có thể thay đổi là không an toàn. Listing 20-11 cho thấy cách khai báo, truy cập và sửa đổi một biến tĩnh có thể thay đổi có tên là COUNTER.

static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}

Giống như với các biến thông thường, chúng ta chỉ định khả năng thay đổi bằng từ khóa mut. Bất kỳ mã nào đọc hoặc ghi từ COUNTER phải nằm trong một khối unsafe. Mã này biên dịch và in COUNTER: 3 như chúng ta mong đợi vì nó là đơn luồng. Việc có nhiều luồng truy cập COUNTER có thể dẫn đến đua dữ liệu, vì vậy nó là hành vi không xác định. Do đó, chúng ta cần đánh dấu toàn bộ hàm là unsafe, và ghi chú giới hạn an toàn, để bất kỳ ai gọi hàm đều biết những gì họ được và không được phép làm một cách an toàn.

Bất cứ khi nào chúng ta viết một hàm unsafe, theo quy ước, chúng ta nên viết một bình luận bắt đầu bằng SAFETY và giải thích những gì người gọi cần làm để gọi hàm một cách an toàn. Tương tự, bất cứ khi nào chúng ta thực hiện một hoạt động unsafe, theo quy ước, chúng ta nên viết một bình luận bắt đầu bằng SAFETY để giải thích cách các quy tắc an toàn được duy trì.

Ngoài ra, trình biên dịch sẽ không cho phép bạn tạo các tham chiếu đến một biến tĩnh có thể thay đổi. Bạn chỉ có thể truy cập nó thông qua một con trỏ thô, được tạo bằng một trong các toán tử mượn thô. Điều đó bao gồm cả trong các trường hợp khi tham chiếu được tạo một cách vô hình, như khi nó được sử dụng trong println! trong mã này. Yêu cầu rằng các tham chiếu đến các biến tĩnh có thể thay đổi chỉ có thể được tạo thông qua các con trỏ thô giúp làm cho các yêu cầu an toàn để sử dụng chúng trở nên rõ ràng hơn.

Với dữ liệu có thể thay đổi mà có thể truy cập toàn cục, khó để đảm bảo rằng không có đua dữ liệu, đó là lý do tại sao Rust coi các biến tĩnh có thể thay đổi là không an toàn. Khi có thể, tốt hơn là sử dụng các kỹ thuật đồng thời và các con trỏ thông minh an toàn với luồng mà chúng ta đã thảo luận trong Chương 16 để trình biên dịch kiểm tra rằng việc truy cập dữ liệu từ các luồng khác nhau được thực hiện một cách an toàn.

Triển Khai một Trait Unsafe

Chúng ta có thể sử dụng unsafe để triển khai một trait unsafe. Một trait là unsafe khi ít nhất một trong các phương thức của nó có một bất biến mà trình biên dịch không thể xác minh. Chúng ta khai báo rằng một trait là unsafe bằng cách thêm từ khóa unsafe trước trait và đánh dấu việc triển khai trait cũng là unsafe, như thể hiện trong Listing 20-12.

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}

Bằng cách sử dụng unsafe impl, chúng ta đang hứa rằng chúng ta sẽ duy trì các bất biến mà trình biên dịch không thể xác minh.

Ví dụ, nhớ lại các trait đánh dấu SyncSend mà chúng ta đã thảo luận trong "Khả năng mở rộng đồng thời với các Trait SyncSend" trong Chương 16: trình biên dịch triển khai các trait này tự động nếu các kiểu của chúng ta được tạo thành hoàn toàn từ các kiểu khác triển khai SendSync. Nếu chúng ta triển khai một kiểu chứa một kiểu không triển khai Send hoặc Sync, chẳng hạn như con trỏ thô, và chúng ta muốn đánh dấu kiểu đó là Send hoặc Sync, chúng ta phải sử dụng unsafe. Rust không thể xác minh rằng kiểu của chúng ta duy trì các đảm bảo rằng nó có thể được gửi an toàn qua các luồng hoặc truy cập từ nhiều luồng; do đó, chúng ta cần thực hiện các kiểm tra đó thủ công và chỉ ra như vậy với unsafe.

Truy Cập các Trường của một Union

Hành động cuối cùng chỉ hoạt động với unsafe là truy cập các trường của một union. Một union tương tự như một struct, nhưng chỉ một trường được khai báo được sử dụng trong một phiên bản cụ thể tại một thời điểm. Unions chủ yếu được sử dụng để giao tiếp với các unions trong mã C. Truy cập các trường của union là unsafe vì Rust không thể đảm bảo kiểu của dữ liệu hiện đang được lưu trữ trong phiên bản union. Bạn có thể tìm hiểu thêm về unions trong Tài liệu tham khảo Rust.

Sử Dụng Miri để Kiểm Tra Mã Unsafe

Khi viết mã unsafe, bạn có thể muốn kiểm tra rằng những gì bạn đã viết thực sự là an toàn và chính xác. Một trong những cách tốt nhất để làm điều đó là sử dụng Miri, một công cụ Rust chính thức để phát hiện hành vi không xác định. Trong khi trình kiểm tra mượn là một công cụ tĩnh hoạt động tại thời điểm biên dịch, Miri là một công cụ động hoạt động tại thời điểm chạy. Nó kiểm tra mã của bạn bằng cách chạy chương trình của bạn, hoặc bộ kiểm tra của nó, và phát hiện khi bạn vi phạm các quy tắc mà nó hiểu về cách Rust nên hoạt động.

Sử dụng Miri yêu cầu một bản dựng nightly của Rust (mà chúng ta nói thêm trong Phụ lục G: Rust được tạo ra như thế nào và "Rust Nightly"). Bạn có thể cài đặt cả phiên bản nightly của Rust và công cụ Miri bằng cách nhập rustup +nightly component add miri. Điều này không thay đổi phiên bản Rust mà dự án của bạn sử dụng; nó chỉ thêm công cụ vào hệ thống của bạn để bạn có thể sử dụng nó khi muốn. Bạn có thể chạy Miri trên một dự án bằng cách nhập cargo +nightly miri run hoặc cargo +nightly miri test.

Ví dụ về việc điều này có thể hữu ích như thế nào, hãy xem điều gì xảy ra khi chúng ta chạy nó với Listing 20-11.

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
COUNTER: 3

Miri chính xác cảnh báo chúng ta rằng chúng ta có các tham chiếu chia sẻ đến dữ liệu có thể thay đổi. Ở đây, Miri chỉ đưa ra cảnh báo vì điều này không được đảm bảo là hành vi không xác định trong trường hợp này, và nó không nói cho chúng ta cách khắc phục vấn đề. nhưng ít nhất chúng ta biết có rủi ro về hành vi không xác định và có thể suy nghĩ về cách làm cho mã an toàn. Trong một số trường hợp, Miri cũng có thể phát hiện các lỗi rõ ràng—các mẫu mã chắc chắn là sai—và đưa ra các khuyến nghị về cách khắc phục các lỗi đó.

Miri không bắt tất cả mọi thứ mà bạn có thể làm sai khi viết mã unsafe. Miri là một công cụ phân tích động, vì vậy nó chỉ bắt các vấn đề với mã thực sự được chạy. Điều đó có nghĩa là bạn sẽ cần sử dụng nó kết hợp với các kỹ thuật kiểm tra tốt để tăng sự tự tin về mã unsafe mà bạn đã viết. Miri cũng không bao gồm mọi cách có thể mà mã của bạn có thể không đúng.

Nói cách khác: Nếu Miri phát hiện một vấn đề, bạn biết có một lỗi, nhưng chỉ vì Miri không bắt được lỗi không có nghĩa là không có vấn đề. Tuy nhiên, nó có thể bắt được nhiều lỗi. Hãy thử chạy nó trên các ví dụ khác về mã unsafe trong chương này và xem nó nói gì!

Bạn có thể tìm hiểu thêm về Miri tại kho lưu trữ GitHub của nó.

Khi Nào Nên Sử Dụng Mã Unsafe

Việc sử dụng unsafe để sử dụng một trong năm siêu năng lực vừa thảo luận không sai hoặc thậm chí không bị coi là xấu, nhưng nó phức tạp hơn để có được mã unsafe chính xác vì trình biên dịch không thể giúp duy trì an toàn bộ nhớ. Khi bạn có lý do để sử dụng mã unsafe, bạn có thể làm như vậy, và việc có chú thích unsafe rõ ràng giúp dễ dàng theo dõi nguồn gốc của các vấn đề khi chúng xảy ra. Bất cứ khi nào bạn viết mã unsafe, bạn có thể sử dụng Miri để giúp bạn tự tin hơn rằng mã bạn đã viết tuân theo các quy tắc của Rust.

Để khám phá sâu hơn về cách làm việc hiệu quả với unsafe Rust, hãy đọc hướng dẫn chính thức của Rust về chủ đề này, Rustonomicon.

Trait Nâng Cao

Chúng ta đã đề cập đến trait trong "Trait: Định Nghĩa Hành Vi Chung" ở Chương 10, nhưng chúng ta chưa thảo luận về các chi tiết nâng cao hơn. Giờ đây khi bạn đã biết nhiều hơn về Rust, chúng ta có thể đi sâu vào chi tiết.

Kiểu Liên Kết (Associated Types)

Kiểu liên kết kết nối một placeholder kiểu với một trait để các định nghĩa phương thức của trait có thể sử dụng các placeholder kiểu này trong chữ ký của chúng. Người triển khai trait sẽ chỉ định kiểu cụ thể được sử dụng thay vì kiểu placeholder cho việc triển khai cụ thể. Bằng cách đó, chúng ta có thể định nghĩa một trait sử dụng một số kiểu mà không cần biết chính xác những kiểu đó là gì cho đến khi trait được triển khai.

Chúng ta đã mô tả hầu hết các tính năng nâng cao trong chương này là ít khi cần đến. Kiểu liên kết nằm đâu đó ở giữa: chúng được sử dụng ít thường xuyên hơn các tính năng được giải thích trong phần còn lại của cuốn sách nhưng phổ biến hơn nhiều tính năng khác được thảo luận trong chương này.

Một ví dụ về trait với kiểu liên kết là trait Iterator mà thư viện chuẩn cung cấp. Kiểu liên kết được đặt tên là Item và đại diện cho kiểu của các giá trị mà kiểu thực hiện trait Iterator đang lặp qua. Định nghĩa của trait Iterator được hiển thị trong Listing 20-13.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

Kiểu Item là một placeholder, và định nghĩa của phương thức next cho thấy nó sẽ trả về các giá trị kiểu Option<Self::Item>. Người triển khai trait Iterator sẽ chỉ định kiểu cụ thể cho Item, và phương thức next sẽ trả về một Option chứa giá trị của kiểu cụ thể đó.

Kiểu liên kết có thể giống với khái niệm generics, vì generics cũng cho phép chúng ta định nghĩa một hàm mà không cần chỉ định kiểu nào nó có thể xử lý. Để xem xét sự khác biệt giữa hai khái niệm này, chúng ta sẽ xem xét việc triển khai trait Iterator trên một kiểu có tên là Counter chỉ định kiểu Itemu32:

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

Cú pháp này có vẻ tương đương với generics. Vậy tại sao không chỉ định nghĩa trait Iterator với generics, như được hiển thị trong Listing 20-14?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

Sự khác biệt là khi sử dụng generics, như trong Listing 20-14, chúng ta phải chú thích các kiểu trong mỗi triển khai; bởi vì chúng ta cũng có thể triển khai Iterator<String> for Counter hoặc bất kỳ kiểu nào khác, chúng ta có thể có nhiều triển khai của Iterator cho Counter. Nói cách khác, khi một trait có tham số generic, nó có thể được triển khai cho một kiểu nhiều lần, thay đổi kiểu cụ thể của các tham số kiểu generic mỗi lần. Khi chúng ta sử dụng phương thức next trên Counter, chúng ta sẽ phải cung cấp chú thích kiểu để chỉ ra triển khai nào của Iterator chúng ta muốn sử dụng.

Với kiểu liên kết, chúng ta không cần phải chú thích kiểu vì chúng ta không thể triển khai một trait trên một kiểu nhiều lần. Trong Listing 20-13 với định nghĩa sử dụng kiểu liên kết, chúng ta chỉ có thể chọn kiểu của Item sẽ là gì một lần, vì chỉ có thể có một impl Iterator for Counter. Chúng ta không phải chỉ định rằng chúng ta muốn một iterator của các giá trị u32 ở mọi nơi mà chúng ta gọi next trên Counter.

Kiểu liên kết cũng trở thành một phần của hợp đồng trait: người triển khai trait phải cung cấp một kiểu để thay thế cho placeholder kiểu liên kết. Kiểu liên kết thường có tên mô tả cách kiểu sẽ được sử dụng, và việc ghi tài liệu cho kiểu liên kết trong tài liệu API là một thực hành tốt.

Tham Số Kiểu Generic Mặc Định và Nạp Chồng Toán Tử

Khi chúng ta sử dụng các tham số kiểu generic, chúng ta có thể chỉ định một kiểu cụ thể mặc định cho kiểu generic. Điều này loại bỏ nhu cầu cho người triển khai trait phải chỉ định một kiểu cụ thể nếu kiểu mặc định hoạt động. Bạn chỉ định một kiểu mặc định khi khai báo một kiểu generic với cú pháp <PlaceholderType=ConcreteType>.

Một ví dụ tuyệt vời về tình huống mà kỹ thuật này hữu ích là với nạp chồng toán tử, trong đó bạn tùy chỉnh hành vi của một toán tử (như +) trong các tình huống cụ thể.

Rust không cho phép bạn tạo các toán tử của riêng mình hoặc nạp chồng các toán tử tùy ý. Nhưng bạn có thể nạp chồng các hoạt động và các trait tương ứng được liệt kê trong std::ops bằng cách triển khai các trait liên quan đến toán tử. Ví dụ, trong Listing 20-15, chúng ta nạp chồng toán tử + để cộng hai thể hiện Point với nhau. Chúng ta làm điều này bằng cách triển khai trait Add trên struct Point.

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

Phương thức add cộng các giá trị x của hai thể hiện Point và các giá trị y của hai thể hiện Point để tạo ra một Point mới. Trait Add có một kiểu liên kết có tên là Output xác định kiểu trả về từ phương thức add.

Kiểu generic mặc định trong mã này nằm trong trait Add. Đây là định nghĩa của nó:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

Mã này có vẻ quen thuộc: một trait với một phương thức và một kiểu liên kết. Phần mới là Rhs=Self: cú pháp này được gọi là tham số kiểu mặc định. Tham số kiểu generic Rhs (viết tắt của "right-hand side") định nghĩa kiểu của tham số rhs trong phương thức add. Nếu chúng ta không chỉ định một kiểu cụ thể cho Rhs khi triển khai trait Add, kiểu của Rhs sẽ mặc định là Self, là kiểu mà chúng ta đang triển khai Add trên đó.

Khi chúng ta triển khai Add cho Point, chúng ta đã sử dụng giá trị mặc định cho Rhs bởi vì chúng ta muốn cộng hai thể hiện Point. Hãy xem xét một ví dụ về việc triển khai trait Add trong đó chúng ta muốn tùy chỉnh kiểu Rhs thay vì sử dụng giá trị mặc định.

Chúng ta có hai struct, MillimetersMeters, chứa các giá trị trong các đơn vị khác nhau. Việc bọc mỏng của một kiểu hiện có trong một struct khác được gọi là mẫu newtype, mà chúng ta mô tả chi tiết hơn trong phần "Sử Dụng Mẫu Newtype để Triển Khai Trait Bên Ngoài trên Kiểu Bên Ngoài". Chúng ta muốn cộng các giá trị tính bằng milimet với các giá trị tính bằng mét và có triển khai của Add thực hiện chuyển đổi một cách chính xác. Chúng ta có thể triển khai Add cho Millimeters với MetersRhs, như hiển thị trong Listing 20-16.

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

Để cộng MillimetersMeters, chúng ta chỉ định impl Add<Meters> để đặt giá trị của tham số kiểu Rhs thay vì sử dụng giá trị mặc định là Self.

Bạn sẽ sử dụng các tham số kiểu mặc định theo hai cách chính:

  1. Để mở rộng một kiểu mà không phá vỡ mã hiện có
  2. Để cho phép tùy chỉnh trong các trường hợp cụ thể mà hầu hết người dùng không cần

Trait Add của thư viện chuẩn là một ví dụ cho mục đích thứ hai: thông thường, bạn sẽ cộng hai kiểu giống nhau, nhưng trait Add cung cấp khả năng tùy chỉnh vượt ra ngoài điều đó. Sử dụng một tham số kiểu mặc định trong định nghĩa trait Add có nghĩa là bạn không phải chỉ định tham số bổ sung trong hầu hết các trường hợp. Nói cách khác, một chút mã soạn sẵn triển khai không cần thiết, làm cho việc sử dụng trait dễ dàng hơn.

Mục đích đầu tiên tương tự như mục đích thứ hai nhưng ngược lại: nếu bạn muốn thêm một tham số kiểu vào một trait hiện có, bạn có thể cho nó một giá trị mặc định để cho phép mở rộng chức năng của trait mà không phá vỡ mã triển khai hiện có.

Phân Biệt Giữa Các Phương Thức Có Cùng Tên

Không có gì trong Rust ngăn cản một trait có một phương thức có cùng tên với phương thức của trait khác, Rust cũng không ngăn bạn triển khai cả hai trait trên một kiểu. Cũng có thể triển khai một phương thức trực tiếp trên kiểu với cùng tên như các phương thức từ các trait.

Khi gọi các phương thức có cùng tên, bạn cần phải nói cho Rust biết bạn muốn sử dụng cái nào. Xem xét mã trong Listing 20-17 trong đó chúng ta đã định nghĩa hai trait, PilotWizard, cả hai đều có một phương thức được gọi là fly. Sau đó, chúng ta triển khai cả hai trait trên một kiểu Human đã có một phương thức tên là fly được triển khai trên nó. Mỗi phương thức fly làm một điều gì đó khác nhau.

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}

Khi chúng ta gọi fly trên một thể hiện của Human, trình biên dịch mặc định gọi phương thức được triển khai trực tiếp trên kiểu, như hiển thị trong Listing 20-18.

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

Chạy mã này sẽ in ra *waving arms furiously*, cho thấy Rust đã gọi phương thức fly được triển khai trực tiếp trên Human.

Để gọi các phương thức fly từ trait Pilot hoặc trait Wizard, chúng ta cần sử dụng cú pháp rõ ràng hơn để chỉ định phương thức fly nào chúng ta muốn. Listing 20-19 minh họa cú pháp này.

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

Chỉ định tên trait trước tên phương thức làm rõ cho Rust biết chúng ta muốn gọi triển khai fly nào. Chúng ta cũng có thể viết Human::fly(&person), tương đương với person.fly() mà chúng ta đã sử dụng trong Listing 20-19, nhưng điều này dài hơn một chút nếu chúng ta không cần phân biệt.

Chạy mã này in ra như sau:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

Bởi vì phương thức fly nhận một tham số self, nếu chúng ta có hai kiểu đều triển khai một trait, Rust có thể xác định triển khai nào của trait sẽ được sử dụng dựa trên kiểu của self.

Tuy nhiên, các hàm liên kết không phải là phương thức không có tham số self. Khi có nhiều kiểu hoặc trait định nghĩa các hàm không phải phương thức với cùng tên hàm, Rust không phải lúc nào cũng biết bạn muốn ý nghĩa kiểu nào trừ khi bạn sử dụng cú pháp đầy đủ. Ví dụ, trong Listing 20-20, chúng ta tạo một trait cho một nơi trú ẩn động vật muốn đặt tên cho tất cả chó con là Spot. Chúng ta tạo một trait Animal với một hàm liên kết không phải phương thức baby_name. Trait Animal được triển khai cho struct Dog, trên đó chúng ta cũng cung cấp một hàm liên kết không phải phương thức baby_name trực tiếp.

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

Chúng ta triển khai mã để đặt tên cho tất cả chó con là Spot trong hàm liên kết baby_name được định nghĩa trên Dog. Kiểu Dog cũng triển khai trait Animal, mô tả các đặc điểm mà tất cả động vật có. Chó con được gọi là puppies, và điều đó được thể hiện trong việc triển khai trait Animal trên Dog trong hàm baby_name liên kết với trait Animal.

Trong main, chúng ta gọi hàm Dog::baby_name, hàm này gọi hàm liên kết được định nghĩa trực tiếp trên Dog. Mã này in ra:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

Đầu ra này không phải là những gì chúng ta muốn. Chúng ta muốn gọi hàm baby_name là một phần của trait Animal mà chúng ta đã triển khai trên Dog để mã in ra A baby dog is called a puppy. Kỹ thuật chỉ định tên trait mà chúng ta đã sử dụng trong Listing 20-19 không giúp ích ở đây; nếu chúng ta thay đổi main thành mã trong Listing 20-21, chúng ta sẽ nhận được lỗi biên dịch.

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

Bởi vì Animal::baby_name không có tham số self, và có thể có các kiểu khác triển khai trait Animal, Rust không thể xác định triển khai Animal::baby_name nào chúng ta muốn. Chúng ta sẽ nhận được lỗi trình biên dịch này:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

Để loại bỏ sự không rõ ràng và nói cho Rust biết rằng chúng ta muốn sử dụng triển khai của Animal cho Dog thay vì triển khai của Animal cho một số kiểu khác, chúng ta cần sử dụng cú pháp đầy đủ. Listing 20-22 minh họa cách sử dụng cú pháp đầy đủ.

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

Chúng ta đang cung cấp cho Rust một chú thích kiểu trong dấu ngoặc nhọn, cho biết chúng ta muốn gọi phương thức baby_name từ trait Animal như được triển khai trên Dog bằng cách nói rằng chúng ta muốn coi kiểu Dog như một Animal cho lệnh gọi hàm này. Mã này giờ đây sẽ in ra những gì chúng ta muốn:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

Nhìn chung, cú pháp đầy đủ được định nghĩa như sau:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

Đối với các hàm liên kết không phải phương thức, sẽ không có receiver: sẽ chỉ có danh sách các đối số khác. Bạn có thể sử dụng cú pháp đầy đủ ở mọi nơi mà bạn gọi hàm hoặc phương thức. Tuy nhiên, bạn được phép bỏ qua bất kỳ phần nào của cú pháp này mà Rust có thể tìm ra từ thông tin khác trong chương trình. Bạn chỉ cần sử dụng cú pháp dài dòng hơn này trong các trường hợp có nhiều triển khai sử dụng cùng một tên và Rust cần trợ giúp để xác định triển khai nào bạn muốn gọi.

Sử Dụng Supertraits

Đôi khi bạn có thể viết một định nghĩa trait phụ thuộc vào một trait khác: để một kiểu triển khai trait đầu tiên, bạn muốn yêu cầu kiểu đó cũng phải triển khai trait thứ hai. Bạn sẽ làm điều này để định nghĩa trait của bạn có thể sử dụng các phần tử liên kết của trait thứ hai. Trait mà định nghĩa trait của bạn dựa vào được gọi là supertrait của trait của bạn.

Ví dụ, giả sử chúng ta muốn tạo một trait OutlinePrint với một phương thức outline_print sẽ in một giá trị đã cho được định dạng để nó được đóng khung bằng dấu hoa thị. Nghĩa là, với một struct Point triển khai trait Display của thư viện chuẩn để có kết quả là (x, y), khi chúng ta gọi outline_print trên một thể hiện Point1 cho x3 cho y, nó sẽ in ra:

**********
*        *
* (1, 3) *
*        *
**********

Trong việc triển khai phương thức outline_print, chúng ta muốn sử dụng chức năng của trait Display. Do đó, chúng ta cần chỉ định rằng trait OutlinePrint sẽ chỉ hoạt động cho các kiểu cũng triển khai Display và cung cấp chức năng mà OutlinePrint cần. Chúng ta có thể làm điều đó trong định nghĩa trait bằng cách chỉ định OutlinePrint: Display. Kỹ thuật này tương tự như việc thêm một ràng buộc trait cho trait. Listing 20-23 cho thấy một triển khai của trait OutlinePrint.

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}

Bởi vì chúng ta đã chỉ định rằng OutlinePrint yêu cầu trait Display, chúng ta có thể sử dụng hàm to_string được tự động triển khai cho bất kỳ kiểu nào triển khai Display. Nếu chúng ta cố gắng sử dụng to_string mà không thêm dấu hai chấm và chỉ định trait Display sau tên trait, chúng ta sẽ gặp lỗi nói rằng không tìm thấy phương thức nào có tên là to_string cho kiểu &Self trong phạm vi hiện tại.

Hãy xem điều gì xảy ra khi chúng ta cố gắng triển khai OutlinePrint trên một kiểu không triển khai Display, chẳng hạn như struct Point:

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Chúng ta nhận được lỗi nói rằng Display là cần thiết nhưng không được triển khai:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4  |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

Để sửa lỗi này, chúng ta triển khai Display trên Point và thỏa mãn ràng buộc mà OutlinePrint yêu cầu, như sau:

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Sau đó, việc triển khai trait OutlinePrint trên Point sẽ biên dịch thành công, và chúng ta có thể gọi outline_print trên một thể hiện Point để hiển thị nó trong một đường viền dấu hoa thị.

Sử Dụng Mẫu Newtype để Triển Khai Trait Bên Ngoài trên Kiểu Bên Ngoài

Trong "Triển Khai một Trait trên một Kiểu" ở Chương 10, chúng ta đã đề cập đến quy tắc orphan nói rằng chúng ta chỉ được phép triển khai một trait trên một kiểu nếu một trong hai trait hoặc kiểu, hoặc cả hai, là cục bộ đối với crate của chúng ta. Có thể vượt qua hạn chế này bằng cách sử dụng mẫu newtype, liên quan đến việc tạo một kiểu mới trong một tuple struct. (Chúng ta đã đề cập đến tuple struct trong "Sử Dụng Tuple Structs Không Có Trường Đặt Tên để Tạo Các Kiểu Khác Nhau" ở Chương 5.) Tuple struct sẽ có một trường và là một bản bọc mỏng xung quanh kiểu mà chúng ta muốn triển khai trait. Sau đó, kiểu bọc sẽ là cục bộ đối với crate của chúng ta, và chúng ta có thể triển khai trait trên bản bọc. Newtype là một thuật ngữ có nguồn gốc từ ngôn ngữ lập trình Haskell. Không có mất mát hiệu suất thời gian chạy khi sử dụng mẫu này, và kiểu bọc được loại bỏ tại thời điểm biên dịch.

Ví dụ, giả sử chúng ta muốn triển khai Display trên Vec<T>, mà quy tắc orphan ngăn chúng ta làm trực tiếp vì trait Display và kiểu Vec<T> được định nghĩa bên ngoài crate của chúng ta. Chúng ta có thể tạo một struct Wrapper chứa một thể hiện của Vec<T>; sau đó chúng ta có thể triển khai Display trên Wrapper và sử dụng giá trị Vec<T>, như hiển thị trong Listing 20-24.

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}

Triển khai của Display sử dụng self.0 để truy cập Vec<T> bên trong, vì Wrapper là một tuple struct và Vec<T> là phần tử ở chỉ số 0 trong tuple. Sau đó, chúng ta có thể sử dụng chức năng của trait Display trên Wrapper.

Nhược điểm của việc sử dụng kỹ thuật này là Wrapper là một kiểu mới, vì vậy nó không có các phương thức của giá trị mà nó đang giữ. Chúng ta sẽ phải triển khai trực tiếp tất cả các phương thức của Vec<T> trên Wrapper sao cho các phương thức ủy quyền cho self.0, điều này sẽ cho phép chúng ta đối xử với Wrapper chính xác như một Vec<T>. Nếu chúng ta muốn kiểu mới có mọi phương thức mà kiểu bên trong có, việc triển khai trait Deref trên Wrapper để trả về kiểu bên trong sẽ là một giải pháp (chúng ta đã thảo luận về việc triển khai trait Deref trong "Đối Xử với Smart Pointers Như Tham Chiếu Thông Thường với Trait Deref" ở Chương 15). Nếu chúng ta không muốn kiểu Wrapper có tất cả các phương thức của kiểu bên trong — ví dụ, để hạn chế hành vi của kiểu Wrapper — chúng ta sẽ phải tự triển khai các phương thức mà chúng ta muốn.

Mẫu newtype này cũng hữu ích ngay cả khi các trait không liên quan. Hãy chuyển sang xem xét một số cách tiên tiến để tương tác với hệ thống kiểu của Rust.

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 MillimetersMeters 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 MillimetersMeters 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ì Kilometersi32 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ị Kilometersi32 ở đâ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 đó Estd::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 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 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 guessu32.

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 Tpanic! có kiểu !, vì vậy kết quả của toàn bộ biểu thức matchT. 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 s1s2&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 &strhai 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!

Hàm và Closure Nâng Cao

Phần này khám phá một số tính năng nâng cao liên quan đến hàm và closure, bao gồm con trỏ hàm và trả về closure.

Con Trỏ Hàm

Chúng ta đã nói về cách truyền closure cho các hàm; bạn cũng có thể truyền các hàm thông thường cho hàm! Kỹ thuật này hữu ích khi bạn muốn truyền một hàm bạn đã định nghĩa thay vì định nghĩa một closure mới. Hàm được ép kiểu thành kiểu fn (với f viết thường), không nên nhầm lẫn với trait closure Fn. Kiểu fn được gọi là con trỏ hàm. Truyền hàm với con trỏ hàm sẽ cho phép bạn sử dụng hàm làm đối số cho các hàm khác.

Cú pháp để chỉ định rằng một tham số là con trỏ hàm tương tự như của closure, như được hiển thị trong Listing 20-28, nơi chúng ta đã định nghĩa một hàm add_one để cộng 1 vào tham số của nó. Hàm do_twice nhận hai tham số: một con trỏ hàm tới bất kỳ hàm nào nhận một tham số i32 và trả về một i32, và một giá trị i32. Hàm do_twice gọi hàm f hai lần, truyền cho nó giá trị arg, sau đó cộng hai kết quả gọi hàm lại với nhau. Hàm main gọi do_twice với các đối số add_one5.

fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}

Mã này in ra The answer is: 12. Chúng ta chỉ định rằng tham số f trong do_twice là một fn nhận một tham số kiểu i32 và trả về một i32. Sau đó, chúng ta có thể gọi f trong thân hàm của do_twice. Trong main, chúng ta có thể truyền tên hàm add_one làm đối số đầu tiên cho do_twice.

Không giống như closure, fn là một kiểu chứ không phải một trait, vì vậy chúng ta chỉ định fn làm kiểu tham số trực tiếp thay vì khai báo một tham số kiểu generic với một trong các trait Fn làm ràng buộc trait.

Con trỏ hàm triển khai cả ba trait closure (Fn, FnMut, và FnOnce), có nghĩa là bạn luôn có thể truyền con trỏ hàm làm đối số cho một hàm mà mong đợi một closure. Tốt nhất là viết các hàm sử dụng một kiểu generic và một trong các trait closure để hàm của bạn có thể chấp nhận cả hàm hoặc closure.

Dù vậy, một ví dụ về trường hợp bạn muốn chỉ chấp nhận fn và không chấp nhận closure là khi giao tiếp với mã bên ngoài không có closure: Các hàm C có thể chấp nhận hàm làm đối số, nhưng C không có closure.

Để xem ví dụ về nơi bạn có thể sử dụng một closure được định nghĩa inline hoặc một hàm có tên, hãy xem xét việc sử dụng phương thức map được cung cấp bởi trait Iterator trong thư viện chuẩn. Để sử dụng phương thức map để biến một vector số thành một vector chuỗi, chúng ta có thể sử dụng một closure, như trong Listing 20-29.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}

Hoặc chúng ta có thể đặt tên một hàm làm đối số cho map thay vì closure. Listing 20-30 cho thấy điều này sẽ trông như thế nào.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}

Lưu ý rằng chúng ta phải sử dụng cú pháp đầy đủ mà chúng ta đã nói trong "Trait Nâng Cao" bởi vì có nhiều hàm có sẵn có tên là to_string.

Ở đây, chúng ta đang sử dụng hàm to_string được định nghĩa trong trait ToString, mà thư viện chuẩn đã triển khai cho bất kỳ kiểu nào triển khai Display.

Nhớ lại từ "Giá trị Enum" trong Chương 6 rằng tên của mỗi biến thể enum mà chúng ta định nghĩa cũng trở thành một hàm khởi tạo. Chúng ta có thể sử dụng các hàm khởi tạo này làm con trỏ hàm triển khai các trait closure, điều này có nghĩa là chúng ta có thể chỉ định các hàm khởi tạo làm đối số cho các phương thức nhận closure, như được thấy trong Listing 20-31.

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}

Ở đây chúng ta tạo các thể hiện Status::Value bằng cách sử dụng mỗi giá trị u32 trong phạm vi mà map được gọi bằng cách sử dụng hàm khởi tạo của Status::Value. Một số người thích phong cách này và một số người thích sử dụng closure. Chúng được biên dịch thành cùng một mã, vì vậy hãy sử dụng phong cách nào rõ ràng hơn với bạn.

Trả Về Closure

Closure được biểu diễn bởi các trait, có nghĩa là bạn không thể trả về closure trực tiếp. Trong hầu hết các trường hợp mà bạn có thể muốn trả về một trait, bạn có thể thay vào đó sử dụng kiểu cụ thể triển khai trait đó làm giá trị trả về của hàm. Tuy nhiên, bạn thường không thể làm điều đó với closure vì chúng không có kiểu cụ thể có thể trả về. Bạn không được phép sử dụng con trỏ hàm fn làm kiểu trả về nếu closure bắt bất kỳ giá trị nào từ phạm vi của nó, chẳng hạn.

Thay vào đó, bạn thường sẽ sử dụng cú pháp impl Trait mà chúng ta đã học trong Chương 10. Bạn có thể trả về bất kỳ kiểu hàm nào, sử dụng Fn, FnOnceFnMut. Ví dụ, mã trong Listing 20-32 sẽ hoạt động tốt.

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}

Tuy nhiên, như chúng ta đã lưu ý trong "Suy Luận Kiểu và Chú Thích Closure" trong Chương 13, mỗi closure cũng là kiểu riêng biệt của nó. Nếu bạn cần làm việc với nhiều hàm có cùng chữ ký nhưng các triển khai khác nhau, bạn sẽ cần sử dụng đối tượng trait cho chúng. Hãy xem xét điều gì xảy ra nếu bạn viết mã như trong Listing 20-33.

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}

Ở đây chúng ta có hai hàm, returns_closurereturns_initialized_closure, cả hai đều trả về impl Fn(i32) -> i32. Lưu ý rằng các closure mà chúng trả về là khác nhau, mặc dù chúng triển khai cùng một kiểu. Nếu chúng ta cố gắng biên dịch điều này, Rust cho chúng ta biết rằng nó sẽ không hoạt động:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
2  |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9  | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:9:25>)
              found opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:13:46>)
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions-example` (bin "functions-example") due to 1 previous error

Thông báo lỗi cho chúng ta biết rằng bất cứ khi nào chúng ta trả về một impl Trait, Rust tạo ra một kiểu mờ duy nhất, một kiểu mà chúng ta không thể nhìn vào chi tiết của những gì Rust xây dựng cho chúng ta. Vì vậy, mặc dù cả hai hàm này đều trả về closure triển khai cùng một trait, Fn(i32) -> i32, các kiểu mờ mà Rust tạo ra cho mỗi hàm là khác biệt. (Điều này tương tự như cách Rust tạo ra các kiểu cụ thể khác nhau cho các khối async riêng biệt ngay cả khi chúng có cùng kiểu đầu ra, như chúng ta đã thấy trong "Làm việc với Bất kỳ Số Lượng Futures" trong Chương 17. Chúng ta đã thấy một giải pháp cho vấn đề này vài lần: chúng ta có thể sử dụng một đối tượng trait, như trong Listing 20-34.

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}

Mã này sẽ biên dịch tốt. Để biết thêm về đối tượng trait, hãy tham khảo phần "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.

Tiếp theo, hãy xem xét các macro!

Macro

Chúng ta đã sử dụng các macro như println! trong suốt cuốn sách này, nhưng chúng ta chưa khám phá đầy đủ về macro là gì và cách nó hoạt động. Thuật ngữ macro đề cập đến một họ các tính năng trong Rust: macro khai báo với macro_rules! và ba loại macro thủ tục:

  • Macro #[derive] tùy chỉnh chỉ định mã được thêm vào với thuộc tính derive được sử dụng trên các struct và enum
  • Macro giống thuộc tính định nghĩa các thuộc tính tùy chỉnh có thể sử dụng trên bất kỳ mục nào
  • Macro giống hàm trông giống như lời gọi hàm nhưng hoạt động trên các token được chỉ định làm đối số của chúng

Chúng ta sẽ nói về từng loại này lần lượt, nhưng trước tiên, hãy xem tại sao chúng ta thậm chí cần macro khi chúng ta đã có hàm.

Sự Khác Biệt Giữa Macro và Hàm

Về cơ bản, macro là một cách để viết mã tạo ra mã khác, điều này được gọi là lập trình meta. Trong Phụ lục C, chúng ta thảo luận về thuộc tính derive, tạo ra một triển khai của các trait khác nhau cho bạn. Chúng ta cũng đã sử dụng các macro println!vec! trong suốt cuốn sách. Tất cả các macro này mở rộng để tạo ra nhiều mã hơn so với mã bạn đã viết thủ công.

Lập trình meta hữu ích để giảm lượng mã bạn phải viết và bảo trì, đây cũng là một trong những vai trò của hàm. Tuy nhiên, macro có một số sức mạnh bổ sung mà hàm không có.

Chữ ký hàm phải khai báo số lượng và kiểu của các tham số mà hàm có. Ngược lại, macro có thể nhận một số lượng tham số thay đổi: chúng ta có thể gọi println!("hello") với một đối số hoặc println!("hello {}", name) với hai đối số. Ngoài ra, macro được mở rộng trước khi trình biên dịch diễn giải ý nghĩa của mã, vì vậy một macro có thể, ví dụ, triển khai một trait trên một kiểu nhất định. Một hàm không thể làm điều này, vì nó được gọi trong thời gian chạy và một trait cần được triển khai tại thời điểm biên dịch.

Nhược điểm của việc triển khai một macro thay vì một hàm là định nghĩa macro phức tạp hơn định nghĩa hàm vì bạn đang viết mã Rust để viết mã Rust. Do sự gián tiếp này, định nghĩa macro thường khó đọc, khó hiểu và khó bảo trì hơn định nghĩa hàm.

Một sự khác biệt quan trọng khác giữa macro và hàm là bạn phải định nghĩa macro hoặc đưa chúng vào phạm vi trước khi bạn gọi chúng trong một tệp, trái ngược với các hàm mà bạn có thể định nghĩa ở bất kỳ đâu và gọi ở bất kỳ đâu.

Macro Khai Báo với macro_rules! cho Lập Trình Meta Tổng Quát

Hình thức macro được sử dụng rộng rãi nhất trong Rust là macro khai báo. Đôi khi chúng còn được gọi là "macro bằng ví dụ," "macro macro_rules!," hoặc đơn giản là "macro." Về cơ bản, macro khai báo cho phép bạn viết một cái gì đó tương tự như một biểu thức match của Rust. Như đã thảo luận trong Chương 6, biểu thức match là cấu trúc điều khiển nhận một biểu thức, so sánh giá trị kết quả của biểu thức với các mẫu, và sau đó chạy mã liên kết với mẫu phù hợp. Macro cũng so sánh một giá trị với các mẫu được liên kết với mã cụ thể: trong tình huống này, giá trị là mã nguồn Rust theo nghĩa đen được truyền cho macro; các mẫu được so sánh với cấu trúc của mã nguồn đó; và mã liên kết với mỗi mẫu, khi khớp, thay thế mã được truyền cho macro. Tất cả điều này diễn ra trong quá trình biên dịch.

Để định nghĩa một macro, bạn sử dụng cấu trúc macro_rules!. Hãy khám phá cách sử dụng macro_rules! bằng cách xem cách định nghĩa macro vec!. Chương 8 đã đề cập đến cách chúng ta có thể sử dụng macro vec! để tạo một vector mới với các giá trị cụ thể. Ví dụ, macro sau tạo một vector mới chứa ba số nguyên:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

Chúng ta cũng có thể sử dụng macro vec! để tạo một vector của hai số nguyên hoặc một vector của năm slice chuỗi. Chúng ta sẽ không thể sử dụng một hàm để làm điều tương tự vì chúng ta sẽ không biết số lượng hoặc kiểu giá trị trước.

Listing 20-35 hiển thị một định nghĩa hơi đơn giản hóa của macro vec!.

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

Lưu ý: Định nghĩa thực tế của macro vec! trong thư viện chuẩn bao gồm mã để cấp phát trước đúng lượng bộ nhớ. Mã đó là một tối ưu hóa mà chúng ta không bao gồm ở đây, để làm cho ví dụ đơn giản hơn.

Chú thích #[macro_export] chỉ ra rằng macro này nên được làm cho có sẵn bất cứ khi nào crate mà macro được định nghĩa trong đó được đưa vào phạm vi. Nếu không có chú thích này, macro không thể được đưa vào phạm vi.

Sau đó, chúng ta bắt đầu định nghĩa macro với macro_rules! và tên của macro chúng ta đang định nghĩa không có dấu chấm than. Tên, trong trường hợp này là vec, được theo sau bởi dấu ngoặc nhọn biểu thị thân của định nghĩa macro.

Cấu trúc trong thân vec! tương tự như cấu trúc của một biểu thức match. Ở đây, chúng ta có một nhánh với mẫu ( $( $x:expr ),* ), theo sau là => và khối mã liên kết với mẫu này. Nếu mẫu khớp, khối mã liên kết sẽ được phát ra. Vì đây là mẫu duy nhất trong macro này, chỉ có một cách hợp lệ để khớp; bất kỳ mẫu nào khác sẽ dẫn đến lỗi. Các macro phức tạp hơn sẽ có nhiều hơn một nhánh.

Cú pháp mẫu hợp lệ trong định nghĩa macro khác với cú pháp mẫu được đề cập trong Chương 19 vì các mẫu macro được khớp với cấu trúc mã Rust chứ không phải giá trị. Hãy đi qua ý nghĩa của các phần mẫu trong Listing 20-29; để biết cú pháp mẫu macro đầy đủ, hãy xem Tài liệu tham khảo Rust.

Đầu tiên, chúng ta sử dụng một bộ dấu ngoặc đơn để bao quanh toàn bộ mẫu. Chúng ta sử dụng dấu đô la ($) để khai báo một biến trong hệ thống macro sẽ chứa mã Rust khớp với mẫu. Dấu đô la làm rõ đây là một biến macro chứ không phải một biến Rust thông thường. Tiếp theo là một bộ dấu ngoặc đơn bắt các giá trị khớp với mẫu trong dấu ngoặc đơn để sử dụng trong mã thay thế. Trong $()$x:expr, khớp với bất kỳ biểu thức Rust nào và gán cho biểu thức tên $x.

Dấu phẩy sau $() chỉ ra rằng một ký tự phân cách dấu phẩy theo nghĩa đen phải xuất hiện giữa mỗi thể hiện của mã khớp với mã trong $(). Dấu * chỉ định rằng mẫu khớp với zero hoặc nhiều của bất cứ thứ gì đứng trước dấu *.

Khi chúng ta gọi macro này với vec![1, 2, 3];, mẫu $x khớp ba lần với ba biểu thức 1, 2, và 3.

Bây giờ, hãy nhìn vào mẫu trong phần thân của mã liên kết với nhánh này: temp_vec.push() trong $()* được tạo ra cho mỗi phần khớp với $() trong mẫu zero hoặc nhiều lần tùy thuộc vào số lần mẫu khớp. Biến $x được thay thế bằng mỗi biểu thức khớp. Khi chúng ta gọi macro này với vec![1, 2, 3];, mã được tạo ra thay thế lời gọi macro này sẽ là như sau:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

Chúng ta đã định nghĩa một macro có thể nhận bất kỳ số lượng đối số nào với bất kỳ kiểu nào và có thể tạo mã để tạo một vector chứa các phần tử được chỉ định.

Để tìm hiểu thêm về cách viết macro, hãy tham khảo tài liệu trực tuyến hoặc các tài nguyên khác, chẳng hạn như "The Little Book of Rust Macros" do Daniel Keep bắt đầu và Lukas Wirth tiếp tục.

Macro Thủ Tục cho Tạo Mã từ Thuộc Tính

Hình thức thứ hai của macro là macro thủ tục, hoạt động giống như một hàm (và là một loại thủ tục). Macro thủ tục chấp nhận một số mã làm đầu vào, hoạt động trên mã đó, và tạo ra một số mã làm đầu ra thay vì khớp với các mẫu và thay thế mã bằng mã khác như macro khai báo. Ba loại macro thủ tục là derive tùy chỉnh, giống thuộc tính, và giống hàm, và tất cả đều hoạt động theo cách tương tự.

Khi tạo macro thủ tục, các định nghĩa phải nằm trong crate riêng của chúng với một loại crate đặc biệt. Điều này là do lý do kỹ thuật phức tạp mà chúng tôi hy vọng sẽ loại bỏ trong tương lai. Trong Listing 20-36, chúng tôi cho thấy cách định nghĩa một macro thủ tục, trong đó some_attribute là một placeholder cho việc sử dụng một biến thể macro cụ thể.

use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

Hàm định nghĩa một macro thủ tục nhận một TokenStream làm đầu vào và tạo ra một TokenStream làm đầu ra. Kiểu TokenStream được định nghĩa bởi crate proc_macro được bao gồm với Rust và đại diện cho một chuỗi các token. Đây là cốt lõi của macro: mã nguồn mà macro đang hoạt động trên tạo thành TokenStream đầu vào, và mã mà macro tạo ra là TokenStream đầu ra. Hàm cũng có một thuộc tính được gắn vào nó chỉ định loại macro thủ tục nào chúng ta đang tạo. Chúng ta có thể có nhiều loại macro thủ tục trong cùng một crate.

Hãy xem xét các loại macro thủ tục khác nhau. Chúng ta sẽ bắt đầu với một macro derive tùy chỉnh và sau đó giải thích sự khác biệt nhỏ làm cho các hình thức khác khác nhau.

Cách Viết Một Macro derive Tùy Chỉnh

Hãy tạo một crate có tên hello_macro định nghĩa một trait có tên HelloMacro với một hàm liên kết có tên hello_macro. Thay vì yêu cầu người dùng của chúng ta triển khai trait HelloMacro cho từng kiểu của họ, chúng ta sẽ cung cấp một macro thủ tục để người dùng có thể chú thích kiểu của họ với #[derive(HelloMacro)] để có được một triển khai mặc định của hàm hello_macro. Triển khai mặc định sẽ in Hello, Macro! My name is TypeName! trong đó TypeName là tên của kiểu mà trait này đã được định nghĩa trên. Nói cách khác, chúng ta sẽ viết một crate cho phép một lập trình viên khác viết mã như Listing 20-37 sử dụng crate của chúng ta.

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

Mã này sẽ in Hello, Macro! My name is Pancakes! khi chúng ta hoàn thành. Bước đầu tiên là tạo một crate thư viện mới, như sau:

$ cargo new hello_macro --lib

Tiếp theo, chúng ta sẽ định nghĩa trait HelloMacro và hàm liên kết của nó:

pub trait HelloMacro {
    fn hello_macro();
}

Chúng ta có một trait và hàm của nó. Tại thời điểm này, người dùng crate của chúng ta có thể triển khai trait để đạt được chức năng mong muốn, như trong Listing 20-39.

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

Tuy nhiên, họ sẽ cần phải viết khối triển khai cho mỗi kiểu họ muốn sử dụng với hello_macro; chúng ta muốn giúp họ không phải làm công việc này.

Ngoài ra, chúng ta chưa thể cung cấp hàm hello_macro với triển khai mặc định sẽ in tên của kiểu mà trait được triển khai: Rust không có khả năng phản ánh, vì vậy nó không thể tra cứu tên kiểu tại thời điểm chạy. Chúng ta cần một macro để tạo mã tại thời điểm biên dịch.

Bước tiếp theo là định nghĩa macro thủ tục. Tại thời điểm viết bài này, macro thủ tục cần phải nằm trong crate riêng của chúng. Cuối cùng, hạn chế này có thể được dỡ bỏ. Quy ước cho việc cấu trúc các crate và crate macro là như sau: đối với một crate có tên foo, một crate macro thủ tục derive tùy chỉnh được gọi là foo_derive. Hãy bắt đầu một crate mới có tên hello_macro_derive bên trong dự án hello_macro của chúng ta:

$ cargo new hello_macro_derive --lib

Hai crate của chúng ta có liên quan chặt chẽ với nhau, vì vậy chúng ta tạo crate macro thủ tục trong thư mục của crate hello_macro. Nếu chúng ta thay đổi định nghĩa trait trong hello_macro, chúng ta cũng sẽ phải thay đổi triển khai của macro thủ tục trong hello_macro_derive. Hai crate sẽ cần được xuất bản riêng biệt, và các lập trình viên sử dụng các crate này sẽ cần thêm cả hai làm phụ thuộc và đưa cả hai vào phạm vi. Chúng ta có thể thay vào đó cho crate hello_macro sử dụng hello_macro_derive làm phụ thuộc và tái xuất mã macro thủ tục. Tuy nhiên, cách chúng ta đã cấu trúc dự án làm cho các lập trình viên có thể sử dụng hello_macro ngay cả khi họ không muốn chức năng derive.

Chúng ta cần khai báo crate hello_macro_derive là một crate macro thủ tục. Chúng ta cũng sẽ cần chức năng từ các crate synquote, như bạn sẽ thấy sau đây, vì vậy chúng ta cần thêm chúng làm phụ thuộc. Thêm phần sau vào tệp Cargo.toml cho hello_macro_derive:

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

Để bắt đầu định nghĩa macro thủ tục, đặt mã trong Listing 20-40 vào tệp src/lib.rs của bạn cho crate hello_macro_derive. Lưu ý rằng mã này sẽ không biên dịch cho đến khi chúng ta thêm một định nghĩa cho hàm impl_hello_macro.

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}

Lưu ý rằng chúng ta đã chia mã thành hàm hello_macro_derive, chịu trách nhiệm phân tích TokenStream, và hàm impl_hello_macro, chịu trách nhiệm chuyển đổi cây cú pháp: điều này làm cho việc viết một macro thủ tục thuận tiện hơn. Mã trong hàm ngoài (trong trường hợp này là hello_macro_derive) sẽ giống nhau cho hầu hết mọi crate macro thủ tục mà bạn thấy hoặc tạo. Mã mà bạn chỉ định trong thân hàm bên trong (trong trường hợp này là impl_hello_macro) sẽ khác nhau tùy thuộc vào mục đích của macro thủ tục của bạn.

Chúng ta đã giới thiệu ba crate mới: proc_macro, syn, và quote. Crate proc_macro đi kèm với Rust, vì vậy chúng ta không cần thêm nó vào phụ thuộc trong Cargo.toml. Crate proc_macro là API của trình biên dịch cho phép chúng ta đọc và thao tác mã Rust từ mã của chúng ta.

Crate syn phân tích mã Rust từ một chuỗi thành một cấu trúc dữ liệu mà chúng ta có thể thực hiện các thao tác. Crate quote chuyển đổi các cấu trúc dữ liệu syn trở lại mã Rust. Các crate này làm cho việc phân tích bất kỳ loại mã Rust nào mà chúng ta có thể muốn xử lý trở nên đơn giản hơn nhiều: viết một trình phân tích cú pháp đầy đủ cho mã Rust không phải là một nhiệm vụ đơn giản.

Hàm hello_macro_derive sẽ được gọi khi người dùng của thư viện của chúng ta chỉ định #[derive(HelloMacro)] trên một kiểu. Điều này có thể vì chúng ta đã chú thích hàm hello_macro_derive ở đây với proc_macro_derive và chỉ định tên HelloMacro, khớp với tên trait của chúng ta; đây là quy ước mà hầu hết các macro thủ tục tuân theo.

Hàm hello_macro_derive đầu tiên chuyển đổi input từ một TokenStream thành một cấu trúc dữ liệu mà sau đó chúng ta có thể giải thích và thực hiện các thao tác. Đây là nơi syn phát huy tác dụng. Hàm parse trong syn nhận một TokenStream và trả về một cấu trúc DeriveInput đại diện cho mã Rust đã được phân tích. Listing 20-41 hiển thị các phần liên quan của cấu trúc DeriveInput mà chúng ta nhận được khi phân tích chuỗi struct Pancakes;.

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

Các trường của cấu trúc này cho thấy rằng mã Rust mà chúng ta đã phân tích là một struct đơn vị với ident (định danh, nghĩa là tên) là Pancakes. Có nhiều trường hơn trên cấu trúc này để mô tả tất cả các loại mã Rust; kiểm tra tài liệu syn cho DeriveInput để biết thêm thông tin.

Chẳng bao lâu nữa chúng ta sẽ định nghĩa hàm impl_hello_macro, nơi mà chúng ta sẽ xây dựng mã Rust mới mà chúng ta muốn bao gồm. Nhưng trước khi chúng ta làm điều đó, hãy lưu ý rằng đầu ra cho macro derive của chúng ta cũng là một TokenStream. TokenStream trả về được thêm vào mã mà người dùng crate của chúng ta viết, vì vậy khi họ biên dịch crate của họ, họ sẽ nhận được chức năng bổ sung mà chúng ta cung cấp trong TokenStream đã được sửa đổi.

Bạn có thể đã nhận thấy rằng chúng ta gọi unwrap để khiến hàm hello_macro_derive panic nếu lời gọi hàm syn::parse không thành công ở đây. Cần thiết cho macro thủ tục của chúng ta panic về lỗi vì các hàm proc_macro_derive phải trả về TokenStream chứ không phải Result để phù hợp với API macro thủ tục. Chúng ta đã đơn giản hóa ví dụ này bằng cách sử dụng unwrap; trong mã sản xuất, bạn nên cung cấp thông báo lỗi cụ thể hơn về những gì đã xảy ra sai bằng cách sử dụng panic! hoặc expect.

Bây giờ chúng ta đã có mã để chuyển đổi mã Rust được chú thích từ một TokenStream thành một thể hiện DeriveInput, hãy tạo mã triển khai trait HelloMacro trên kiểu được chú thích, như hiển thị trong Listing 20-42.

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}

Chúng ta nhận được một thể hiện của cấu trúc Ident chứa tên (định danh) của kiểu được chú thích bằng cách sử dụng ast.ident. Cấu trúc trong Listing 20-33 cho thấy rằng khi chúng ta chạy hàm impl_hello_macro trên mã trong Listing 20-31, ident mà chúng ta nhận được sẽ có trường ident với giá trị là "Pancakes". Do đó, biến name trong Listing 20-34 sẽ chứa một thể hiện của cấu trúc Ident mà, khi được in ra, sẽ là chuỗi "Pancakes", tên của struct trong Listing 20-37.

Macro quote! cho phép chúng ta định nghĩa mã Rust mà chúng ta muốn trả về. Trình biên dịch mong đợi một thứ khác với kết quả trực tiếp của thực thi macro quote!, vì vậy chúng ta cần chuyển đổi nó thành một TokenStream. Chúng ta làm điều này bằng cách gọi phương thức into, tiêu thụ biểu diễn trung gian này và trả về một giá trị của kiểu TokenStream cần thiết.

Macro quote! cũng cung cấp một số cơ chế mẫu rất tuyệt: chúng ta có thể nhập #name, và quote! sẽ thay thế nó bằng giá trị trong biến name. Bạn thậm chí có thể làm một số lặp lại tương tự như cách hoạt động của các macro thông thường. Kiểm tra tài liệu của crate quote để có một giới thiệu kỹ lưỡng.

Chúng ta muốn macro thủ tục của chúng ta tạo ra một triển khai của trait HelloMacro cho kiểu mà người dùng đã chú thích, đó là những gì chúng ta có thể nhận được bằng cách sử dụng #name. Việc triển khai trait có một hàm hello_macro, thân của nó chứa chức năng mà chúng ta muốn cung cấp: in Hello, Macro! My name is và sau đó là tên của kiểu được chú thích.

Macro stringify! được sử dụng ở đây là được tích hợp sẵn trong Rust. Nó lấy một biểu thức Rust, chẳng hạn như 1 + 2, và vào thời điểm biên dịch chuyển đổi biểu thức thành một chuỗi theo nghĩa đen, chẳng hạn như "1 + 2". Điều này khác với format! hoặc println!, các macro đánh giá biểu thức và sau đó chuyển đổi kết quả thành một String. Có khả năng là đầu vào #name có thể là một biểu thức để in ra theo nghĩa đen, vì vậy chúng ta sử dụng stringify!. Sử dụng stringify! cũng tiết kiệm một phân bổ bằng cách chuyển đổi #name thành một chuỗi theo nghĩa đen vào thời điểm biên dịch.

Tại thời điểm này, cargo build nên hoàn thành thành công trong cả hello_macrohello_macro_derive. Hãy kết nối các crate này với mã trong Listing 20-31 để xem macro thủ tục hoạt động! Tạo một dự án nhị phân mới trong thư mục projects của bạn bằng cách sử dụng cargo new pancakes. Chúng ta cần thêm hello_macrohello_macro_derive làm phụ thuộc trong crate pancakes của Cargo.toml. Nếu bạn đang xuất bản phiên bản của hello_macrohello_macro_derive lên crates.io, chúng sẽ là phụ thuộc thông thường; nếu không, bạn có thể chỉ định chúng như phụ thuộc path như sau:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

Đặt mã trong Listing 20-37 vào src/main.rs, và chạy cargo run: nó sẽ in Hello, Macro! My name is Pancakes! Việc triển khai trait HelloMacro từ macro thủ tục đã được bao gồm mà không cần crate pancakes phải triển khai nó; #[derive(HelloMacro)] đã thêm việc triển khai trait.

Tiếp theo, hãy khám phá cách các loại macro thủ tục khác khác với macro derive tùy chỉnh.

Macro Giống Thuộc Tính

Macro giống thuộc tính tương tự như macro derive tùy chỉnh, nhưng thay vì tạo mã cho thuộc tính derive, chúng cho phép bạn tạo thuộc tính mới. Chúng cũng linh hoạt hơn: derive chỉ hoạt động cho struct và enum; thuộc tính có thể được áp dụng cho các mục khác, chẳng hạn như hàm. Đây là một ví dụ về việc sử dụng một macro giống thuộc tính. Giả sử bạn có một thuộc tính tên là route chú thích các hàm khi sử dụng một framework ứng dụng web:

#[route(GET, "/")]
fn index() {

Thuộc tính #[route] này sẽ được định nghĩa bởi framework như một macro thủ tục. Chữ ký của hàm định nghĩa macro sẽ trông như thế này:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

Tại đây, chúng ta có hai tham số kiểu TokenStream. Tham số đầu tiên là cho nội dung của thuộc tính: phần GET, "/". Tham số thứ hai là cho phần thân của mục mà thuộc tính được gắn vào: trong trường hợp này, fn index() {} và phần còn lại của thân hàm.

Ngoài ra, macro giống thuộc tính hoạt động theo cách tương tự như macro derive tùy chỉnh: bạn tạo một crate với loại crate proc-macro và triển khai một hàm tạo ra mã bạn muốn!

Macro Giống Hàm

Macro giống hàm định nghĩa các macro trông giống như lời gọi hàm. Tương tự như macro macro_rules!, chúng linh hoạt hơn các hàm; ví dụ, chúng có thể nhận một số lượng đối số không xác định. Tuy nhiên, macro macro_rules! chỉ có thể được định nghĩa bằng cách sử dụng cú pháp giống match mà chúng ta đã thảo luận trong "Macro Khai Báo với macro_rules! cho Lập Trình Meta Tổng Quát" trước đó. Macro giống hàm nhận một tham số TokenStream và định nghĩa của chúng thao tác trên TokenStream đó bằng mã Rust giống như hai loại macro thủ tục khác. Một ví dụ về một macro giống hàm là macro sql! có thể được gọi như sau:

let sql = sql!(SELECT * FROM posts WHERE id=1);

Macro này sẽ phân tích câu lệnh SQL bên trong nó và kiểm tra xem nó có đúng cú pháp hay không, đây là quá trình xử lý phức tạp hơn nhiều so với những gì một macro macro_rules! có thể làm. Macro sql! sẽ được định nghĩa như sau:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

Định nghĩa này tương tự như chữ ký của macro derive tùy chỉnh: chúng ta nhận các token nằm trong dấu ngoặc đơn và trả về mã mà chúng ta muốn tạo ra.

Tóm Tắt

Whew! Bây giờ bạn đã có một số tính năng Rust trong hộp công cụ của mình mà bạn có thể sẽ không sử dụng thường xuyên, nhưng bạn sẽ biết chúng có sẵn trong những tình huống rất cụ thể. Chúng ta đã giới thiệu một số chủ đề phức tạp để khi bạn gặp chúng trong các gợi ý thông báo lỗi hoặc trong mã của người khác, bạn sẽ có thể nhận ra các khái niệm và cú pháp này. Sử dụng chương này như một tài liệu tham khảo để hướng dẫn bạn đến các giải pháp.

Tiếp theo, chúng ta sẽ đưa tất cả những gì chúng ta đã thảo luận trong suốt cuốn sách vào thực hành và thực hiện một dự án nữa!

Dự Án Cuối Cùng: Xây Dựng Một Web Server Đa Luồng

Đó là một hành trình dài, nhưng chúng ta đã đến phần cuối của cuốn sách. Trong chương này, chúng ta sẽ cùng nhau xây dựng một dự án nữa để minh họa một số khái niệm mà chúng ta đã học trong các chương cuối, cũng như ôn lại một số bài học trước đó.

Đối với dự án cuối cùng, chúng ta sẽ tạo một máy chủ web hiển thị "hello" và trông giống như Hình 21-1 trong trình duyệt web.

hello from rust

Hình 21-1: Dự án cuối cùng của chúng ta

Đây là kế hoạch của chúng ta để xây dựng máy chủ web:

  1. Tìm hiểu một chút về TCP và HTTP.
  2. Lắng nghe các kết nối TCP trên một socket.
  3. Phân tích một số lượng nhỏ các yêu cầu HTTP.
  4. Tạo một phản hồi HTTP phù hợp.
  5. Cải thiện hiệu suất của máy chủ của chúng ta với một thread pool.

Trước khi chúng ta bắt đầu, chúng ta nên đề cập đến hai chi tiết. Thứ nhất, phương pháp mà chúng ta sẽ sử dụng sẽ không phải là cách tốt nhất để xây dựng một máy chủ web với Rust. Các thành viên cộng đồng đã xuất bản một số crate sẵn sàng cho sản xuất có sẵn trên crates.io cung cấp các triển khai máy chủ web và thread pool đầy đủ hơn so với những gì chúng ta sẽ xây dựng. Tuy nhiên, mục đích của chúng ta trong chương này là giúp bạn học, không phải để đi theo con đường dễ dàng. Bởi vì Rust là một ngôn ngữ lập trình hệ thống, chúng ta có thể chọn mức độ trừu tượng mà chúng ta muốn làm việc và có thể đi đến mức thấp hơn so với những gì có thể hoặc thực tế trong các ngôn ngữ khác.

Thứ hai, chúng ta sẽ không sử dụng async và await ở đây. Việc xây dựng một thread pool đã là một thách thức đủ lớn, mà không cần thêm việc xây dựng một runtime async! Tuy nhiên, chúng ta sẽ lưu ý cách async và await có thể áp dụng cho một số vấn đề tương tự mà chúng ta sẽ thấy trong chương này. Cuối cùng, như chúng ta đã lưu ý trong Chương 17, nhiều runtime async sử dụng thread pool để quản lý công việc của chúng.

Do đó, chúng ta sẽ viết máy chủ HTTP cơ bản và thread pool thủ công để bạn có thể học các ý tưởng và kỹ thuật chung đằng sau các crate mà bạn có thể sử dụng trong tương lai.

Xây Dựng Một Web Server Đơn Luồng

Chúng ta sẽ bắt đầu bằng việc xây dựng một web server đơn luồng hoạt động. Trước khi bắt đầu, hãy xem qua tổng quan nhanh về các giao thức liên quan đến việc xây dựng web server. Chi tiết của các giao thức này nằm ngoài phạm vi của cuốn sách này, nhưng một tổng quan ngắn gọn sẽ cung cấp cho bạn thông tin cần thiết.

Hai giao thức chính liên quan đến web server là Hypertext Transfer Protocol (HTTP)Transmission Control Protocol (TCP). Cả hai giao thức đều là các giao thức yêu cầu-phản hồi, nghĩa là khách hàng (client) khởi tạo yêu cầu và máy chủ (server) lắng nghe các yêu cầu và cung cấp phản hồi cho khách hàng. Nội dung của các yêu cầu và phản hồi đó được định nghĩa bởi các giao thức.

TCP là giao thức cấp thấp hơn mô tả chi tiết về cách thông tin được truyền từ một máy chủ sang máy chủ khác nhưng không chỉ định thông tin đó là gì. HTTP xây dựng trên TCP bằng cách định nghĩa nội dung của các yêu cầu và phản hồi. Về mặt kỹ thuật, có thể sử dụng HTTP với các giao thức khác, nhưng trong phần lớn trường hợp, HTTP gửi dữ liệu qua TCP. Chúng ta sẽ làm việc với các byte thô của yêu cầu và phản hồi TCP và HTTP.

Lắng Nghe Kết Nối TCP

Web server của chúng ta cần lắng nghe kết nối TCP, vì vậy đó là phần đầu tiên mà chúng ta sẽ xử lý. Thư viện chuẩn cung cấp một module std::net cho phép chúng ta làm điều này. Hãy tạo một dự án mới theo cách thông thường:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

Bây giờ nhập mã trong Listing 21-1 vào src/main.rs để bắt đầu. Mã này sẽ lắng nghe tại địa chỉ cục bộ 127.0.0.1:7878 để tìm các luồng TCP đến. Khi nó nhận được một luồng đến, nó sẽ in ra Connection established!.

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}

Sử dụng TcpListener, chúng ta có thể lắng nghe các kết nối TCP tại địa chỉ 127.0.0.1:7878. Trong địa chỉ, phần trước dấu hai chấm là địa chỉ IP đại diện cho máy tính của bạn (điều này giống nhau trên mọi máy tính và không đại diện cụ thể cho máy tính của tác giả), và 7878 là cổng. Chúng ta đã chọn cổng này vì hai lý do: HTTP thường không được chấp nhận trên cổng này nên server của chúng ta khó có thể xung đột với bất kỳ web server nào khác mà bạn có thể đang chạy trên máy của mình, và 7878 là rust được gõ trên điện thoại.

Hàm bind trong tình huống này hoạt động giống như hàm new vì nó sẽ trả về một thể hiện TcpListener mới. Hàm này được gọi là bind vì trong mạng, việc kết nối với một cổng để lắng nghe được gọi là "binding to a port" (liên kết với một cổng).

Hàm bind trả về một Result<T, E>, cho thấy rằng việc liên kết có thể thất bại. Ví dụ, kết nối với cổng 80 yêu cầu quyền quản trị viên (người dùng không phải quản trị viên chỉ có thể lắng nghe trên các cổng cao hơn 1023), vì vậy nếu chúng ta cố kết nối với cổng 80 mà không phải là quản trị viên, việc liên kết sẽ không hoạt động. Việc liên kết cũng sẽ không hoạt động, ví dụ, nếu chúng ta chạy hai thể hiện của chương trình và do đó có hai chương trình lắng nghe cùng một cổng. Bởi vì chúng ta đang viết một server cơ bản chỉ để học, chúng ta sẽ không lo lắng về việc xử lý các loại lỗi này; thay vào đó, chúng ta sử dụng unwrap để dừng chương trình nếu có lỗi xảy ra.

Phương thức incoming trên TcpListener trả về một iterator cung cấp cho chúng ta một chuỗi các luồng (cụ thể hơn, các luồng thuộc loại TcpStream). Một luồng (stream) đơn lẻ đại diện cho một kết nối mở giữa khách hàng và máy chủ. Một kết nối (connection) là tên cho toàn bộ quá trình yêu cầu và phản hồi trong đó khách hàng kết nối với máy chủ, máy chủ tạo ra phản hồi, và máy chủ đóng kết nối. Do đó, chúng ta sẽ đọc từ TcpStream để xem khách hàng đã gửi gì và sau đó viết phản hồi của chúng ta vào luồng để gửi dữ liệu trở lại cho khách hàng. Nhìn chung, vòng lặp for này sẽ xử lý từng kết nối lần lượt và tạo ra một chuỗi các luồng để chúng ta xử lý.

Hiện tại, việc xử lý luồng của chúng ta bao gồm việc gọi unwrap để chấm dứt chương trình của chúng ta nếu luồng có bất kỳ lỗi nào; nếu không có lỗi nào, chương trình sẽ in ra một thông báo. Chúng ta sẽ thêm chức năng cho trường hợp thành công trong danh sách tiếp theo. Lý do chúng ta có thể nhận được lỗi từ phương thức incoming khi khách hàng kết nối với máy chủ là vì chúng ta không thực sự lặp qua các kết nối. Thay vào đó, chúng ta đang lặp qua các nỗ lực kết nối. Kết nối có thể không thành công vì nhiều lý do, nhiều lý do phụ thuộc vào hệ điều hành. Ví dụ, nhiều hệ điều hành có giới hạn về số lượng kết nối đồng thời mở mà chúng có thể hỗ trợ; các nỗ lực kết nối mới vượt quá số đó sẽ tạo ra lỗi cho đến khi một số kết nối mở được đóng lại.

Hãy thử chạy mã này! Gọi cargo run trong terminal và sau đó tải 127.0.0.1:7878 trong trình duyệt web. Trình duyệt sẽ hiển thị một thông báo lỗi như "Connection reset" vì máy chủ hiện không gửi lại bất kỳ dữ liệu nào. Nhưng khi bạn nhìn vào terminal của mình, bạn sẽ thấy một số thông báo đã được in ra khi trình duyệt kết nối với máy chủ!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

Đôi khi bạn sẽ thấy nhiều thông báo được in ra cho một yêu cầu trình duyệt; lý do có thể là trình duyệt đang gửi yêu cầu cho trang cũng như yêu cầu cho các tài nguyên khác, như biểu tượng favicon.ico xuất hiện trong tab trình duyệt.

Cũng có thể là trình duyệt đang cố gắng kết nối với máy chủ nhiều lần vì máy chủ không phản hồi với bất kỳ dữ liệu nào. Khi stream ra khỏi phạm vi và bị drop vào cuối vòng lặp, kết nối sẽ bị đóng như một phần của việc triển khai drop. Trình duyệt đôi khi xử lý các kết nối bị đóng bằng cách thử lại, vì vấn đề có thể là tạm thời.

Trình duyệt đôi khi cũng mở nhiều kết nối đến máy chủ mà không gửi bất kỳ yêu cầu nào, để nếu sau đó họ gửi yêu cầu, chúng có thể xảy ra nhanh hơn. Khi điều này xảy ra, máy chủ của chúng ta sẽ thấy mỗi kết nối, bất kể có bất kỳ yêu cầu nào qua kết nối đó hay không. Nhiều phiên bản của trình duyệt dựa trên Chrome làm điều này, ví dụ; bạn có thể tắt tối ưu hóa đó bằng cách sử dụng chế độ duyệt web riêng tư hoặc sử dụng trình duyệt khác.

Yếu tố quan trọng là chúng ta đã thành công trong việc có được một handle cho kết nối TCP!

Hãy nhớ dừng chương trình bằng cách nhấn ctrl-c khi bạn đã chạy xong một phiên bản cụ thể của mã. Sau đó khởi động lại chương trình bằng cách gọi lệnh cargo run sau khi bạn đã thực hiện mỗi bộ thay đổi mã để đảm bảo bạn đang chạy mã mới nhất.

Đọc Yêu Cầu

Hãy triển khai chức năng để đọc yêu cầu từ trình duyệt! Để tách biệt các mối quan tâm về việc đầu tiên là nhận được kết nối và sau đó thực hiện một số hành động với kết nối, chúng ta sẽ bắt đầu một hàm mới để xử lý các kết nối. Trong hàm handle_connection mới này, chúng ta sẽ đọc dữ liệu từ luồng TCP và in nó ra để chúng ta có thể thấy dữ liệu được gửi từ trình duyệt. Thay đổi mã để trông giống như Listing 21-2.

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}

Chúng ta đưa std::io::preludestd::io::BufReader vào phạm vi để có quyền truy cập vào các traits và kiểu cho phép chúng ta đọc từ và viết vào luồng. Trong vòng lặp for trong hàm main, thay vì in một thông báo cho biết chúng ta đã thiết lập kết nối, bây giờ chúng ta gọi hàm handle_connection mới và truyền stream vào nó.

Trong hàm handle_connection, chúng ta tạo một thể hiện BufReader mới bao bọc một tham chiếu đến stream. BufReader thêm bộ đệm bằng cách quản lý các cuộc gọi đến các phương thức của trait std::io::Read cho chúng ta.

Chúng ta tạo một biến có tên http_request để thu thập các dòng của yêu cầu mà trình duyệt gửi đến máy chủ của chúng ta. Chúng ta chỉ ra rằng chúng ta muốn thu thập các dòng này trong một vector bằng cách thêm chú thích kiểu Vec<_>.

BufReader triển khai trait std::io::BufRead, cung cấp phương thức lines. Phương thức lines trả về một iterator của Result<String, std::io::Error> bằng cách chia luồng dữ liệu bất cứ khi nào nó thấy một byte xuống dòng. Để lấy mỗi String, chúng ta map và unwrap mỗi Result. Result có thể là một lỗi nếu dữ liệu không phải là UTF-8 hợp lệ hoặc nếu có vấn đề khi đọc từ luồng. Một lần nữa, một chương trình sản xuất nên xử lý các lỗi này một cách thanh lịch hơn, nhưng chúng ta chọn dừng chương trình trong trường hợp lỗi để đơn giản.

Trình duyệt báo hiệu kết thúc của một yêu cầu HTTP bằng cách gửi hai ký tự xuống dòng liên tiếp, vì vậy để nhận một yêu cầu từ luồng, chúng ta lấy các dòng cho đến khi chúng ta nhận được một dòng là chuỗi rỗng. Một khi chúng ta đã thu thập các dòng vào vector, chúng ta in chúng ra bằng cách sử dụng định dạng gỡ lỗi đẹp để chúng ta có thể xem xét các hướng dẫn mà trình duyệt web đang gửi đến máy chủ của chúng ta.

Hãy thử mã này! Khởi động chương trình và tạo yêu cầu trong trình duyệt web một lần nữa. Lưu ý rằng chúng ta vẫn sẽ nhận được một trang lỗi trong trình duyệt, nhưng đầu ra của chương trình của chúng ta trong terminal bây giờ sẽ trông giống như thế này:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

Tùy thuộc vào trình duyệt của bạn, bạn có thể nhận được đầu ra hơi khác. Bây giờ chúng ta đang in dữ liệu yêu cầu, chúng ta có thể thấy tại sao chúng ta nhận được nhiều kết nối từ một yêu cầu trình duyệt bằng cách nhìn vào đường dẫn sau GET trong dòng đầu tiên của yêu cầu. Nếu các kết nối lặp lại đều yêu cầu /, chúng ta biết rằng trình duyệt đang cố gắng tìm nạp / nhiều lần vì nó không nhận được phản hồi từ chương trình của chúng ta.

Hãy phân tích dữ liệu yêu cầu này để hiểu những gì trình duyệt đang yêu cầu từ chương trình của chúng ta.

Xem Xét Kỹ Hơn về Yêu Cầu HTTP

HTTP là một giao thức dựa trên văn bản, và một yêu cầu có định dạng này:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

Dòng đầu tiên là dòng yêu cầu (request line) chứa thông tin về những gì khách hàng đang yêu cầu. Phần đầu tiên của dòng yêu cầu chỉ ra phương thức (method) được sử dụng, chẳng hạn như GET hoặc POST, mô tả cách khách hàng đang thực hiện yêu cầu này. Khách hàng của chúng ta sử dụng yêu cầu GET, có nghĩa là nó đang yêu cầu thông tin.

Phần tiếp theo của dòng yêu cầu là /, chỉ ra bộ nhận dạng tài nguyên thống nhất (URI) mà khách hàng đang yêu cầu: một URI gần như, nhưng không hoàn toàn, giống như định vị tài nguyên thống nhất (URL). Sự khác biệt giữa URI và URL không quan trọng cho mục đích của chúng ta trong chương này, nhưng đặc tả HTTP sử dụng thuật ngữ URI, vì vậy chúng ta có thể chỉ thay thế URL cho URI ở đây.

Phần cuối cùng là phiên bản HTTP mà khách hàng sử dụng, và sau đó dòng yêu cầu kết thúc bằng một chuỗi CRLF. (CRLF là viết tắt của carriage returnline feed, là các thuật ngữ từ thời máy đánh chữ!) Chuỗi CRLF cũng có thể được viết là \r\n, trong đó \r là carriage return và \n là line feed. Chuỗi CRLF phân tách dòng yêu cầu khỏi phần còn lại của dữ liệu yêu cầu. Lưu ý rằng khi CRLF được in, chúng ta thấy một dòng mới bắt đầu thay vì \r\n.

Nhìn vào dữ liệu dòng yêu cầu mà chúng ta đã nhận được từ việc chạy chương trình của mình cho đến nay, chúng ta thấy rằng GET là phương thức, / là URI yêu cầu, và HTTP/1.1 là phiên bản.

Sau dòng yêu cầu, các dòng còn lại bắt đầu từ Host: trở đi là các header. Yêu cầu GET không có phần thân.

Hãy thử tạo một yêu cầu từ một trình duyệt khác hoặc yêu cầu một địa chỉ khác, chẳng hạn như 127.0.0.1:7878/test, để xem dữ liệu yêu cầu thay đổi như thế nào.

Bây giờ chúng ta biết trình duyệt đang yêu cầu gì, hãy gửi lại một số dữ liệu!

Viết Phản Hồi

Chúng ta sẽ triển khai việc gửi dữ liệu để đáp ứng yêu cầu của khách hàng. Các phản hồi có định dạng sau:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

Dòng đầu tiên là dòng trạng thái (status line) chứa phiên bản HTTP được sử dụng trong phản hồi, một mã trạng thái số tóm tắt kết quả của yêu cầu, và một cụm từ lý do cung cấp mô tả văn bản của mã trạng thái. Sau chuỗi CRLF là bất kỳ header nào, một chuỗi CRLF khác, và thân của phản hồi.

Dưới đây là một ví dụ về phản hồi sử dụng phiên bản HTTP 1.1, có mã trạng thái 200, cụm từ lý do OK, không có header, và không có thân:

HTTP/1.1 200 OK\r\n\r\n

Mã trạng thái 200 là phản hồi thành công tiêu chuẩn. Văn bản là một phản hồi HTTP thành công nhỏ. Hãy viết điều này vào luồng như phản hồi của chúng ta cho một yêu cầu thành công! Từ hàm handle_connection, xóa println! đã in dữ liệu yêu cầu và thay thế nó bằng mã trong Listing 21-3.

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}

Dòng mới đầu tiên định nghĩa biến response giữ dữ liệu của thông báo thành công. Sau đó chúng ta gọi as_bytes trên response để chuyển đổi dữ liệu chuỗi thành byte. Phương thức write_all trên stream nhận một &[u8] và gửi các byte đó trực tiếp qua kết nối. Vì thao tác write_all có thể thất bại, chúng ta sử dụng unwrap trên bất kỳ kết quả lỗi nào như trước đây. Một lần nữa, trong một ứng dụng thực tế, bạn sẽ thêm xử lý lỗi ở đây.

Với những thay đổi này, hãy chạy mã của chúng ta và tạo một yêu cầu. Chúng ta không còn in bất kỳ dữ liệu nào vào terminal, vì vậy chúng ta sẽ không thấy bất kỳ đầu ra nào ngoài đầu ra từ Cargo. Khi bạn tải 127.0.0.1:7878 trong trình duyệt web, bạn sẽ nhận được một trang trống thay vì lỗi. Bạn vừa viết mã bằng tay để nhận yêu cầu HTTP và gửi phản hồi!

Trả về HTML Thực Sự

Hãy triển khai chức năng để trả về nhiều hơn một trang trống. Tạo file mới hello.html trong thư mục gốc của dự án của bạn, không phải trong thư mục src. Bạn có thể nhập bất kỳ HTML nào bạn muốn; Listing 21-4 hiển thị một khả năng.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>

Đây là một tài liệu HTML5 tối thiểu với một tiêu đề và một số văn bản. Để trả về từ máy chủ khi một yêu cầu được nhận, chúng ta sẽ sửa đổi handle_connection như trong Listing 21-5 để đọc file HTML, thêm nó vào phản hồi dưới dạng thân, và gửi nó.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Chúng ta đã thêm fs vào câu lệnh use để đưa module filesystem của thư viện chuẩn vào phạm vi. Mã để đọc nội dung của một file vào một chuỗi sẽ trông quen thuộc; chúng ta đã sử dụng nó khi chúng ta đọc nội dung của một file cho dự án I/O của chúng ta trong Listing 12-4.

Tiếp theo, chúng ta sử dụng format! để thêm nội dung của file dưới dạng thân của phản hồi thành công. Để đảm bảo một phản hồi HTTP hợp lệ, chúng ta thêm header Content-Length được thiết lập bằng kích thước của thân phản hồi, trong trường hợp này là kích thước của hello.html.

Chạy mã này với cargo run và tải 127.0.0.1:7878 trong trình duyệt của bạn; bạn sẽ thấy HTML của bạn được hiển thị!

Hiện tại, chúng ta đang bỏ qua dữ liệu yêu cầu trong http_request và chỉ đơn giản gửi lại nội dung của file HTML một cách vô điều kiện. Điều đó có nghĩa là nếu bạn thử yêu cầu 127.0.0.1:7878/something-else trong trình duyệt của bạn, bạn vẫn sẽ nhận lại cùng một phản hồi HTML. Ở thời điểm hiện tại, máy chủ của chúng ta rất hạn chế và không làm những gì hầu hết các máy chủ web làm. Chúng ta muốn tùy chỉnh các phản hồi của mình tùy thuộc vào yêu cầu và chỉ gửi lại file HTML cho một yêu cầu được định dạng tốt tới /.

Xác Thực Yêu Cầu và Phản Hồi Có Chọn Lọc

Hiện tại, máy chủ web của chúng ta sẽ trả về HTML trong file bất kể khách hàng yêu cầu gì. Hãy thêm chức năng để kiểm tra xem trình duyệt có yêu cầu / trước khi trả về file HTML và trả về lỗi nếu trình duyệt yêu cầu bất cứ thứ gì khác. Để làm điều này, chúng ta cần sửa đổi handle_connection, như trong Listing 21-6. Mã mới này kiểm tra nội dung của yêu cầu nhận được so với những gì chúng ta biết một yêu cầu cho / trông như thế nào và thêm các khối ifelse để xử lý các yêu cầu khác nhau.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}

Chúng ta chỉ xem xét dòng đầu tiên của yêu cầu HTTP, vì vậy thay vì đọc toàn bộ yêu cầu vào vector, chúng ta đang gọi next để lấy mục đầu tiên từ iterator. unwrap đầu tiên xử lý Option và dừng chương trình nếu iterator không có mục nào. unwrap thứ hai xử lý Result và có cùng tác dụng với unwrap đã được thêm vào map trong Listing 21-2.

Tiếp theo, chúng ta kiểm tra request_line để xem nó có bằng với dòng yêu cầu của một yêu cầu GET đến đường dẫn / không. Nếu có, khối if trả về nội dung của file HTML của chúng ta.

Nếu request_line không bằng với yêu cầu GET đến đường dẫn /, có nghĩa là chúng ta đã nhận được một số yêu cầu khác. Chúng ta sẽ thêm mã vào khối else ngay bây giờ để phản hồi tất cả các yêu cầu khác.

Chạy mã này bây giờ và yêu cầu 127.0.0.1:7878; bạn sẽ nhận được HTML trong hello.html. Nếu bạn thực hiện bất kỳ yêu cầu nào khác, chẳng hạn như 127.0.0.1:7878/something-else, bạn sẽ nhận được lỗi kết nối giống như những lỗi bạn đã thấy khi chạy mã trong Listing 21-1 và Listing 21-2.

Bây giờ hãy thêm mã trong Listing 21-7 vào khối else để trả về phản hồi với mã trạng thái 404, báo hiệu rằng nội dung cho yêu cầu không được tìm thấy. Chúng ta cũng sẽ trả về một số HTML cho một trang để hiển thị trong trình duyệt cho biết phản hồi cho người dùng cuối.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}

Ở đây, phản hồi của chúng ta có một dòng trạng thái với mã trạng thái 404 và cụm từ lý do NOT FOUND. Thân của phản hồi sẽ là HTML trong file 404.html. Bạn sẽ cần tạo một file 404.html cạnh hello.html cho trang lỗi; một lần nữa, hãy tự do sử dụng bất kỳ HTML nào bạn muốn hoặc sử dụng HTML mẫu trong Listing 21-8.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>

Với những thay đổi này, hãy chạy lại máy chủ của bạn. Yêu cầu 127.0.0.1:7878 sẽ trả về nội dung của hello.html, và bất kỳ yêu cầu nào khác, như 127.0.0.1:7878/foo, sẽ trả về HTML lỗi từ 404.html.

Một Chút Tái Cấu Trúc

Tại thời điểm này, các khối ifelse có nhiều sự lặp lại: cả hai đều đang đọc các file và viết nội dung của các file vào luồng. Những khác biệt duy nhất là dòng trạng thái và tên file. Hãy làm cho mã ngắn gọn hơn bằng cách rút ra những khác biệt đó thành các dòng ifelse riêng biệt sẽ gán giá trị của dòng trạng thái và tên file cho các biến; sau đó chúng ta có thể sử dụng vô điều kiện các biến đó trong mã để đọc file và viết phản hồi. Listing 21-9 hiển thị mã kết quả sau khi thay thế các khối ifelse lớn.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Bây giờ các khối ifelse chỉ trả về các giá trị thích hợp cho dòng trạng thái và tên file trong một tuple; sau đó chúng ta sử dụng destructuring để gán hai giá trị này cho status_linefilename bằng cách sử dụng một mẫu trong câu lệnh let, như đã thảo luận trong Chương 19.

Mã đã được lặp lại trước đây hiện nằm ngoài các khối ifelse và sử dụng các biến status_linefilename. Điều này làm cho nó dễ dàng hơn để thấy sự khác biệt giữa hai trường hợp, và có nghĩa là chúng ta chỉ có một nơi để cập nhật mã nếu chúng ta muốn thay đổi cách đọc file và viết phản hồi. Hành vi của mã trong Listing 21-9 sẽ giống với mã trong Listing 21-7.

Tuyệt vời! Bây giờ chúng ta có một máy chủ web đơn giản trong khoảng 40 dòng mã Rust phản hồi một yêu cầu với một trang nội dung và phản hồi tất cả các yêu cầu khác với phản hồi 404.

Hiện tại, máy chủ của chúng ta chạy trong một luồng duy nhất, nghĩa là nó chỉ có thể phục vụ một yêu cầu tại một thời điểm. Hãy xem xét làm thế nào điều đó có thể là một vấn đề bằng cách mô phỏng một số yêu cầu chậm. Sau đó, chúng ta sẽ khắc phục nó để máy chủ của chúng ta có thể xử lý nhiều yêu cầu cùng một lúc.

Chuyển Đổi Máy Chủ Đơn Luồng Thành Máy Chủ Đa Luồng

Hiện tại, máy chủ sẽ xử lý từng yêu cầu lần lượt, nghĩa là nó sẽ không xử lý kết nối thứ hai cho đến khi kết nối đầu tiên hoàn tất. Nếu máy chủ nhận được ngày càng nhiều yêu cầu, việc thực thi tuần tự này sẽ ngày càng kém hiệu quả. Nếu máy chủ nhận được một yêu cầu mất nhiều thời gian để xử lý, các yêu cầu tiếp theo sẽ phải đợi cho đến khi yêu cầu dài hoàn thành, ngay cả khi các yêu cầu mới có thể được xử lý nhanh chóng. Chúng ta cần khắc phục điều này, nhưng trước tiên chúng ta sẽ xem xét vấn đề trong thực tế.

Mô Phỏng Yêu Cầu Chậm Trong Cài Đặt Máy Chủ Hiện Tại

Chúng ta sẽ xem xét cách một yêu cầu xử lý chậm có thể ảnh hưởng đến các yêu cầu khác được gửi đến cài đặt máy chủ hiện tại của chúng ta. Listing 21-10 triển khai việc xử lý yêu cầu đến /sleep với một phản hồi chậm mô phỏng sẽ khiến máy chủ ngủ trong năm giây trước khi phản hồi.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Chúng ta đã chuyển từ if sang match khi đã có ba trường hợp. Chúng ta cần phải so khớp rõ ràng trên một lát cắt của request_line để khớp mẫu với các giá trị chuỗi; match không tự động tham chiếu và hủy tham chiếu, như phương thức so sánh bằng.

Cánh tay đầu tiên giống như khối if từ Listing 21-9. Cánh tay thứ hai khớp với một yêu cầu đến /sleep. Khi yêu cầu đó được nhận, máy chủ sẽ ngủ trong năm giây trước khi hiển thị trang HTML thành công. Cánh tay thứ ba giống như khối else từ Listing 21-9.

Bạn có thể thấy máy chủ của chúng ta nguyên thủy như thế nào: các thư viện thực tế sẽ xử lý việc nhận dạng nhiều yêu cầu theo cách ít dài dòng hơn nhiều!

Khởi động máy chủ bằng cách sử dụng cargo run. Sau đó mở hai cửa sổ trình duyệt: một cho http://127.0.0.1:7878/ và cái kia cho http://127.0.0.1:7878/sleep. Nếu bạn nhập URI / vài lần, như trước đây, bạn sẽ thấy nó phản hồi nhanh chóng. Nhưng nếu bạn nhập /sleep và sau đó tải /, bạn sẽ thấy / đợi cho đến khi sleep đã ngủ đủ năm giây trước khi tải.

Có nhiều kỹ thuật chúng ta có thể sử dụng để tránh các yêu cầu ùn tắc phía sau một yêu cầu chậm, bao gồm việc sử dụng async như chúng ta đã làm trong Chương 17; kỹ thuật chúng ta sẽ triển khai là một thread pool.

Cải Thiện Thông Lượng với Thread Pool

Một thread pool là một nhóm các luồng được tạo ra đang chờ đợi và sẵn sàng xử lý một tác vụ. Khi chương trình nhận được một tác vụ mới, nó gán một trong các luồng trong pool cho tác vụ đó, và luồng đó sẽ xử lý tác vụ. Các luồng còn lại trong pool sẵn sàng xử lý bất kỳ tác vụ nào khác đến trong khi luồng đầu tiên đang xử lý. Khi luồng đầu tiên hoàn thành việc xử lý tác vụ của nó, nó được trả lại pool của các luồng rảnh, sẵn sàng xử lý một tác vụ mới. Thread pool cho phép bạn xử lý các kết nối đồng thời, tăng thông lượng của máy chủ của bạn.

Chúng ta sẽ giới hạn số lượng luồng trong pool ở một số nhỏ để bảo vệ chúng ta khỏi các cuộc tấn công DoS; nếu chúng ta để chương trình của mình tạo một luồng mới cho mỗi yêu cầu khi nó đến, ai đó gửi 10 triệu yêu cầu đến máy chủ của chúng ta có thể gây rối bằng cách sử dụng hết tài nguyên của máy chủ và làm cho việc xử lý yêu cầu dừng lại.

Thay vì tạo ra không giới hạn luồng, chúng ta sẽ có một số lượng cố định luồng đang chờ trong pool. Các yêu cầu đến được gửi đến pool để xử lý. Pool sẽ duy trì một hàng đợi các yêu cầu đến. Mỗi luồng trong pool sẽ lấy một yêu cầu từ hàng đợi này, xử lý yêu cầu, và sau đó hỏi hàng đợi về yêu cầu khác. Với thiết kế này, chúng ta có thể xử lý đồng thời tối đa N yêu cầu, trong đó N là số lượng luồng. Nếu mỗi luồng đang đáp ứng một yêu cầu chạy lâu, các yêu cầu tiếp theo vẫn có thể ùn tắc trong hàng đợi, nhưng chúng ta đã tăng số lượng yêu cầu chạy lâu mà chúng ta có thể xử lý trước khi đạt đến điểm đó.

Kỹ thuật này chỉ là một trong nhiều cách để cải thiện thông lượng của máy chủ web. Các tùy chọn khác mà bạn có thể khám phá là mô hình fork/join, mô hình I/O không đồng bộ đơn luồng, và mô hình I/O không đồng bộ đa luồng. Nếu bạn quan tâm đến chủ đề này, bạn có thể đọc thêm về các giải pháp khác và thử triển khai chúng; với một ngôn ngữ cấp thấp như Rust, tất cả các tùy chọn này đều có thể.

Trước khi bắt đầu triển khai thread pool, hãy nói về việc sử dụng pool sẽ trông như thế nào. Khi bạn đang cố gắng thiết kế mã, việc viết giao diện khách hàng trước có thể giúp hướng dẫn thiết kế của bạn. Viết API của mã sao cho nó được cấu trúc theo cách bạn muốn gọi nó; sau đó triển khai chức năng trong cấu trúc đó thay vì triển khai chức năng và sau đó thiết kế API công khai.

Tương tự như cách chúng ta đã sử dụng phát triển dựa trên kiểm thử trong dự án trong Chương 12, chúng ta sẽ sử dụng phát triển dựa trên trình biên dịch ở đây. Chúng ta sẽ viết mã gọi các hàm mà chúng ta muốn, và sau đó chúng ta sẽ xem xét các lỗi từ trình biên dịch để xác định những gì chúng ta nên thay đổi tiếp theo để làm cho mã hoạt động. Tuy nhiên, trước khi làm điều đó, chúng ta sẽ khám phá kỹ thuật mà chúng ta sẽ không sử dụng làm điểm khởi đầu.

Tạo một Luồng cho Mỗi Yêu Cầu

Đầu tiên, hãy khám phá cách mã của chúng ta có thể trông như thế nào nếu nó thực sự tạo ra một luồng mới cho mỗi kết nối. Như đã đề cập trước đó, đây không phải là kế hoạch cuối cùng của chúng ta do các vấn đề với việc có thể tạo ra một số lượng không giới hạn các luồng, nhưng nó là một điểm khởi đầu để có được một máy chủ đa luồng hoạt động trước. Sau đó, chúng ta sẽ thêm thread pool như một cải tiến, và việc so sánh hai giải pháp sẽ dễ dàng hơn. Listing 21-11 hiển thị các thay đổi cần thực hiện đối với main để tạo một luồng mới để xử lý mỗi luồng trong vòng lặp for.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Như bạn đã học trong Chương 16, thread::spawn sẽ tạo một luồng mới và sau đó chạy mã trong closure trong luồng mới. Nếu bạn chạy mã này và tải /sleep trong trình duyệt của bạn, sau đó / trong hai tab trình duyệt khác, bạn sẽ thấy rằng các yêu cầu đến / không phải đợi /sleep hoàn thành. Tuy nhiên, như chúng ta đã đề cập, điều này cuối cùng sẽ làm quá tải hệ thống vì bạn sẽ tạo ra các luồng mới mà không có bất kỳ giới hạn nào.

Bạn cũng có thể nhớ lại từ Chương 17 rằng đây chính xác là loại tình huống mà async và await thực sự tỏa sáng! Hãy nhớ điều đó khi chúng ta xây dựng thread pool và nghĩ về việc mọi thứ sẽ trông khác hoặc giống nhau như thế nào với async.

Tạo một Số Lượng Giới Hạn Các Luồng

Chúng ta muốn thread pool của mình hoạt động theo cách tương tự, quen thuộc để việc chuyển đổi từ luồng sang thread pool không yêu cầu thay đổi lớn đối với mã sử dụng API của chúng ta. Listing 21-12 hiển thị giao diện giả thuyết cho một cấu trúc ThreadPool mà chúng ta muốn sử dụng thay vì thread::spawn.

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Chúng ta sử dụng ThreadPool::new để tạo một thread pool mới với một số lượng luồng có thể cấu hình, trong trường hợp này là bốn. Sau đó, trong vòng lặp for, pool.execute có một giao diện tương tự như thread::spawn ở chỗ nó nhận một closure mà pool nên chạy cho mỗi luồng. Chúng ta cần triển khai pool.execute để nó lấy closure và đưa nó cho một luồng trong pool để chạy. Mã này chưa biên dịch, nhưng chúng ta sẽ thử để trình biên dịch có thể hướng dẫn chúng ta cách khắc phục nó.

Xây Dựng ThreadPool Sử Dụng Phát Triển Dựa Trên Trình Biên Dịch

Thực hiện các thay đổi trong Listing 21-12 cho src/main.rs, và sau đó hãy sử dụng các lỗi biên dịch từ cargo check để hướng dẫn phát triển của chúng ta. Đây là lỗi đầu tiên chúng ta nhận được:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Tuyệt! Lỗi này cho chúng ta biết rằng chúng ta cần một loại hoặc module ThreadPool, vì vậy chúng ta sẽ xây dựng nó ngay bây giờ. Việc triển khai ThreadPool của chúng ta sẽ độc lập với loại công việc mà máy chủ web của chúng ta đang làm. Vì vậy, hãy chuyển crate hello từ một crate nhị phân thành một crate thư viện để giữ việc triển khai ThreadPool của chúng ta. Sau khi chúng ta chuyển sang crate thư viện, chúng ta cũng có thể sử dụng thư viện thread pool riêng biệt cho bất kỳ công việc nào chúng ta muốn làm bằng cách sử dụng thread pool, không chỉ để phục vụ các yêu cầu web.

Tạo một file src/lib.rs chứa những điều sau, đây là định nghĩa đơn giản nhất của một cấu trúc ThreadPool mà chúng ta có thể có bây giờ:

pub struct ThreadPool;

Sau đó chỉnh sửa file main.rs để đưa ThreadPool vào phạm vi từ crate thư viện bằng cách thêm mã sau vào đầu của src/main.rs:

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Mã này vẫn chưa hoạt động, nhưng hãy kiểm tra lại để có được lỗi tiếp theo mà chúng ta cần giải quyết:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Lỗi này chỉ ra rằng tiếp theo chúng ta cần tạo một hàm liên kết có tên new cho ThreadPool. Chúng ta cũng biết rằng new cần có một tham số có thể chấp nhận 4 làm đối số và nên trả về một thể hiện ThreadPool. Hãy triển khai hàm new đơn giản nhất có những đặc điểm đó:

pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

Chúng ta đã chọn usize làm loại của tham số size vì chúng ta biết rằng một số âm của luồng không có ý nghĩa. Chúng ta cũng biết rằng chúng ta sẽ sử dụng 4 này làm số lượng phần tử trong một bộ sưu tập các luồng, đó là mục đích của loại usize, như đã thảo luận trong "Các Loại Số Nguyên" trong Chương 3.

Hãy kiểm tra mã một lần nữa:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Bây giờ lỗi xảy ra vì chúng ta không có phương thức execute trên ThreadPool. Nhớ lại từ "Tạo một Số Lượng Giới Hạn Các Luồng" rằng chúng ta đã quyết định thread pool của mình nên có một giao diện tương tự như thread::spawn. Ngoài ra, chúng ta sẽ triển khai hàm execute để nó lấy closure mà nó được cung cấp và giao nó cho một luồng rảnh trong pool để chạy.

Chúng ta sẽ định nghĩa phương thức execute trên ThreadPool để nhận một closure làm tham số. Nhớ lại từ "Di Chuyển Các Giá Trị Được Bắt Giữ Ra Khỏi Closure và Các Đặc Điểm Fn" trong Chương 13 rằng chúng ta có thể lấy closures làm tham số với ba đặc điểm khác nhau: Fn, FnMut, và FnOnce. Chúng ta cần quyết định loại closure nào để sử dụng ở đây. Chúng ta biết rằng cuối cùng chúng ta sẽ làm điều gì đó tương tự như việc triển khai thread::spawn của thư viện chuẩn, vì vậy chúng ta có thể xem xét những ràng buộc nào mà chữ ký của thread::spawn có trên tham số của nó. Tài liệu hiển thị cho chúng ta những điều sau:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

Tham số loại F là loại chúng ta quan tâm ở đây; tham số loại T liên quan đến giá trị trả về, và chúng ta không quan tâm đến điều đó. Chúng ta có thể thấy rằng spawn sử dụng FnOnce làm ràng buộc đặc điểm trên F. Đây có lẽ là điều chúng ta cũng muốn, vì cuối cùng chúng ta sẽ truyền đối số mà chúng ta nhận được trong execute cho spawn. Chúng ta có thể tin tưởng hơn rằng FnOnce là đặc điểm mà chúng ta muốn sử dụng vì luồng để chạy một yêu cầu sẽ chỉ thực thi closure của yêu cầu đó một lần, điều này phù hợp với Once trong FnOnce.

Tham số loại F cũng có ràng buộc đặc điểm Send và ràng buộc vòng đời 'static, điều này hữu ích trong tình huống của chúng ta: chúng ta cần Send để chuyển closure từ một luồng sang luồng khác và 'static vì chúng ta không biết luồng sẽ mất bao lâu để thực thi. Hãy tạo một phương thức execute trên ThreadPool sẽ lấy một tham số chung kiểu F với những ràng buộc này:

pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

Chúng ta vẫn sử dụng () sau FnOnceFnOnce này đại diện cho một closure không nhận tham số nào và trả về kiểu đơn vị (). Giống như các định nghĩa hàm, kiểu trả về có thể được bỏ qua khỏi chữ ký, nhưng ngay cả khi chúng ta không có tham số nào, chúng ta vẫn cần dấu ngoặc đơn.

Một lần nữa, đây là cách triển khai đơn giản nhất của phương thức execute: nó không làm gì cả, nhưng chúng ta chỉ đang cố gắng làm cho mã của mình biên dịch. Hãy kiểm tra lại:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

Nó biên dịch! Nhưng lưu ý rằng nếu bạn thử cargo run và thực hiện một yêu cầu trong trình duyệt, bạn sẽ thấy các lỗi trong trình duyệt mà chúng ta đã thấy ở đầu chương. Thư viện của chúng ta không thực sự gọi closure được truyền vào execute nữa!

Lưu ý: Một câu nói mà bạn có thể nghe về các ngôn ngữ với trình biên dịch nghiêm ngặt, như Haskell và Rust, là "nếu mã biên dịch, nó hoạt động." Nhưng câu nói này không phải là sự thật phổ quát. Dự án của chúng ta biên dịch, nhưng nó hoàn toàn không làm gì cả! Nếu chúng ta đang xây dựng một dự án thực tế, hoàn chỉnh, đây sẽ là thời điểm tốt để bắt đầu viết các bài kiểm tra đơn vị để kiểm tra rằng mã biên dịch có hành vi mà chúng ta muốn.

Hãy xem xét: điều gì sẽ khác ở đây nếu chúng ta sẽ thực thi một future thay vì một closure?

Xác Thực Số Lượng Luồng trong new

Chúng ta không làm gì với các tham số cho newexecute. Hãy triển khai các thân của các hàm này với hành vi mà chúng ta muốn. Để bắt đầu, hãy nghĩ về new. Trước đó, chúng ta đã chọn một loại không dấu cho tham số size vì một pool với số lượng luồng âm không có ý nghĩa. Tuy nhiên, một pool với số luồng là không cũng không có ý nghĩa, nhưng không là một usize hoàn toàn hợp lệ. Chúng ta sẽ thêm mã để kiểm tra rằng size lớn hơn không trước khi chúng ta trả về một thể hiện ThreadPool và có chương trình hoảng sợ nếu nó nhận được một số không bằng cách sử dụng macro assert!, như được hiển thị trong Listing 21-13.

pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

Chúng ta cũng đã thêm một số tài liệu cho ThreadPool của chúng ta với các chú thích tài liệu. Lưu ý rằng chúng ta đã tuân theo các thực hành tài liệu tốt bằng cách thêm một phần nêu ra các tình huống mà hàm của chúng ta có thể hoảng sợ, như đã thảo luận trong Chương 14. Hãy thử chạy cargo doc --open và nhấp vào cấu trúc ThreadPool để xem tài liệu được tạo ra cho new trông như thế nào!

Thay vì thêm macro assert! như chúng ta đã làm ở đây, chúng ta có thể thay đổi new thành build và trả về một Result như chúng ta đã làm với Config::build trong dự án I/O trong Listing 12-9. Nhưng chúng ta đã quyết định trong trường hợp này rằng việc cố gắng tạo một thread pool mà không có bất kỳ luồng nào nên là một lỗi không thể khôi phục. Nếu bạn cảm thấy tham vọng, hãy thử viết một hàm có tên build với chữ ký sau để so sánh với hàm new:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

Tạo Không Gian Để Lưu Trữ Các Luồng

Bây giờ chúng ta có một cách để biết chúng ta có một số lượng luồng hợp lệ để lưu trữ trong pool, chúng ta có thể tạo các luồng đó và lưu trữ chúng trong cấu trúc ThreadPool trước khi trả về cấu trúc. Nhưng làm thế nào để chúng ta "lưu trữ" một luồng? Hãy xem lại chữ ký của thread::spawn:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

Hàm spawn trả về một JoinHandle<T>, trong đó T là loại mà closure trả về. Hãy thử sử dụng JoinHandle và xem điều gì xảy ra. Trong trường hợp của chúng ta, các closure mà chúng ta đang truyền cho thread pool sẽ xử lý kết nối và không trả về bất cứ điều gì, vì vậy T sẽ là kiểu đơn vị ().

Mã trong Listing 21-14 sẽ biên dịch nhưng chưa tạo bất kỳ luồng nào. Chúng ta đã thay đổi định nghĩa của ThreadPool để chứa một vector của các thể hiện thread::JoinHandle<()>, khởi tạo vector với dung lượng là size, thiết lập một vòng lặp for sẽ chạy một số mã để tạo các luồng, và trả về một thể hiện ThreadPool chứa chúng.

use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

Chúng ta đã đưa std::thread vào phạm vi trong crate thư viện vì chúng ta đang sử dụng thread::JoinHandle làm kiểu của các mục trong vector trong ThreadPool.

Khi một kích thước hợp lệ được nhận, ThreadPool của chúng ta tạo một vector mới có thể chứa size mục. Hàm with_capacity thực hiện cùng một nhiệm vụ như Vec::new nhưng với một sự khác biệt quan trọng: nó cấp phát trước không gian trong vector. Vì chúng ta biết chúng ta cần lưu trữ size phần tử trong vector, việc thực hiện cấp phát này trước là hiệu quả hơn một chút so với việc sử dụng Vec::new, việc này tự điều chỉnh kích thước khi các phần tử được chèn vào.

Khi bạn chạy cargo check một lần nữa, nó sẽ thành công.

Một Cấu Trúc Worker Chịu Trách Nhiệm Gửi Mã từ ThreadPool đến một Luồng

Chúng ta đã để lại một chú thích trong vòng lặp for trong Listing 21-14 liên quan đến việc tạo các luồng. Ở đây, chúng ta sẽ xem xét cách chúng ta thực sự tạo ra các luồng. Thư viện chuẩn cung cấp thread::spawn như một cách để tạo các luồng, và thread::spawn mong đợi nhận được một số mã mà luồng nên chạy ngay khi luồng được tạo. Tuy nhiên, trong trường hợp của chúng ta, chúng ta muốn tạo các luồng và có chúng đợi mã mà chúng ta sẽ gửi sau này. Việc triển khai các luồng của thư viện chuẩn không bao gồm bất kỳ cách nào để làm điều đó; chúng ta phải triển khai nó theo cách thủ công.

Chúng ta sẽ triển khai hành vi này bằng cách giới thiệu một cấu trúc dữ liệu mới giữa ThreadPool và các luồng sẽ quản lý hành vi mới này. Chúng ta sẽ gọi cấu trúc dữ liệu này là Worker, đây là một thuật ngữ phổ biến trong các triển khai pooling. Worker nhận mã cần được chạy và chạy mã đó trong luồng của Worker.

Hãy nghĩ về những người làm việc trong nhà bếp của một nhà hàng: các công nhân đợi cho đến khi đơn đặt hàng đến từ khách hàng, và sau đó họ chịu trách nhiệm lấy các đơn đặt hàng đó và thực hiện chúng.

Thay vì lưu trữ một vector các thể hiện JoinHandle<()> trong thread pool, chúng ta sẽ lưu trữ các thể hiện của cấu trúc Worker. Mỗi Worker sẽ lưu trữ một thể hiện JoinHandle<()> duy nhất. Sau đó, chúng ta sẽ triển khai một phương thức trên Worker sẽ lấy một closure của mã để chạy và gửi nó đến luồng đã chạy để thực thi. Chúng ta cũng sẽ cung cấp cho mỗi Worker một id để chúng ta có thể phân biệt giữa các thể hiện khác nhau của Worker trong pool khi ghi nhật ký hoặc gỡ lỗi.

Đây là quy trình mới sẽ xảy ra khi chúng ta tạo một ThreadPool. Chúng ta sẽ triển khai mã gửi closure đến luồng sau khi chúng ta đã thiết lập Worker theo cách này:

  1. Định nghĩa một cấu trúc Worker chứa một id và một JoinHandle<()>.
  2. Thay đổi ThreadPool để chứa một vector các thể hiện Worker.
  3. Định nghĩa một hàm Worker::new nhận một số id và trả về một thể hiện Worker chứa id và một luồng được tạo ra với một closure rỗng.
  4. Trong ThreadPool::new, sử dụng bộ đếm vòng lặp for để tạo ra một id, tạo một Worker mới với id đó, và lưu trữ worker trong vector.

Nếu bạn sẵn sàng cho một thử thách, hãy thử triển khai các thay đổi này một mình trước khi xem mã trong Listing 21-15.

Sẵn sàng chưa? Đây là Listing 21-15 với một cách để thực hiện các sửa đổi trước đó.

use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

Chúng ta đã thay đổi tên của trường trên ThreadPool từ threads thành workers vì nó hiện đang chứa các thể hiện Worker thay vì các thể hiện JoinHandle<()>. Chúng ta sử dụng bộ đếm trong vòng lặp for làm đối số cho Worker::new, và chúng ta lưu trữ mỗi Worker mới trong vector có tên workers.

Mã bên ngoài (như máy chủ của chúng ta trong src/main.rs) không cần biết các chi tiết triển khai liên quan đến việc sử dụng cấu trúc Worker trong ThreadPool, vì vậy chúng ta làm cho cấu trúc Worker và hàm new của nó là riêng tư. Hàm Worker::new sử dụng id mà chúng ta cung cấp và lưu trữ một thể hiện JoinHandle<()> được tạo ra bằng cách tạo một luồng mới bằng một closure rỗng.

Lưu ý: Nếu hệ điều hành không thể tạo một luồng vì không có đủ tài nguyên hệ thống, thread::spawn sẽ hoảng sợ. Điều đó sẽ khiến toàn bộ máy chủ của chúng ta hoảng sợ, ngay cả khi việc tạo một số luồng có thể thành công. Để đơn giản, hành vi này là tốt, nhưng trong một triển khai thread pool sản xuất, bạn có thể muốn sử dụng std::thread::Builder và phương thức spawn của nó trả về Result thay thế.

Mã này sẽ biên dịch và sẽ lưu trữ số lượng thể hiện Worker mà chúng ta đã chỉ định làm đối số cho ThreadPool::new. Nhưng chúng ta vẫn không xử lý closure mà chúng ta nhận được trong execute. Hãy xem cách làm điều đó tiếp theo.

Gửi Yêu Cầu đến Các Luồng thông qua Các Kênh

Vấn đề tiếp theo mà chúng ta sẽ giải quyết là các closure được đưa cho thread::spawn hoàn toàn không làm gì cả. Hiện tại, chúng ta nhận được closure mà chúng ta muốn thực thi trong phương thức execute. Nhưng chúng ta cần cung cấp cho thread::spawn một closure để chạy khi chúng ta tạo mỗi Worker trong quá trình tạo ThreadPool.

Chúng ta muốn các cấu trúc Worker mà chúng ta vừa tạo lấy mã để chạy từ một hàng đợi được giữ trong ThreadPool và gửi mã đó đến luồng của nó để chạy.

Các kênh mà chúng ta đã học trong Chương 16—một cách đơn giản để giao tiếp giữa hai luồng—sẽ hoàn hảo cho trường hợp sử dụng này. Chúng ta sẽ sử dụng một kênh để hoạt động như hàng đợi các công việc, và execute sẽ gửi một công việc từ ThreadPool đến các thể hiện Worker, sẽ gửi công việc đến luồng của nó để chạy. Đây là kế hoạch:

  1. ThreadPool sẽ tạo một kênh và giữ người gửi.
  2. Mỗi Worker sẽ giữ người nhận.
  3. Chúng ta sẽ tạo một cấu trúc Job mới sẽ chứa các closure mà chúng ta muốn gửi qua kênh.
  4. Phương thức execute sẽ gửi công việc mà nó muốn thực thi thông qua người gửi.
  5. Trong luồng của nó, Worker sẽ lặp qua người nhận của nó và thực thi các closure của bất kỳ công việc nào nó nhận được.

Hãy bắt đầu bằng cách tạo một kênh trong ThreadPool::new và giữ người gửi trong thể hiện ThreadPool, như được hiển thị trong Listing 21-16. Cấu trúc Job không chứa gì bây giờ nhưng sẽ là loại mục mà chúng ta đang gửi qua kênh.

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

Trong ThreadPool::new, chúng ta tạo kênh mới của mình và có pool giữ người gửi. Điều này sẽ biên dịch thành công.

Hãy thử truyền người nhận của kênh vào mỗi Worker khi thread pool tạo kênh. Chúng ta biết rằng chúng ta muốn sử dụng người nhận trong luồng mà các thể hiện Worker tạo ra, vì vậy chúng ta sẽ tham chiếu đến tham số receiver trong closure. Mã trong Listing 21-17 chưa biên dịch hoàn toàn.

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

Chúng ta đã thực hiện một số thay đổi nhỏ và đơn giản: chúng ta truyền người nhận vào Worker::new, và sau đó chúng ta sử dụng nó bên trong closure.

Khi chúng ta cố gắng kiểm tra mã này, chúng ta nhận được lỗi này:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

Mã đang cố gắng truyền receiver cho nhiều thể hiện Worker. Điều này sẽ không hoạt động, như bạn sẽ nhớ lại từ Chương 16: việc triển khai kênh mà Rust cung cấp là nhiều producer, một consumer. Điều này có nghĩa là chúng ta không thể chỉ sao chép đầu tiêu thụ của kênh để sửa mã này. Chúng ta cũng không muốn gửi một thông điệp nhiều lần đến nhiều người tiêu dùng; chúng ta muốn một danh sách các thông điệp với nhiều thể hiện Worker sao cho mỗi thông điệp được xử lý một lần.

Ngoài ra, việc lấy một công việc khỏi hàng đợi kênh liên quan đến việc thay đổi receiver, vì vậy các luồng cần một cách an toàn để chia sẻ và sửa đổi receiver; nếu không, chúng ta có thể gặp phải các điều kiện đua (như đã đề cập trong Chương 16).

Nhớ lại các con trỏ thông minh an toàn cho luồng được thảo luận trong Chương 16: để chia sẻ quyền sở hữu trên nhiều luồng và cho phép các luồng thay đổi giá trị, chúng ta cần sử dụng Arc<Mutex<T>>. Loại Arc sẽ cho phép nhiều thể hiện Worker sở hữu người nhận, và Mutex sẽ đảm bảo rằng chỉ một Worker nhận được một công việc từ người nhận tại một thời điểm. Listing 21-18 hiển thị các thay đổi chúng ta cần thực hiện.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

Trong ThreadPool::new, chúng ta đặt người nhận trong một Arc và một Mutex. Đối với mỗi Worker mới, chúng ta sao chép Arc để tăng số đếm tham chiếu để các thể hiện Worker có thể chia sẻ quyền sở hữu của người nhận.

Với những thay đổi này, mã biên dịch! Chúng ta đang tiến gần đến đích!

Triển Khai Phương Thức execute

Cuối cùng, hãy triển khai phương thức execute trên ThreadPool. Chúng ta cũng sẽ thay đổi Job từ một cấu trúc thành một bí danh kiểu cho một đối tượng đặc điểm chứa loại closure mà execute nhận được. Như đã thảo luận trong "Tạo Đồng Nghĩa Kiểu với Bí Danh Kiểu" trong Chương 20, bí danh kiểu cho phép chúng ta làm cho các kiểu dài ngắn hơn để dễ sử dụng. Hãy xem Listing 21-19.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

Sau khi tạo một thể hiện Job mới bằng cách sử dụng closure mà chúng ta nhận được trong execute, chúng ta gửi công việc đó qua đầu gửi của kênh. Chúng ta đang gọi unwrap trên send cho trường hợp gửi thất bại. Điều này có thể xảy ra nếu, ví dụ, chúng ta dừng tất cả các luồng của mình từ việc thực thi, nghĩa là đầu nhận đã dừng nhận các thông điệp mới. Tại thời điểm này, chúng ta không thể dừng các luồng của mình từ việc thực thi: các luồng của chúng ta tiếp tục thực thi miễn là pool tồn tại. Lý do chúng ta sử dụng unwrap là vì chúng ta biết trường hợp thất bại sẽ không xảy ra, nhưng trình biên dịch không biết điều đó.

Nhưng chúng ta chưa hoàn thành! Trong Worker, closure của chúng ta đang được truyền cho thread::spawn vẫn chỉ tham chiếu đến đầu nhận của kênh. Thay vào đó, chúng ta cần closure để lặp mãi mãi, yêu cầu đầu nhận của kênh một công việc và chạy công việc khi nó nhận được một. Hãy thực hiện thay đổi được hiển thị trong Listing 21-20 cho Worker::new.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

Ở đây, trước tiên chúng ta gọi lock trên receiver để có được mutex, và sau đó chúng ta gọi unwrap để hoảng sợ khi có bất kỳ lỗi nào. Việc có được một khóa có thể không thành công nếu mutex đang ở trạng thái poisoned, điều này có thể xảy ra nếu một số luồng khác hoảng sợ trong khi giữ khóa thay vì giải phóng khóa. Trong tình huống này, việc gọi unwrap để có luồng này hoảng sợ là hành động đúng đắn để thực hiện. Đừng ngại thay đổi unwrap này thành expect với một thông báo lỗi có ý nghĩa đối với bạn.

Nếu chúng ta có được khóa trên mutex, chúng ta gọi recv để nhận một Job từ kênh. Một unwrap cuối cùng vượt qua bất kỳ lỗi nào ở đây cũng vậy, có thể xảy ra nếu luồng giữ người gửi đã tắt, tương tự như cách phương thức send trả về Err nếu người nhận tắt.

Cuộc gọi đến recv chặn, vì vậy nếu chưa có công việc, luồng hiện tại sẽ đợi cho đến khi một công việc có sẵn. Mutex<T> đảm bảo rằng chỉ một luồng Worker tại một thời điểm đang cố gắng yêu cầu một công việc.

Thread pool của chúng ta bây giờ đang ở trạng thái hoạt động! Hãy thử cargo run và thực hiện một số yêu cầu:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

Thành công! Bây giờ chúng ta có một thread pool thực thi các kết nối không đồng bộ. Không bao giờ có nhiều hơn bốn luồng được tạo ra, vì vậy hệ thống của chúng ta sẽ không bị quá tải nếu máy chủ nhận được rất nhiều yêu cầu. Nếu chúng ta thực hiện một yêu cầu đến /sleep, máy chủ sẽ có thể phục vụ các yêu cầu khác bằng cách có một luồng khác chạy chúng.

Lưu ý: Nếu bạn mở /sleep trong nhiều cửa sổ trình duyệt cùng một lúc, chúng có thể tải một cái một lúc trong khoảng thời gian năm giây. Một số trình duyệt web thực thi nhiều phiên bản của cùng một yêu cầu tuần tự vì lý do cache. Hạn chế này không phải do máy chủ web của chúng ta gây ra.

Đây là một thời điểm tốt để tạm dừng và xem xét cách mã trong Listings 21-18, 21-19, và 21-20 sẽ khác nhau nếu chúng ta đang sử dụng futures thay vì closure cho công việc cần thực hiện. Những kiểu nào sẽ thay đổi? Chữ ký phương thức sẽ khác nhau như thế nào, nếu có? Những phần nào của mã sẽ giữ nguyên?

Sau khi học về vòng lặp while let trong Chương 17 và 18, bạn có thể đang tự hỏi tại sao chúng ta không viết mã luồng công nhân như được hiển thị trong Listing 21-21.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

Mã này biên dịch và chạy nhưng không dẫn đến hành vi luồng mong muốn: một yêu cầu chậm vẫn sẽ khiến các yêu cầu khác phải đợi để được xử lý. Lý do hơi tinh tế: cấu trúc Mutex không có phương thức unlock công khai vì quyền sở hữu của khóa dựa trên vòng đời của MutexGuard<T> trong LockResult<MutexGuard<T>> mà phương thức lock trả về. Tại thời điểm biên dịch, trình kiểm tra mượn sau đó có thể thực thi quy tắc rằng một tài nguyên được bảo vệ bởi Mutex không thể được truy cập trừ khi chúng ta giữ khóa. Tuy nhiên, việc triển khai này cũng có thể dẫn đến khóa được giữ lâu hơn dự định nếu chúng ta không chú ý đến vòng đời của MutexGuard<T>.

Mã trong Listing 21-20 sử dụng let job = receiver.lock().unwrap().recv().unwrap(); hoạt động vì với let, bất kỳ giá trị tạm thời nào được sử dụng trong biểu thức ở phía bên phải của dấu bằng sẽ bị bỏ ngay lập tức khi câu lệnh let kết thúc. Tuy nhiên, while let (và if letmatch) không bỏ các giá trị tạm thời cho đến khi kết thúc khối liên kết. Trong Listing 21-21, khóa vẫn được giữ trong suốt thời gian gọi job(), có nghĩa là các thể hiện Worker khác không thể nhận công việc.

Tắt Máy Nhẹ Nhàng và Dọn Dẹp

Đoạn mã trong Listing 21-20 đang phản hồi các yêu cầu bất đồng bộ thông qua việc sử dụng thread pool, như chúng ta dự định. Chúng ta nhận được một số cảnh báo về các trường workers, id, và thread mà chúng ta không sử dụng trực tiếp, điều này nhắc nhở chúng ta rằng chúng ta không dọn dẹp bất cứ thứ gì. Khi chúng ta sử dụng phương pháp kém thanh lịch ctrl-c để dừng luồng chính, tất cả các luồng khác cũng bị dừng ngay lập tức, ngay cả khi chúng đang trong quá trình phục vụ một yêu cầu.

Tiếp theo, chúng ta sẽ thực hiện đặc tính Drop để gọi join trên mỗi luồng trong pool để chúng có thể hoàn thành các yêu cầu mà chúng đang xử lý trước khi đóng. Sau đó, chúng ta sẽ thực hiện một cách để cho các luồng biết rằng chúng nên dừng chấp nhận các yêu cầu mới và tắt. Để xem mã này hoạt động, chúng ta sẽ sửa đổi máy chủ của mình để chỉ chấp nhận hai yêu cầu trước khi tắt thread pool một cách nhẹ nhàng.

Một điều cần lưu ý khi chúng ta tiến hành: không có gì trong phần này ảnh hưởng đến các phần của mã xử lý việc thực thi các closure, vì vậy mọi thứ ở đây sẽ giống hệt nhau nếu chúng ta đang sử dụng thread pool cho một môi trường thực thi bất đồng bộ.

Thực Hiện Đặc Tính Drop trên ThreadPool

Hãy bắt đầu với việc thực hiện Drop trên thread pool của chúng ta. Khi pool bị huỷ, các luồng của chúng ta đều nên tham gia (join) để đảm bảo chúng hoàn thành công việc của mình. Listing 21-22 hiển thị nỗ lực đầu tiên để thực hiện Drop; mã này chưa hoạt động hoàn toàn.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

Đầu tiên, chúng ta lặp qua từng worker trong thread pool. Chúng ta sử dụng &mut cho việc này vì self là một tham chiếu có thể thay đổi, và chúng ta cũng cần có khả năng thay đổi worker. Đối với mỗi worker, chúng ta in một thông báo cho biết thể hiện Worker cụ thể này đang tắt, sau đó chúng ta gọi join trên luồng của thể hiện Worker đó. Nếu lệnh gọi join thất bại, chúng ta sử dụng unwrap để làm cho Rust hoảng loạn (panic) và chuyển sang tắt không nhẹ nhàng.

Đây là lỗi chúng ta nhận được khi biên dịch mã này:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:1876:17

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

Lỗi cho chúng ta biết rằng chúng ta không thể gọi join vì chúng ta chỉ có một tham chiếu có thể thay đổi của mỗi workerjoin lấy quyền sở hữu của đối số của nó. Để giải quyết vấn đề này, chúng ta cần di chuyển luồng ra khỏi thể hiện Worker sở hữu thread để join có thể tiêu thụ luồng. Một cách để làm điều này là bằng cách sử dụng cùng một phương pháp mà chúng ta đã làm trong Listing 18-15. Nếu Worker giữ một Option<thread::JoinHandle<()>>, chúng ta có thể gọi phương thức take trên Option để di chuyển giá trị ra khỏi biến thể Some và để lại một biến thể None ở vị trí của nó. Nói cách khác, một Worker đang chạy sẽ có một biến thể Some trong thread, và khi chúng ta muốn dọn dẹp một Worker, chúng ta sẽ thay thế Some bằng None để Worker không có luồng để chạy.

Tuy nhiên, chỉ thời điểm này sẽ xuất hiện là khi huỷ Worker. Để đổi lại, chúng ta sẽ phải xử lý một Option<thread::JoinHandle<()>> bất cứ nơi nào chúng ta truy cập worker.thread. Rust thành ngữ sử dụng Option khá nhiều, nhưng khi bạn thấy mình bọc thứ gì đó mà bạn biết sẽ luôn có mặt trong Option như một giải pháp tạm thời như thế này, đó là một ý tưởng tốt để tìm kiếm các phương pháp thay thế. Chúng có thể làm cho mã của bạn sạch hơn và ít dễ xảy ra lỗi hơn.

Trong trường hợp này, một giải pháp thay thế tốt hơn tồn tại: phương thức Vec::drain. Nó chấp nhận một tham số phạm vi để chỉ định các mục cần xóa khỏi Vec, và trả về một iterator của các mục đó. Truyền cú pháp phạm vi .. sẽ xóa mọi giá trị khỏi Vec.

Vì vậy, chúng ta cần cập nhật việc thực hiện drop của ThreadPool như thế này:

#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

Điều này giải quyết lỗi biên dịch và không yêu cầu bất kỳ thay đổi nào khác cho mã của chúng ta.

Báo Hiệu cho Các Luồng Dừng Lắng Nghe Công Việc

Với tất cả các thay đổi chúng ta đã thực hiện, mã của chúng ta biên dịch mà không có bất kỳ cảnh báo nào. Tuy nhiên, tin xấu là mã này chưa hoạt động theo cách chúng ta muốn. Chìa khóa là logic trong các closure được chạy bởi các luồng của các thể hiện Worker: tại thời điểm này, chúng ta gọi join, nhưng điều đó sẽ không tắt các luồng vì chúng loop mãi mãi tìm kiếm công việc. Nếu chúng ta thử huỷ ThreadPool của mình với cách thực hiện drop hiện tại, luồng chính sẽ bị chặn mãi mãi, chờ đợi luồng đầu tiên hoàn thành.

Để khắc phục vấn đề này, chúng ta sẽ cần thay đổi trong cách thực hiện drop của ThreadPool và sau đó thay đổi trong vòng lặp Worker.

Đầu tiên, chúng ta sẽ thay đổi cách thực hiện drop của ThreadPool để rõ ràng huỷ sender trước khi đợi các luồng hoàn thành. Listing 21-23 hiển thị các thay đổi đối với ThreadPool để rõ ràng huỷ sender. Không giống như với luồng, ở đây chúng ta thực sự cần sử dụng Option để có thể di chuyển sender ra khỏi ThreadPool với Option::take.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

Việc huỷ sender đóng kênh, điều này chỉ ra rằng không có thêm thông báo nào sẽ được gửi. Khi điều đó xảy ra, tất cả các lệnh gọi recv mà các thể hiện Worker thực hiện trong vòng lặp vô hạn sẽ trả về một lỗi. Trong Listing 21-24, chúng ta thay đổi vòng lặp Worker để nhẹ nhàng thoát khỏi vòng lặp trong trường hợp đó, điều đó có nghĩa là các luồng sẽ kết thúc khi thực hiện drop của ThreadPool gọi join trên chúng.

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}

Để xem mã này hoạt động, hãy sửa đổi main để chỉ chấp nhận hai yêu cầu trước khi tắt máy chủ một cách nhẹ nhàng, như được hiển thị trong Listing 21-25.

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

Bạn sẽ không muốn một máy chủ web trong thế giới thực tắt sau khi phục vụ chỉ hai yêu cầu. Mã này chỉ chứng minh rằng việc tắt nhẹ nhàng và dọn dẹp đang hoạt động tốt.

Phương thức take được định nghĩa trong đặc tính Iterator và giới hạn vòng lặp tối đa là hai mục đầu tiên. ThreadPool sẽ ra khỏi phạm vi khi kết thúc main, và việc thực hiện drop sẽ chạy.

Khởi động máy chủ với cargo run, và thực hiện ba yêu cầu. Yêu cầu thứ ba sẽ bị lỗi, và trong terminal của bạn, bạn sẽ thấy đầu ra tương tự như thế này:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

Bạn có thể thấy thứ tự ID Worker và thông báo được in khác nhau. Chúng ta có thể thấy cách mã này hoạt động từ các thông báo: các thể hiện Worker 0 và 3 nhận hai yêu cầu đầu tiên. Máy chủ dừng chấp nhận kết nối sau kết nối thứ hai, và việc thực hiện Drop trên ThreadPool bắt đầu thực thi trước khi Worker 3 thậm chí bắt đầu công việc của nó. Việc huỷ sender ngắt kết nối tất cả các thể hiện Worker và cho chúng biết phải tắt. Các thể hiện Worker mỗi thể hiện in một thông báo khi chúng ngắt kết nối, và sau đó thread pool gọi join để đợi mỗi luồng Worker hoàn thành.

Hãy chú ý một khía cạnh thú vị của việc thực thi cụ thể này: ThreadPool đã huỷ sender, và trước khi bất kỳ Worker nào nhận được lỗi, chúng ta đã cố gắng tham gia Worker 0. Worker 0 chưa nhận được lỗi từ recv, vì vậy luồng chính bị chặn chờ đợi Worker 0 hoàn thành. Trong khi đó, Worker 3 nhận được một công việc và sau đó tất cả các luồng nhận được lỗi. Khi Worker 0 hoàn thành, luồng chính đợi phần còn lại của các thể hiện Worker hoàn thành. Tại thời điểm đó, tất cả chúng đều đã thoát khỏi vòng lặp của chúng và dừng lại.

Xin chúc mừng! Bây giờ chúng ta đã hoàn thành dự án của mình; chúng ta có một máy chủ web cơ bản sử dụng thread pool để phản hồi bất đồng bộ. Chúng ta có thể thực hiện việc tắt máy chủ một cách nhẹ nhàng, điều này dọn dẹp tất cả các luồng trong pool.

Đây là toàn bộ mã để tham khảo:

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

Chúng ta có thể làm nhiều hơn nữa ở đây! Nếu bạn muốn tiếp tục nâng cao dự án này, đây là một số ý tưởng:

  • Thêm nhiều tài liệu vào ThreadPool và các phương thức công khai của nó.
  • Thêm các bài kiểm tra chức năng của thư viện.
  • Thay đổi các lệnh gọi unwrap thành xử lý lỗi mạnh mẽ hơn.
  • Sử dụng ThreadPool để thực hiện một số tác vụ khác ngoài việc phục vụ các yêu cầu web.
  • Tìm một crate thread pool trên crates.io và thực hiện một máy chủ web tương tự bằng cách sử dụng crate thay thế. Sau đó so sánh API và độ mạnh mẽ của nó với thread pool mà chúng ta đã thực hiện.

Tổng Kết

Làm tốt lắm! Bạn đã đến được cuối sách! Chúng tôi muốn cảm ơn bạn đã tham gia cùng chúng tôi trong chuyến tham quan Rust này. Bây giờ bạn đã sẵn sàng để thực hiện các dự án Rust của riêng mình và giúp đỡ với các dự án của người khác. Hãy nhớ rằng có một cộng đồng chào đón của các Rustacean khác sẵn sàng giúp đỡ bạn với bất kỳ thách thức nào bạn gặp phải trên hành trình Rust của mình.

Phụ lục

Các phần sau đây chứa những tài liệu tham khảo mà bạn có thể thấy hữu ích trong hành trình học Rust của mình.

Phụ lục A: Các từ khóa

Danh sách sau đây chứa các từ khóa được dành riêng cho việc sử dụng hiện tại hoặc trong tương lai của ngôn ngữ Rust. Do đó, chúng không thể được sử dụng làm định danh (ngoại trừ khi dùng làm định danh thô như chúng ta sẽ thảo luận trong phần “Định danh thô“). Định danh là tên của các hàm, biến, tham số, trường của struct, module, crate, hằng số, macro, giá trị tĩnh, thuộc tính, kiểu dữ liệu, trait, hoặc lifetime.

Các từ khóa hiện đang được sử dụng

Sau đây là danh sách các từ khóa hiện đang được sử dụng, cùng với mô tả chức năng của chúng.

  • as - thực hiện ép kiểu nguyên thủy, làm rõ trait cụ thể chứa một item, hoặc đổi tên các item trong câu lệnh use
  • async - trả về một Future thay vì chặn luồng hiện tại
  • await - tạm dừng thực thi cho đến khi kết quả của một Future sẵn sàng
  • break - thoát khỏi vòng lặp ngay lập tức
  • const - định nghĩa các hằng số item hoặc hằng số con trỏ thô
  • continue - tiếp tục với vòng lặp tiếp theo
  • crate - trong đường dẫn module, tham chiếu đến gốc của crate
  • dyn - phân phối động (dynamic dispatch) đến một trait object
  • else - dự phòng cho các cấu trúc luồng điều khiển ifif let
  • enum - định nghĩa một kiểu liệt kê
  • extern - liên kết một hàm hoặc biến bên ngoài
  • false - giá trị Boolean false
  • fn - định nghĩa một hàm hoặc kiểu con trỏ hàm
  • for - lặp qua các item từ một iterator, triển khai một trait, hoặc chỉ định một lifetime cao hơn
  • if - rẽ nhánh dựa trên kết quả của một biểu thức điều kiện
  • impl - triển khai phương thức gốc hoặc trait
  • in - một phần của cú pháp vòng lặp for
  • let - gán một biến
  • loop - lặp vô điều kiện
  • match - so khớp một giá trị với các mẫu
  • mod - định nghĩa một module
  • move - làm cho một closure chiếm quyền sở hữu của tất cả các giá trị mà nó bắt giữ
  • mut - chỉ định tính khả biến trong các tham chiếu, con trỏ thô, hoặc gán mẫu
  • pub - chỉ định tính công khai trong các trường của struct, khối impl, hoặc module
  • ref - gán bằng tham chiếu
  • return - trả về từ hàm
  • Self - một kiểu bí danh cho kiểu mà chúng ta đang định nghĩa hoặc triển khai
  • self - đối tượng hiện tại trong phương thức hoặc module hiện tại
  • static - biến toàn cục hoặc lifetime kéo dài trong toàn bộ thời gian thực thi của chương trình
  • struct - định nghĩa một struct
  • super - module cha của module hiện tại
  • trait - định nghĩa một trait
  • true - giá trị Boolean true
  • type - định nghĩa một kiểu bí danh hoặc kiểu liên kết
  • union - định nghĩa một union; chỉ là từ khóa khi được sử dụng trong một khai báo union
  • unsafe - chỉ định mã, hàm, trait, hoặc triển khai không an toàn
  • use - đưa các ký hiệu vào phạm vi; chỉ định các capture chính xác cho generic và ràng buộc lifetime
  • where - chỉ định các điều kiện ràng buộc một kiểu
  • while - lặp có điều kiện dựa trên kết quả của một biểu thức

Các từ khóa được dành riêng cho sử dụng trong tương lai

Các từ khóa sau đây chưa có chức năng cụ thể nhưng được Rust dành riêng cho khả năng sử dụng trong tương lai.

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

Định danh thô

Định danh thô là cú pháp cho phép bạn sử dụng các từ khóa ở những nơi thông thường không được phép. Bạn sử dụng định danh thô bằng cách thêm tiền tố r# vào trước từ khóa.

Ví dụ, match là một từ khóa. Nếu bạn thử biên dịch hàm sau đây sử dụng match làm tên của nó:

Tên tệp: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

bạn sẽ nhận được lỗi này:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

Lỗi này cho thấy bạn không thể sử dụng từ khóa match làm định danh hàm. Để sử dụng match làm tên hàm, bạn cần sử dụng cú pháp định danh thô, như sau:

Tên tệp: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

Mã này sẽ biên dịch mà không có bất kỳ lỗi nào. Lưu ý tiền tố r# trên tên hàm trong định nghĩa cũng như nơi hàm được gọi trong main.

Định danh thô cho phép bạn sử dụng bất kỳ từ nào làm định danh, ngay cả khi từ đó tình cờ là một từ khóa dành riêng. Điều này mang lại cho chúng ta nhiều tự do hơn trong việc chọn tên định danh, cũng như cho phép chúng ta tích hợp với các chương trình được viết bằng ngôn ngữ mà các từ này không phải là từ khóa. Ngoài ra, định danh thô cho phép bạn sử dụng các thư viện được viết bằng phiên bản Rust khác với phiên bản mà crate của bạn sử dụng. Ví dụ, try không phải là từ khóa trong phiên bản 2015 nhưng lại là từ khóa trong các phiên bản 2018, 2021, và 2024. Nếu bạn phụ thuộc vào một thư viện được viết bằng phiên bản 2015 và có một hàm try, bạn sẽ cần sử dụng cú pháp định danh thô, r#try trong trường hợp này, để gọi hàm đó từ mã của bạn trên các phiên bản sau. Xem Phụ lục E để biết thêm thông tin về các phiên bản.

Phụ lục B: Các toán tử và ký hiệu

Phụ lục này chứa danh mục các cú pháp của Rust, bao gồm các toán tử và các ký hiệu khác xuất hiện độc lập hoặc trong ngữ cảnh của đường dẫn, generics, ràng buộc trait, macro, thuộc tính, bình luận, tuple và dấu ngoặc.

Các toán tử

Bảng B-1 chứa các toán tử trong Rust, một ví dụ về cách toán tử xuất hiện trong ngữ cảnh, một lời giải thích ngắn gọn và liệu toán tử đó có thể được nạp chồng hay không. Nếu một toán tử có thể được nạp chồng, trait có liên quan được sử dụng để nạp chồng toán tử đó được liệt kê.

Bảng B-1: Các toán tử

Toán tửVí dụGiải thíchNạp chồng?
!ident!(...), ident!{...}, ident![...]Mở rộng macro
!!exprPhủ định bit hoặc logicNot
!=expr != exprSo sánh không bằngPartialEq
%expr % exprPhép chia lấy dưRem
%=var %= exprPhép chia lấy dư và gánRemAssign
&&expr, &mut exprMượn (borrow)
&&type, &mut type, &'a type, &'a mut typeKiểu con trỏ mượn
&expr & exprPhép AND bitBitAnd
&=var &= exprPhép AND bit và gánBitAndAssign
&&expr && exprPhép AND logic ngắn mạch
*expr * exprPhép nhân số họcMul
*=var *= exprPhép nhân số học và gánMulAssign
**exprDereference (giải tham chiếu)Deref
**const type, *mut typeCon trỏ thô
+trait + trait, 'a + traitRàng buộc kiểu phức hợp
+expr + exprPhép cộng số họcAdd
+=var += exprPhép cộng số học và gánAddAssign
,expr, exprPhân cách đối số và phần tử
-- exprPhủ định số học (đổi dấu)Neg
-expr - exprPhép trừ số họcSub
-=var -= exprPhép trừ số học và gánSubAssign
->fn(...) -> type, |...| -> typeKiểu trả về của hàm và closure
.expr.identTruy cập trường
.expr.ident(expr, ...)Gọi phương thức
.expr.0, expr.1, v.v.Truy cập phần tử của tuple theo chỉ mục
...., expr.., ..expr, expr..exprKhoảng (range) loại trừ bên phảiPartialOrd
..=..=expr, expr..=exprKhoảng (range) bao gồm bên phảiPartialOrd
....exprCú pháp cập nhật biểu thức struct
..variant(x, ..), struct_type { x, .. }Gán mẫu "và phần còn lại"
...expr...expr(Đã lỗi thời, sử dụng ..= thay thế) Trong mẫu: mẫu khoảng bao gồm
/expr / exprPhép chia số họcDiv
/=var /= exprPhép chia số học và gánDivAssign
:pat: type, ident: typeRàng buộc
:ident: exprKhởi tạo trường struct
:'a: loop {...}Nhãn vòng lặp
;expr;Kết thúc câu lệnh và mục
;[...; len]Một phần của cú pháp mảng kích thước cố định
<<expr << exprDịch tráiShl
<<=var <<= exprDịch trái và gánShlAssign
<expr < exprSo sánh nhỏ hơnPartialOrd
<=expr <= exprSo sánh nhỏ hơn hoặc bằngPartialOrd
=var = expr, ident = typeGán/tương đương
==expr == exprSo sánh bằngPartialEq
=>pat => exprMột phần của cú pháp nhánh match
>expr > exprSo sánh lớn hơnPartialOrd
>=expr >= exprSo sánh lớn hơn hoặc bằngPartialOrd
>>expr >> exprDịch phảiShr
>>=var >>= exprDịch phải và gánShrAssign
@ident @ patGán mẫu
^expr ^ exprPhép XOR bitBitXor
^=var ^= exprPhép XOR bit và gánBitXorAssign
|pat | patCác mẫu thay thế
|expr | exprPhép OR bitBitOr
|=var |= exprPhép OR bit và gánBitOrAssign
||expr || exprPhép OR logic ngắn mạch
?expr?Truyền lỗi

Các ký hiệu không phải toán tử

Danh sách sau đây chứa tất cả các ký hiệu không hoạt động như các toán tử; nghĩa là, chúng không hoạt động như một lời gọi hàm hoặc phương thức.

Bảng B-2 hiển thị các ký hiệu xuất hiện độc lập và hợp lệ trong nhiều vị trí khác nhau.

Bảng B-2: Cú pháp độc lập

Ký hiệuGiải thích
'identTên Lifetime hoặc nhãn vòng lặp
...u8, ...i32, ...f64, ...usize, v.v.Hằng số số học có kiểu rõ ràng
"..."Chuỗi ký tự
r"...", r#"..."#, r##"..."##, v.v.Chuỗi ký tự thô, các ký tự thoát không được xử lý
b"..."Chuỗi byte; tạo mảng byte thay vì chuỗi
br"...", br#"..."#, br##"..."##, v.v.Chuỗi byte thô, kết hợp của chuỗi thô và chuỗi byte
'...'Ký tự
b'...'Byte ASCII
|...| exprClosure
!Kiểu bottom luôn trống cho các hàm phân kỳ
_Gán mẫu "bị bỏ qua"; cũng được sử dụng để làm cho số nguyên dễ đọc hơn

Bảng B-3 hiển thị các ký hiệu xuất hiện trong ngữ cảnh của đường dẫn thông qua hệ thống phân cấp module đến một mục.

Bảng B-3: Cú pháp liên quan đến đường dẫn

Ký hiệuGiải thích
ident::identĐường dẫn namespace
::pathĐường dẫn tương đối với extern prelude, nơi tất cả các crate khác được đặt gốc (tức là, một đường dẫn tuyệt đối rõ ràng bao gồm tên crate)
self::pathĐường dẫn tương đối với module hiện tại (tức là, một đường dẫn tương đối rõ ràng).
super::pathĐường dẫn tương đối với module cha của module hiện tại
type::ident, <type as trait>::identCác hằng số, hàm và kiểu liên kết
<type>::...Mục liên kết cho một kiểu không thể được đặt tên trực tiếp (ví dụ, <&T>::..., <[T]>::..., v.v.)
trait::method(...)Làm rõ lời gọi phương thức bằng cách đặt tên trait định nghĩa nó
type::method(...)Làm rõ lời gọi phương thức bằng cách đặt tên kiểu mà nó được định nghĩa
<type as trait>::method(...)Làm rõ lời gọi phương thức bằng cách đặt tên trait và kiểu

Bảng B-4 hiển thị các ký hiệu xuất hiện trong ngữ cảnh của việc sử dụng các tham số kiểu generic.

Bảng B-4: Generics

Ký hiệuGiải thích
path<...>Chỉ định tham số cho kiểu generic trong một kiểu (ví dụ, Vec<u8>)
path::<...>, method::<...>Chỉ định tham số cho kiểu generic, hàm hoặc phương thức trong một biểu thức; thường được gọi là turbofish (ví dụ, "42".parse::<i32>())
fn ident<...> ...Định nghĩa hàm generic
struct ident<...> ...Định nghĩa struct generic
enum ident<...> ...Định nghĩa enum generic
impl<...> ...Định nghĩa triển khai generic
for<...> typeRàng buộc lifetime bậc cao hơn
type<ident=type>Một kiểu generic trong đó một hoặc nhiều kiểu liên kết có gán cụ thể (ví dụ, Iterator<Item=T>)

Bảng B-5 hiển thị các ký hiệu xuất hiện trong ngữ cảnh của việc ràng buộc các tham số kiểu generic với các ràng buộc trait.

Bảng B-5: Ràng buộc Trait

Ký hiệuGiải thích
T: UTham số generic T bị ràng buộc vào các kiểu triển khai U
T: 'aKiểu generic T phải tồn tại lâu hơn lifetime 'a (nghĩa là kiểu không thể chứa bất kỳ tham chiếu nào có lifetime ngắn hơn 'a)
T: 'staticKiểu generic T không chứa các tham chiếu được mượn ngoài tham chiếu 'static
'b: 'aLifetime generic 'b phải tồn tại lâu hơn lifetime 'a
T: ?SizedCho phép tham số kiểu generic là một kiểu có kích thước động
'a + trait, trait + traitRàng buộc kiểu phức hợp

Bảng B-6 hiển thị các ký hiệu xuất hiện trong ngữ cảnh của việc gọi hoặc định nghĩa macro và chỉ định thuộc tính trên một mục.

Bảng B-6: Macro và thuộc tính

Ký hiệuGiải thích
#[meta]Thuộc tính ngoài
#![meta]Thuộc tính trong
$identThay thế macro
$ident:kindCapture macro
$(…)…Lặp lại macro
ident!(...), ident!{...}, ident![...]Gọi macro

Bảng B-7 hiển thị các ký hiệu tạo bình luận.

Bảng B-7: Bình luận

Ký hiệuGiải thích
//Bình luận dòng
//!Bình luận tài liệu dòng bên trong
///Bình luận tài liệu dòng bên ngoài
/*...*/Bình luận khối
/*!...*/Bình luận tài liệu khối bên trong
/**...*/Bình luận tài liệu khối bên ngoài

Bảng B-8 hiển thị các ngữ cảnh mà dấu ngoặc đơn được sử dụng.

Bảng B-8: Dấu ngoặc đơn

Ký hiệuGiải thích
()Tuple rỗng (còn gọi là unit), cả kiểu và giá trị
(expr)Biểu thức trong ngoặc đơn
(expr,)Biểu thức tuple một phần tử
(type,)Kiểu tuple một phần tử
(expr, ...)Biểu thức tuple
(type, ...)Kiểu tuple
expr(expr, ...)Biểu thức gọi hàm; cũng được sử dụng để khởi tạo struct tuple và biến thể enum tuple

Bảng B-9 hiển thị các ngữ cảnh mà dấu ngoặc nhọn được sử dụng.

Bảng B-9: Dấu ngoặc nhọn

Ngữ cảnhGiải thích
{...}Biểu thức khối
Type {...}Giá trị struct

Bảng B-10 hiển thị các ngữ cảnh mà dấu ngoặc vuông được sử dụng.

Bảng B-10: Dấu ngoặc vuông

Ngữ cảnhGiải thích
[...]Giá trị mảng
[expr; len]Giá trị mảng chứa len bản sao của expr
[type; len]Kiểu mảng chứa len thể hiện của type
expr[expr]Truy cập phần tử của collection. Có thể nạp chồng (Index, IndexMut)
expr[..], expr[a..], expr[..b], expr[a..b]Truy cập phần tử của collection thành lát cắt collection, sử dụng Range, RangeFrom, RangeTo hoặc RangeFull làm "chỉ mục"

Phụ lục C: Các Trait có thể dẫn xuất

Trong nhiều phần của cuốn sách này, chúng ta đã thảo luận về thuộc tính derive, có thể áp dụng cho định nghĩa struct hoặc enum. Thuộc tính derive tạo ra mã sẽ triển khai một trait với triển khai mặc định của nó trên kiểu mà bạn đã chú thích với cú pháp derive (dẫn xuất).

Trong phụ lục này, chúng tôi cung cấp một tài liệu tham khảo về tất cả các trait trong thư viện chuẩn mà bạn có thể sử dụng với derive. Mỗi phần bao gồm:

  • Những toán tử và phương thức nào mà trait này sẽ kích hoạt
  • Triển khai của trait được cung cấp bởi derive làm gì
  • Việc triển khai trait báo hiệu điều gì về kiểu đó
  • Những điều kiện mà bạn được phép hoặc không được phép triển khai trait
  • Các ví dụ về các hoạt động yêu cầu trait này

Nếu bạn muốn có hành vi khác với hành vi được cung cấp bởi thuộc tính derive, hãy tham khảo tài liệu thư viện chuẩn cho mỗi trait để biết chi tiết về cách triển khai chúng thủ công.

Các trait được liệt kê ở đây là những trait được định nghĩa bởi thư viện chuẩn mà có thể được triển khai cho các kiểu của bạn bằng cách sử dụng derive. Các trait khác được định nghĩa trong thư viện chuẩn không có hành vi mặc định hợp lý, vì vậy việc triển khai chúng theo cách phù hợp với mục đích của bạn là tùy thuộc vào bạn.

Một ví dụ về trait không thể được derive là Display, xử lý việc định dạng cho người dùng cuối. Bạn nên luôn cân nhắc cách thích hợp để hiển thị một kiểu cho người dùng cuối. Những phần nào của kiểu mà người dùng cuối nên được phép xem? Những phần nào họ sẽ thấy liên quan? Định dạng dữ liệu nào sẽ liên quan nhất đối với họ? Trình biên dịch Rust không có cái nhìn sâu sắc này, vì vậy nó không thể cung cấp hành vi mặc định phù hợp cho bạn.

Danh sách các trait có thể derive được cung cấp trong phụ lục này không toàn diện: các thư viện có thể triển khai derive cho các trait của riêng họ, làm cho danh sách các trait mà bạn có thể sử dụng derive thực sự mở rộng. Việc triển khai derive liên quan đến việc sử dụng một macro thủ tục, được đề cập trong phần "Macro" của Chương 20.

Debug cho đầu ra dành cho lập trình viên

Trait Debug cho phép định dạng gỡ lỗi trong chuỗi định dạng, bạn chỉ định bằng cách thêm :? trong các placeholder {}.

Trait Debug cho phép bạn in các thể hiện của một kiểu cho mục đích gỡ lỗi, để bạn và các lập trình viên khác sử dụng kiểu của bạn có thể kiểm tra một thể hiện tại một điểm cụ thể trong quá trình thực thi của chương trình.

Trait Debug được yêu cầu, ví dụ, khi sử dụng macro assert_eq!. Macro này in ra giá trị của các thể hiện được đưa ra làm đối số nếu phép khẳng định bằng thất bại để các lập trình viên có thể thấy tại sao hai thể hiện không bằng nhau.

PartialEqEq cho so sánh bằng

Trait PartialEq cho phép bạn so sánh các thể hiện của một kiểu để kiểm tra sự bằng nhau và cho phép sử dụng các toán tử ==!=.

Việc derive PartialEq triển khai phương thức eq. Khi PartialEq được derive trên các struct, hai thể hiện chỉ bình đẳng khi tất cả các trường bằng nhau, và các thể hiện không bằng nhau nếu bất kỳ trường nào không bằng nhau. Khi được derive trên enum, mỗi biến thể bằng nhau với chính nó và không bằng nhau với các biến thể khác.

Trait PartialEq là cần thiết, ví dụ, khi sử dụng macro assert_eq!, cần có khả năng so sánh hai thể hiện của một kiểu về sự bằng nhau.

Trait Eq không có phương thức nào. Mục đích của nó là báo hiệu rằng với mọi giá trị của kiểu được chú thích, giá trị đó bằng chính nó. Trait Eq chỉ có thể được áp dụng cho các kiểu cũng triển khai PartialEq, mặc dù không phải tất cả các kiểu triển khai PartialEq đều có thể triển khai Eq. Một ví dụ về điều này là các kiểu số thực dấu phẩy động: việc triển khai số thực dấu phẩy động nêu rõ rằng hai thể hiện của giá trị không phải một số (NaN) không bằng nhau.

Một ví dụ về khi Eq được yêu cầu là đối với các khóa trong HashMap<K, V> để HashMap<K, V> có thể biết liệu hai khóa có giống nhau hay không.

PartialOrdOrd cho so sánh thứ tự

Trait PartialOrd cho phép bạn so sánh các thể hiện của một kiểu để sắp xếp. Một kiểu triển khai PartialOrd có thể được sử dụng với các toán tử <, >, <=>=. Bạn chỉ có thể áp dụng trait PartialOrd cho các kiểu cũng triển khai PartialEq.

Việc derive PartialOrd triển khai phương thức partial_cmp, trả về một Option<Ordering> sẽ là None khi các giá trị được đưa ra không tạo ra một thứ tự. Một ví dụ về giá trị không tạo ra thứ tự, mặc dù hầu hết các giá trị của kiểu đó có thể được so sánh, là giá trị số dấu phẩy động không phải một số (NaN). Gọi partial_cmp với bất kỳ số dấu phẩy động nào và giá trị dấu phẩy động NaN sẽ trả về None.

Khi được derive trên struct, PartialOrd so sánh hai thể hiện bằng cách so sánh giá trị trong mỗi trường theo thứ tự mà các trường xuất hiện trong định nghĩa struct. Khi được derive trên enum, các biến thể của enum được khai báo sớm hơn trong định nghĩa enum được coi là nhỏ hơn các biến thể được liệt kê sau đó.

Trait PartialOrd được yêu cầu, ví dụ, cho phương thức gen_range từ crate rand tạo ra một giá trị ngẫu nhiên trong phạm vi được chỉ định bởi một biểu thức phạm vi.

Trait Ord cho phép bạn biết rằng đối với bất kỳ hai giá trị nào của kiểu được chú thích, một thứ tự hợp lệ sẽ tồn tại. Trait Ord triển khai phương thức cmp, trả về một Ordering thay vì một Option<Ordering> vì một thứ tự hợp lệ sẽ luôn có thể xảy ra. Bạn chỉ có thể áp dụng trait Ord cho các kiểu cũng triển khai PartialOrdEq (và Eq yêu cầu PartialEq). Khi được derive trên struct và enum, cmp hoạt động giống như triển khai được derive cho partial_cmp với PartialOrd.

Một ví dụ về khi Ord được yêu cầu là khi lưu trữ giá trị trong BTreeSet<T>, một cấu trúc dữ liệu lưu trữ dữ liệu dựa trên thứ tự sắp xếp của các giá trị.

CloneCopy để Sao chép Giá trị

Trait Clone cho phép bạn tạo một bản sao sâu của một giá trị một cách rõ ràng, và quá trình sao chép có thể liên quan đến việc chạy mã tùy ý và sao chép dữ liệu trên heap. Xem Biến và Dữ liệu Tương tác với Clone trong Chương 4 để biết thêm thông tin về Clone.

Việc derive Clone triển khai phương thức clone, khi được triển khai cho toàn bộ kiểu, gọi clone trên từng phần của kiểu. Điều này có nghĩa là tất cả các trường hoặc giá trị trong kiểu cũng phải triển khai Clone để derive Clone.

Một ví dụ về khi Clone được yêu cầu là khi gọi phương thức to_vec trên một slice. Slice không sở hữu các thể hiện kiểu mà nó chứa, nhưng vector trả về từ to_vec sẽ cần sở hữu các thể hiện của nó, vì vậy to_vec gọi clone trên mỗi mục. Do đó kiểu được lưu trữ trong slice phải triển khai Clone.

Trait Copy cho phép bạn sao chép một giá trị bằng cách chỉ sao chép các bit được lưu trữ trên ngăn xếp; không cần mã tùy ý nào. Xem "Dữ liệu Chỉ-Ngăn-Xếp: Copy" trong Chương 4 để biết thêm thông tin về Copy.

Trait Copy không định nghĩa bất kỳ phương thức nào để ngăn chặn các lập trình viên ghi đè các phương thức đó và vi phạm giả định rằng không có mã tùy ý nào đang được chạy. Bằng cách đó, tất cả các lập trình viên có thể giả định rằng việc sao chép một giá trị sẽ rất nhanh.

Bạn có thể derive Copy trên bất kỳ kiểu nào mà tất cả các phần của nó đều triển khai Copy. Một kiểu triển khai Copy cũng phải triển khai Clone, bởi vì một kiểu triển khai Copy có một triển khai tầm thường của Clone thực hiện cùng một nhiệm vụ như Copy.

Trait Copy hiếm khi được yêu cầu; các kiểu triển khai Copy có các tối ưu hóa khả dụng, có nghĩa là bạn không phải gọi clone, điều này làm cho mã ngắn gọn hơn.

Mọi thứ có thể làm được với Copy bạn cũng có thể thực hiện với Clone, nhưng mã có thể chậm hơn hoặc phải sử dụng clone ở nhiều nơi.

Hash để Ánh xạ một Giá trị thành một Giá trị có Kích thước Cố định

Trait Hash cho phép bạn lấy một thể hiện của một kiểu có kích thước tùy ý và ánh xạ thể hiện đó thành một giá trị có kích thước cố định bằng cách sử dụng một hàm băm. Việc derive Hash triển khai phương thức hash. Triển khai được derive của phương thức hash kết hợp kết quả của việc gọi hash trên từng phần của kiểu, có nghĩa là tất cả các trường hoặc giá trị cũng phải triển khai Hash để derive Hash.

Một ví dụ về khi Hash được yêu cầu là trong việc lưu trữ khóa trong HashMap<K, V> để lưu trữ dữ liệu một cách hiệu quả.

Default cho Giá trị Mặc định

Trait Default cho phép bạn tạo một giá trị mặc định cho một kiểu. Việc derive Default triển khai hàm default. Triển khai được derive của hàm default gọi hàm default trên từng phần của kiểu, có nghĩa là tất cả các trường hoặc giá trị trong kiểu cũng phải triển khai Default để derive Default.

Hàm Default::default thường được sử dụng kết hợp với cú pháp cập nhật struct được thảo luận trong "Tạo Thể hiện từ Các Thể hiện Khác với Cú pháp Cập nhật Struct" trong Chương 5. Bạn có thể tùy chỉnh một vài trường của một struct và sau đó thiết lập và sử dụng giá trị mặc định cho phần còn lại của các trường bằng cách sử dụng ..Default::default().

Trait Default được yêu cầu khi bạn sử dụng phương thức unwrap_or_default trên các thể hiện Option<T>, ví dụ. Nếu Option<T>None, phương thức unwrap_or_default sẽ trả về kết quả của Default::default cho kiểu T được lưu trữ trong Option<T>.

Phụ lục D - Công cụ Phát triển Hữu ích

Trong phụ lục này, chúng ta nói về một số công cụ phát triển hữu ích mà dự án Rust cung cấp. Chúng ta sẽ xem xét định dạng tự động, cách nhanh chóng để áp dụng các sửa lỗi cảnh báo, công cụ kiểm tra và tích hợp với các IDE.

Định dạng Tự động với rustfmt

Công cụ rustfmt định dạng lại mã của bạn theo phong cách mã của cộng đồng. Nhiều dự án hợp tác sử dụng rustfmt để ngăn chặn các tranh cãi về phong cách nào để sử dụng khi viết Rust: mọi người đều định dạng mã của họ bằng công cụ này.

Cài đặt Rust bao gồm rustfmt theo mặc định, vì vậy bạn nên đã có sẵn các chương trình rustfmtcargo-fmt trên hệ thống của mình. Hai lệnh này tương tự như rustccargo ở chỗ rustfmt cho phép kiểm soát chi tiết hơn và cargo-fmt hiểu các quy ước của dự án sử dụng Cargo. Để định dạng bất kỳ dự án Cargo nào, hãy nhập như sau:

$ cargo fmt

Chạy lệnh này sẽ định dạng lại tất cả mã Rust trong crate hiện tại. Điều này chỉ nên thay đổi phong cách mã, không phải ngữ nghĩa mã.

Lệnh này cung cấp cho bạn rustfmtcargo-fmt, tương tự như cách Rust cung cấp cho bạn cả rustccargo. Để định dạng bất kỳ dự án Cargo nào, hãy nhập như sau:

$ cargo fmt

Chạy lệnh này sẽ định dạng lại tất cả mã Rust trong crate hiện tại. Điều này chỉ nên thay đổi phong cách mã, không phải ngữ nghĩa mã. Để biết thêm thông tin về rustfmt, hãy xem tài liệu của nó.

Sửa Mã của Bạn với rustfix

Công cụ rustfix được bao gồm trong cài đặt Rust và có thể tự động sửa các cảnh báo của trình biên dịch có cách rõ ràng để sửa vấn đề mà có thể là điều bạn muốn. Có thể bạn đã thấy các cảnh báo của trình biên dịch trước đây. Ví dụ, hãy xem xét mã này:

Tên tệp: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

Ở đây, chúng ta đang định nghĩa biến x là có thể thay đổi, nhưng chúng ta không bao giờ thực sự thay đổi nó. Rust cảnh báo chúng ta về điều đó:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

Cảnh báo gợi ý rằng chúng ta nên xóa từ khóa mut. Chúng ta có thể tự động áp dụng gợi ý đó bằng cách sử dụng công cụ rustfix bằng cách chạy lệnh cargo fix:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Khi chúng ta xem lại src/main.rs, chúng ta sẽ thấy rằng cargo fix đã thay đổi mã:

Tên tệp: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

Biến x giờ đây không thể thay đổi, và cảnh báo không còn xuất hiện nữa.

Bạn cũng có thể sử dụng lệnh cargo fix để chuyển đổi mã của mình giữa các phiên bản Rust khác nhau. Các phiên bản được đề cập trong Phụ lục E.

Kiểm tra Nhiều hơn với Clippy

Công cụ Clippy là một bộ sưu tập các công cụ kiểm tra để phân tích mã của bạn để bạn có thể phát hiện các lỗi thông thường và cải thiện mã Rust của mình. Clippy được bao gồm trong cài đặt Rust tiêu chuẩn.

Để chạy kiểm tra của Clippy trên bất kỳ dự án Cargo nào, hãy nhập như sau:

$ cargo clippy

Ví dụ, giả sử bạn viết một chương trình sử dụng một giá trị xấp xỉ của một hằng số toán học, như pi, như chương trình này:

fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Chạy cargo clippy trên dự án này sẽ dẫn đến lỗi này:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

Lỗi này cho bạn biết rằng Rust đã có một hằng số PI chính xác hơn được định nghĩa sẵn, và chương trình của bạn sẽ chính xác hơn nếu bạn sử dụng hằng số đó thay vì. Sau đó bạn sẽ thay đổi mã của mình để sử dụng hằng số PI. Mã sau đây không gây ra bất kỳ lỗi hoặc cảnh báo nào từ Clippy:

fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Để biết thêm thông tin về Clippy, hãy xem tài liệu của nó.

Tích hợp IDE Sử dụng rust-analyzer

Để hỗ trợ tích hợp IDE, cộng đồng Rust khuyên dùng rust-analyzer. Công cụ này là một tập hợp các tiện ích tập trung vào trình biên dịch nói chuyện theo Giao thức Máy chủ Ngôn ngữ, là một đặc điểm kỹ thuật để IDE và ngôn ngữ lập trình giao tiếp với nhau. Các client khác nhau có thể sử dụng rust-analyzer, chẳng hạn như plug-in Rust analyzer cho Visual Studio Code.

Truy cập trang chủ của dự án rust-analyzer để biết hướng dẫn cài đặt, sau đó cài đặt hỗ trợ máy chủ ngôn ngữ trong IDE cụ thể của bạn. IDE của bạn sẽ có các khả năng như tự động hoàn thành, nhảy đến định nghĩa và hiển thị lỗi trực tiếp.

Phụ lục E - Các Phiên bản

Trong Chương 1, bạn đã thấy rằng cargo new thêm một số siêu dữ liệu vào file Cargo.toml của bạn về phiên bản. Phụ lục này nói về ý nghĩa của điều đó!

Ngôn ngữ và trình biên dịch Rust có chu kỳ phát hành sáu tuần một lần, có nghĩa là người dùng nhận được một dòng liên tục các tính năng mới. Các ngôn ngữ lập trình khác phát hành những thay đổi lớn hơn ít thường xuyên hơn; Rust phát hành các bản cập nhật nhỏ hơn thường xuyên hơn. Sau một thời gian, tất cả những thay đổi nhỏ này cộng lại. Nhưng từ phiên bản này sang phiên bản khác, có thể khó để nhìn lại và nói, "Wow, giữa Rust 1.10 và Rust 1.31, Rust đã thay đổi rất nhiều!"

Cứ khoảng ba năm một lần, đội ngũ Rust tạo ra một phiên bản Rust mới. Mỗi phiên bản tập hợp các tính năng đã ra mắt thành một gói rõ ràng với tài liệu và công cụ được cập nhật đầy đủ. Các phiên bản mới được phát hành như một phần của quy trình phát hành sáu tuần một lần thông thường.

Các phiên bản phục vụ những mục đích khác nhau cho những người khác nhau:

  • Đối với người dùng Rust tích cực, một phiên bản mới tập hợp các thay đổi tăng dần thành một gói dễ hiểu.
  • Đối với người không phải người dùng, một phiên bản mới báo hiệu rằng một số cải tiến lớn đã xuất hiện, điều này có thể khiến Rust đáng để xem xét lại.
  • Đối với những người phát triển Rust, một phiên bản mới cung cấp một điểm tụ họp cho dự án nói chung.

Tại thời điểm viết bài này, có bốn phiên bản Rust: Rust 2015, Rust 2018, Rust 2021, và Rust 2024. Cuốn sách này được viết bằng các cách dùng của phiên bản Rust 2024.

Khóa edition trong Cargo.toml chỉ ra phiên bản nào trình biên dịch nên sử dụng cho mã của bạn. Nếu khóa không tồn tại, Rust sử dụng 2015 làm giá trị phiên bản vì lý do tương thích ngược.

Mỗi dự án có thể chọn một phiên bản khác với phiên bản mặc định 2015. Các phiên bản có thể chứa các thay đổi không tương thích, chẳng hạn như bao gồm một từ khóa mới xung đột với các định danh trong mã. Tuy nhiên, trừ khi bạn chọn những thay đổi đó, mã của bạn sẽ tiếp tục được biên dịch ngay cả khi bạn nâng cấp phiên bản trình biên dịch Rust mà bạn sử dụng.

Phụ lục F: Các bản dịch của Sách

Cho các tài nguyên bằng ngôn ngữ khác ngoài tiếng Anh. Hầu hết vẫn đang được tiến hành; xem nhãn Translations để giúp đỡ hoặc cho chúng tôi biết về một bản dịch mới!

Phụ lục G - Cách Rust Được Tạo Ra và "Rust Nightly"

Phụ lục này nói về cách Rust được tạo ra và điều đó ảnh hưởng như thế nào đến bạn với tư cách là một nhà phát triển Rust.

Sự Ổn Định Không Đình Trệ

Là một ngôn ngữ, Rust quan tâm rất nhiều về tính ổn định của mã của bạn. Chúng tôi muốn Rust trở thành nền tảng vững chắc mà bạn có thể xây dựng trên đó, và nếu mọi thứ liên tục thay đổi, điều đó sẽ là không thể. Đồng thời, nếu chúng ta không thể thử nghiệm với các tính năng mới, chúng ta có thể không phát hiện ra những lỗi quan trọng cho đến sau khi phát hành, khi chúng ta không thể thay đổi mọi thứ nữa.

Giải pháp của chúng tôi cho vấn đề này là điều chúng tôi gọi là "sự ổn định không đình trệ", và nguyên tắc hướng dẫn của chúng tôi là: bạn không bao giờ phải lo sợ việc nâng cấp lên phiên bản mới của Rust ổn định. Mỗi lần nâng cấp nên diễn ra suôn sẻ, nhưng cũng nên mang đến cho bạn các tính năng mới, ít lỗi hơn và thời gian biên dịch nhanh hơn.

Choo, Choo! Các Kênh Phát Hành và Đi Theo Các Đoàn Tàu

Sự phát triển của Rust hoạt động theo một lịch trình đoàn tàu. Nghĩa là, tất cả sự phát triển được thực hiện trên nhánh master của kho lưu trữ Rust. Các phiên bản phát hành tuân theo mô hình đoàn tàu phát hành phần mềm, đã được sử dụng bởi Cisco IOS và các dự án phần mềm khác. Có ba kênh phát hành cho Rust:

  • Nightly
  • Beta
  • Stable

Hầu hết các nhà phát triển Rust chủ yếu sử dụng kênh ổn định, nhưng những người muốn thử các tính năng mới có thể sử dụng nightly hoặc beta.

Dưới đây là một ví dụ về cách quá trình phát triển và phát hành hoạt động: giả sử rằng nhóm Rust đang làm việc trên phiên bản Rust 1.5. Phiên bản đó đã được phát hành vào tháng 12 năm 2015, nhưng nó sẽ cung cấp cho chúng ta các số phiên bản thực tế. Một tính năng mới được thêm vào Rust: một commit mới được đưa vào nhánh master. Mỗi đêm, một phiên bản nightly mới của Rust được tạo ra. Mỗi ngày là một ngày phát hành, và các phiên bản này được tạo ra bởi cơ sở hạ tầng phát hành của chúng tôi một cách tự động. Vì vậy, theo thời gian, các phiên bản của chúng tôi trông như thế này, mỗi đêm một lần:

nightly: * - - * - - *

Cứ mỗi sáu tuần, đã đến lúc chuẩn bị một phiên bản mới! Nhánh beta của kho lưu trữ Rust tách ra từ nhánh master được sử dụng bởi nightly. Bây giờ, có hai phiên bản:

nightly: * - - * - - *
                     |
beta:                *

Hầu hết người dùng Rust không sử dụng các phiên bản beta một cách tích cực, nhưng kiểm tra chống lại beta trong hệ thống CI của họ để giúp Rust phát hiện các lỗi có thể xảy ra. Trong khi đó, vẫn có một phiên bản nightly mỗi đêm:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

Giả sử một lỗi được phát hiện. Thật may mắn là chúng tôi đã có thời gian để kiểm tra phiên bản beta trước khi lỗi này lẻn vào phiên bản ổn định! Bản sửa lỗi được áp dụng cho master, để nightly được sửa, và sau đó bản sửa lỗi được chuyển lại cho nhánh beta, và một phiên bản beta mới được tạo ra:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

Sáu tuần sau khi phiên bản beta đầu tiên được tạo ra, đã đến lúc phát hành phiên bản ổn định! Nhánh stable được tạo ra từ nhánh beta:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

Hoan hô! Rust 1.5 đã hoàn thành! Tuy nhiên, chúng tôi đã quên một điều: vì sáu tuần đã trôi qua, chúng tôi cũng cần một phiên bản beta mới của phiên bản tiếp theo của Rust, 1.6. Vì vậy, sau khi stable tách ra từ beta, phiên bản tiếp theo của beta lại tách ra từ nightly:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

Điều này được gọi là "mô hình đoàn tàu" vì cứ mỗi sáu tuần, một phiên bản "rời khỏi nhà ga", nhưng vẫn phải trải qua một hành trình qua kênh beta trước khi nó đến như một phiên bản ổn định.

Rust phát hành mỗi sáu tuần, như đồng hồ. Nếu bạn biết ngày của một phiên bản Rust, bạn có thể biết ngày của phiên bản tiếp theo: nó là sáu tuần sau đó. Một khía cạnh hay của việc có các phiên bản được lên lịch mỗi sáu tuần là đoàn tàu tiếp theo đang đến sớm. Nếu một tính năng tình cờ bỏ lỡ một phiên bản cụ thể, không cần phải lo lắng: một phiên bản khác đang diễn ra trong thời gian ngắn! Điều này giúp giảm áp lực để lén lút các tính năng có thể chưa hoàn thiện vào gần thời hạn phát hành.

Nhờ quá trình này, bạn luôn có thể kiểm tra bản dựng tiếp theo của Rust và tự mình xác minh rằng việc nâng cấp là dễ dàng: nếu một phiên bản beta không hoạt động như mong đợi, bạn có thể báo cáo cho nhóm và sửa lỗi trước khi phiên bản ổn định tiếp theo diễn ra! Sự cố trong một phiên bản beta là tương đối hiếm, nhưng rustc vẫn là một phần mềm, và lỗi vẫn tồn tại.

Thời gian bảo trì

Dự án Rust hỗ trợ phiên bản ổn định mới nhất. Khi một phiên bản ổn định mới được phát hành, phiên bản cũ sẽ đạt đến cuối vòng đời (EOL). Điều này có nghĩa là mỗi phiên bản được hỗ trợ trong sáu tuần.

Các Tính Năng Không Ổn Định

Có một điều nữa với mô hình phát hành này: các tính năng không ổn định. Rust sử dụng một kỹ thuật gọi là "cờ tính năng" để xác định các tính năng nào được kích hoạt trong một phiên bản nhất định. Nếu một tính năng mới đang được phát triển tích cực, nó sẽ được đưa vào master, và do đó, trong nightly, nhưng đằng sau một cờ tính năng. Nếu bạn, với tư cách là người dùng, muốn thử tính năng đang trong quá trình phát triển, bạn có thể, nhưng bạn phải sử dụng phiên bản nightly của Rust và chú thích mã nguồn của bạn với cờ thích hợp để chọn tham gia.

Nếu bạn đang sử dụng phiên bản beta hoặc ổn định của Rust, bạn không thể sử dụng bất kỳ cờ tính năng nào. Đây là chìa khóa cho phép chúng tôi sử dụng thực tế với các tính năng mới trước khi chúng tôi tuyên bố chúng ổn định mãi mãi. Những người muốn tham gia vào cạnh tiên tiến có thể làm như vậy, và những người muốn có trải nghiệm vững chắc có thể gắn bó với ổn định và biết rằng mã của họ sẽ không bị hỏng. Sự ổn định không đình trệ.

Cuốn sách này chỉ chứa thông tin về các tính năng ổn định, vì các tính năng đang trong quá trình phát triển vẫn đang thay đổi, và chắc chắn chúng sẽ khác nhau giữa khi cuốn sách này được viết và khi chúng được kích hoạt trong các bản dựng ổn định. Bạn có thể tìm thấy tài liệu cho các tính năng chỉ có trong nightly trực tuyến.

Rustup và Vai Trò của Rust Nightly

Rustup giúp dễ dàng chuyển đổi giữa các kênh phát hành khác nhau của Rust, trên toàn cầu hoặc theo dự án. Theo mặc định, bạn sẽ có Rust ổn định được cài đặt. Để cài đặt nightly, ví dụ:

$ rustup toolchain install nightly

Bạn cũng có thể xem tất cả các toolchains (các phiên bản của Rust và các thành phần liên quan) mà bạn đã cài đặt với rustup. Dưới đây là một ví dụ trên máy tính Windows của một trong những tác giả của bạn:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

Như bạn có thể thấy, toolchain ổn định là mặc định. Hầu hết người dùng Rust sử dụng ổn định hầu hết thời gian. Bạn có thể muốn sử dụng ổn định hầu hết thời gian, nhưng sử dụng nightly trên một dự án cụ thể, vì bạn quan tâm đến một tính năng tiên tiến. Để làm điều đó, bạn có thể sử dụng rustup override trong thư mục của dự án đó để đặt toolchain nightly là toolchain mà rustup nên sử dụng khi bạn ở trong thư mục đó:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

Bây giờ, mỗi khi bạn gọi rustc hoặc cargo bên trong ~/projects/needs-nightly, rustup sẽ đảm bảo rằng bạn đang sử dụng Rust nightly, thay vì mặc định của bạn là Rust ổn định. Điều này rất hữu ích khi bạn có nhiều dự án Rust!

Quy Trình RFC và Các Nhóm

Vậy làm thế nào để bạn biết về những tính năng mới này? Mô hình phát triển của Rust tuân theo một Quy Trình Yêu Cầu Bình Luận (RFC). Nếu bạn muốn cải thiện Rust, bạn có thể viết một đề xuất, gọi là RFC.

Bất kỳ ai cũng có thể viết RFC để cải thiện Rust, và các đề xuất được xem xét và thảo luận bởi nhóm Rust, bao gồm nhiều nhóm chủ đề. Có một danh sách đầy đủ các nhóm trên trang web của Rust, bao gồm các nhóm cho mỗi lĩnh vực của dự án: thiết kế ngôn ngữ, triển khai trình biên dịch, cơ sở hạ tầng, tài liệu và nhiều hơn nữa. Nhóm thích hợp đọc đề xuất và các bình luận, viết một số bình luận của riêng họ, và cuối cùng, có sự đồng thuận để chấp nhận hoặc từ chối tính năng.

Nếu tính năng được chấp nhận, một vấn đề được mở trên kho lưu trữ Rust, và ai đó có thể triển khai nó. Người triển khai nó rất có thể không phải là người đề xuất tính năng ban đầu! Khi việc triển khai đã sẵn sàng, nó sẽ được đưa vào nhánh master đằng sau một cờ tính năng, như chúng tôi đã thảo luận trong phần “Các Tính Năng Không Ổn Định”.

Sau một thời gian, khi các nhà phát triển Rust sử dụng các phiên bản nightly đã có thể thử tính năng mới, các thành viên nhóm sẽ thảo luận về tính năng, cách nó hoạt động trên nightly, và quyết định xem nó có nên được đưa vào Rust ổn định hay không. Nếu quyết định là tiến lên, cờ tính năng sẽ được gỡ bỏ, và tính năng này bây giờ được coi là ổn định! Nó sẽ đi theo các đoàn tàu vào một phiên bản ổn định mới của Rust.