Bạn có biết cảm giác khi bắt đầu học một ngôn ngữ mới và kỳ vọng mình sẽ chạy nước rút, nhưng thay vào đó lại vấp ngã ngay bước đầu tiên không? Đó chính là tôi khi học Rust. Tôi có một tiện ích JavaScript hoàn toàn ổn — thứ dùng để phân tích và biến đổi một vài mảng — và tôi nghĩ mình có thể viết lại nó chỉ trong một giờ nghỉ giải lao. Tôi mở trình soạn thảo, gõ những dòng mã có vẻ rất tự nhiên, và chạy trình biên dịch. Nó "nổ tung". Không phải vì lỗi cú pháp. Không phải vì thiếu dấu chấm phẩy. Nó báo rằng tôi đã cố gắng sử dụng một giá trị đã bị di chuyển (moved). Tôi nhìn chằm chằm vào màn hình. Di chuyển? Tôi đâu có di chuyển gì đâu. Tôi chỉ gán nó cho một biến khác thôi mà.

Vùng an toàn của JavaScript

JavaScript rèn luyện bạn cách đối xử với dữ liệu như một chiếc bảng trắng dùng chung. Bạn khai báo một đối tượng hoặc một mảng, đưa nó vào một hàm, để hàm đó thêm một thuộc tính, rồi đưa nó đi nơi khác. Bộ thu gom rác (garbage collector) ngồi ở phía sau như một nhân viên vệ sinh vô hình, chờ đợi để dọn dẹp bất cứ thứ gì bạn bỏ lại. Bạn không bao giờ hỏi: "Ai sở hữu chuỗi này?". Bạn chỉ hỏi: "Tôi có thể đọc nó không?". Nếu câu trả lời là có, bạn chạm vào nó. Nếu bạn cần một tham chiếu khác, bạn chỉ cần viết let b = a và tiếp tục. Cả hai biến đều trỏ đến cùng một vùng nhớ, và nếu a thay đổi nó, b sẽ thấy sự thay đổi ngay lập tức. Nó rất tiện lợi. Nó cũng rất hỗn loạn, nhưng sự hỗn loạn đó hiếm khi gây rắc rối cho bạn vì runtime sẽ xử lý việc dọn dẹp.

Cú sốc đầu tiên từ trình biên dịch

Rust không tin tưởng bạn. Nghe có vẻ khắc nghiệt, nhưng đó là điều đầu tiên bạn học được. Ngôn ngữ này được xây dựng xoay quanh một hệ thống quyền sở hữu (ownership system) với ba quy tắc nghiêm ngặt. Thứ nhất, mỗi giá trị có chính xác một chủ sở hữu. Thứ hai, tại một thời điểm chỉ có duy nhất một chủ sở hữu. Thứ ba, khi chủ sở hữu đó ra khỏi phạm vi (out of scope), Rust sẽ tự động giải phóng giá trị đó. Không có bộ thu gom rác. Không có đếm tham chiếu (reference counting) chạy ngầm trừ khi bạn chủ động yêu cầu. Chỉ có ba quy tắc này, được trình biên dịch thực thi trước khi mã của bạn kịp chạy.

Khi tôi viết phiên bản Rust cho tiện ích JavaScript đó, tôi đã làm theo những gì cảm thấy tự nhiên. Tôi tạo một vector, vốn là câu trả lời của Rust cho mảng (array), và gán nó cho biến thứ hai. Sau đó, tôi cố gắng sử dụng biến đầu tiên. Trình biên dịch từ chối. Trong JavaScript, let b = a sẽ sao chép tham chiếu. Trong Rust, nó di chuyển quyền sở hữu (moves ownership). Biến ban đầu trở nên không hợp lệ. Trình biên dịch làm điều này để đảm bảo rằng bạn không bao giờ vô tình có hai luồng cùng tác động lên một vùng nhớ, vốn là cách mà các lỗi tranh chấp dữ liệu (data races) và lỗi use-after-free xảy ra trong các hệ thống khác.

Tại sao Rust lại "tước đoạt" đồ chơi của bạn

Hãy nhìn vào mẫu này trong JavaScript:

let a = [1, 2, 3];
let b = a;
a.push(4);
// Both a and b see 4.

Nó đã trở thành bản năng. Bạn sẽ không mảy may suy nghĩ. Trong Rust, chính trực giác đó sẽ khiến bạn bị trình biên dịch từ chối trước khi bạn kịp gõ xong. Một khi b sở hữu vector, a chỉ còn là một cái tên rỗng. Bạn không thể push thêm vào nó. Bạn thậm chí không thể nhìn vào nó. Bộ nhớ hiện đã thuộc về b.

Điều này cảm giác như một hình phạt cho đến khi bạn nhận ra nó ngăn chặn điều gì. Nếu hai biến đều có thể cùng thay đổi dữ liệu trên heap mà không có sự phối hợp, bạn sẽ đối mặt với nguy cơ hỏng bộ nhớ. Rust loại bỏ hoàn toàn danh mục lỗi đó bằng cách làm cho việc chuyển giao trở nên rõ ràng. Trình biên dịch không hề cầu kỳ. Nó đang đóng vai trò là một người gác cổng. Nó buộc bạn phải quyết định, ở mỗi bước, phần nào trong mã của bạn chịu trách nhiệm cho dữ liệu nào.

Borrowing: Giải pháp tình thế nhưng thực chất là một tính năng

Tất nhiên, nếu mọi phép gán đều chuyển giao quyền sở hữu mãi mãi, việc viết chương trình sẽ cực kỳ mệt mỏi. Bạn sẽ cần phải clone mọi thứ, gây lãng phí bộ nhớ và tốc độ. Rust giải quyết vấn đề này bằng cơ chế borrowing (mượn).

Borrowing cho phép bạn sử dụng một giá trị mà không cần chiếm hữu nó. Có hai loại, và sự khác biệt giữa chúng rất quan trọng.

  • Immutable borrows (Mượn không thay đổi), được viết là &T: Những tham chiếu này cho phép bạn đọc dữ liệu. Bạn có thể có bao nhiêu tùy thích tại cùng một