Quyền sở hữu và vay mượn của Rust có thể gây nhầm lẫn nếu chúng ta không nắm bắt được điều gì đang thực sự xảy ra. Điều này đặc biệt đúng khi áp dụng một phong cách lập trình đã học trước đó vào một mô hình mới; chúng tôi gọi đây là một sự thay đổi mô hình. Quyền sở hữu là một ý tưởng mới lạ, thoạt đầu khó hiểu, nhưng càng ngày chúng ta càng thực hiện càng dễ dàng.
Trước khi chúng ta đi sâu hơn về quyền sở hữu và vay mượn của Rust, trước tiên chúng ta hãy hiểu “an toàn bộ nhớ” và “rò rỉ bộ nhớ” là gì và cách các ngôn ngữ lập trình đối phó với chúng.
An toàn bộ nhớ đề cập đến trạng thái của một ứng dụng phần mềm trong đó con trỏ bộ nhớ hoặc tham chiếu luôn tham chiếu đến bộ nhớ hợp lệ. Vì khả năng xảy ra hỏng bộ nhớ nên có rất ít đảm bảo về hành vi của chương trình nếu chương trình đó không phải là bộ nhớ an toàn. Nói một cách đơn giản, nếu một chương trình không thực sự an toàn với bộ nhớ, sẽ có rất ít đảm bảo về chức năng của nó. Khi xử lý một chương trình không an toàn về bộ nhớ, một bên độc hại có thể sử dụng lỗ hổng để đọc bí mật hoặc thực thi mã tùy ý trên máy của người khác.
Hãy sử dụng mã giả để xem bộ nhớ hợp lệ là gì.
// pseudocode #1 - shows valid reference { // scope starts here int x = 5 int y = &x } // scope ends here
Trong mã giả ở trên, chúng tôi đã tạo một biến x
được gán với giá trị là 10
. Chúng tôi sử dụng toán tử &
hoặc từ khóa để tạo tham chiếu. Do đó, cú pháp &x
cho phép chúng ta tạo một tham chiếu đề cập đến giá trị của x
. Nói một cách đơn giản, chúng tôi đã tạo một biến x
sở hữu 5
và một biến y
là một tham chiếu đến x
.
Vì cả hai biến x
và y
đều nằm trong cùng một khối hoặc phạm vi, nên biến y
có một tham chiếu hợp lệ đề cập đến giá trị của x
. Kết quả là, biến y
có giá trị là 5
.
Hãy xem mã giả bên dưới. Như chúng ta có thể thấy, phạm vi của x
được giới hạn trong khối mà nó được tạo ra. Chúng tôi gặp phải các tham chiếu lơ lửng khi chúng tôi cố gắng truy cập x
bên ngoài phạm vi của nó. Tham khảo nguy hiểm…? Chính xác thì nó là gì?
// pseudocode #2 - shows invalid reference aka dangling reference { // scope starts here int x = 5 } // scope ends here int y = &x // can't access x from here; creates dangling reference
Tham chiếu treo là một con trỏ trỏ đến vị trí bộ nhớ đã được cấp cho người khác hoặc được giải phóng (giải phóng). Nếu một chương trình (hay còn gọi là quy trình ) đề cập đến bộ nhớ đã được giải phóng hoặc xóa sạch, nó có thể bị lỗi hoặc gây ra kết quả không xác định.
Phải nói rằng, mất an toàn bộ nhớ là một thuộc tính của một số ngôn ngữ lập trình cho phép người lập trình xử lý dữ liệu không hợp lệ. Do đó, tình trạng mất an toàn bộ nhớ đã tạo ra nhiều vấn đề có thể gây ra các lỗ hổng bảo mật lớn sau:
Các lỗ hổng do mất an toàn bộ nhớ là gốc rễ của nhiều mối đe dọa bảo mật nghiêm trọng khác. Thật không may, việc phát hiện ra những lỗ hổng này có thể cực kỳ khó khăn đối với các nhà phát triển.
Điều quan trọng là phải hiểu rò rỉ bộ nhớ là gì và hậu quả của nó là gì.
Rò rỉ bộ nhớ là một dạng tiêu thụ bộ nhớ không chủ ý, theo đó nhà phát triển không thể giải phóng khối bộ nhớ heap được cấp phát khi nó không còn cần thiết nữa. Nó chỉ đơn giản là đối lập với an toàn bộ nhớ. Sau này sẽ có thêm thông tin về các loại bộ nhớ khác nhau, nhưng hiện tại, chỉ cần biết rằng ngăn xếp lưu trữ các biến có độ dài cố định đã biết tại thời điểm biên dịch, trong khi kích thước của các biến có thể thay đổi sau đó trong thời gian chạy phải được đặt trên heap .
Khi so sánh với cấp phát bộ nhớ heap, cấp phát bộ nhớ ngăn xếp được coi là an toàn hơn vì bộ nhớ sẽ tự động được giải phóng khi nó không còn phù hợp hoặc cần thiết, bởi lập trình viên hoặc bởi chính thời gian chạy chương trình.
Tuy nhiên, khi các lập trình viên tạo bộ nhớ trên heap và không thể xóa bộ nhớ đó trong trường hợp không có bộ thu gom rác (trong trường hợp của C và C ++), thì sẽ phát sinh rò rỉ bộ nhớ. Ngoài ra, nếu chúng ta mất tất cả các tham chiếu đến một đoạn bộ nhớ mà không phân bổ bộ nhớ đó, thì chúng ta sẽ bị rò rỉ bộ nhớ. Chương trình của chúng tôi sẽ tiếp tục sở hữu bộ nhớ đó, nhưng nó không có cách nào sử dụng lại được nữa.
Rò rỉ một chút bộ nhớ không phải là vấn đề, nhưng nếu một chương trình phân bổ lượng bộ nhớ lớn hơn và không bao giờ phân bổ nó, vùng nhớ của chương trình sẽ tiếp tục tăng, dẫn đến Từ chối Dịch vụ.
Khi một chương trình thoát ra, hệ điều hành sẽ ngay lập tức khôi phục tất cả bộ nhớ mà nó sở hữu. Kết quả là, rò rỉ bộ nhớ chỉ ảnh hưởng đến một chương trình khi nó đang chạy; nó không có hiệu lực khi chương trình đã kết thúc.
Hãy cùng điểm qua những hậu quả chính của việc rò rỉ bộ nhớ.
Rò rỉ bộ nhớ làm giảm hiệu suất của máy tính bằng cách giảm dung lượng bộ nhớ khả dụng (bộ nhớ heap). Cuối cùng, nó khiến toàn bộ hoặc một phần của hệ thống ngừng hoạt động bình thường hoặc chậm lại nghiêm trọng. Sự cố thường liên quan đến rò rỉ bộ nhớ.
Cách tiếp cận của chúng tôi để tìm ra cách ngăn chặn rò rỉ bộ nhớ sẽ khác nhau tùy thuộc vào ngôn ngữ lập trình mà chúng tôi đang sử dụng. Rò rỉ bộ nhớ có thể bắt đầu như một "vấn đề nhỏ và gần như không đáng chú ý", nhưng chúng có thể leo thang rất nhanh và lấn át hệ thống mà chúng tác động. Bất cứ nơi nào khả thi, chúng ta nên theo dõi chúng và hành động để khắc phục chúng thay vì để chúng phát triển.
Rò rỉ bộ nhớ và mất an toàn bộ nhớ là hai loại vấn đề nhận được sự quan tâm lớn nhất về cách phòng ngừa và khắc phục. Điều quan trọng cần lưu ý là việc sửa một cái không tự động sửa cái kia.
Trước khi chúng ta đi xa hơn, điều quan trọng là phải hiểu các loại bộ nhớ khác nhau mà mã của chúng ta sẽ sử dụng trong thời gian chạy.
Có hai loại bộ nhớ, như sau, và những bộ nhớ này được cấu trúc khác nhau.
Đăng ký bộ xử lý
Tĩnh
Cây rơm
Đống
Cả hai loại thanh ghi bộ xử lý và bộ nhớ tĩnh đều nằm ngoài phạm vi của bài đăng này.
Ngăn xếp lưu trữ dữ liệu theo thứ tự nhận và xóa dữ liệu theo thứ tự ngược lại. Các mục có thể được truy cập từ ngăn xếp theo thứ tự cuối cùng vào trước (LIFO). Việc thêm dữ liệu vào ngăn xếp được gọi là "đẩy" và xóa dữ liệu khỏi ngăn xếp được gọi là "bật".
Tất cả dữ liệu được lưu trữ trên ngăn xếp phải có kích thước cố định, đã biết. Thay vào đó, 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 sau này phải được lưu trữ trên heap.
Là nhà phát triển, chúng tôi không phải lo lắng về việc phân bổ bộ nhớ ngăn xếp và phân bổ giao dịch ; việc cấp phát và phân bổ bộ nhớ ngăn xếp được trình biên dịch “tự động thực hiện”. Nó ngụ ý rằng khi dữ liệu trên ngăn xếp không còn phù hợp (ngoài phạm vi), nó sẽ tự động bị xóa mà không cần sự can thiệp của chúng tôi.
Loại cấp phát bộ nhớ này còn được gọi là cấp phát bộ nhớ tạm thời , bởi vì ngay sau khi hàm kết thúc quá trình thực thi, tất cả dữ liệu thuộc về hàm đó sẽ được tự động chuyển ra khỏi ngăn xếp.
Tất cả các kiểu nguyên thủy trong Rust đều nằm trên ngăn xếp. Các loại như số, ký tự, lát cắt, boolean, mảng có kích thước cố định, bộ chứa các nguyên thủy và con trỏ hàm đều có thể nằm trên ngăn xếp.
Không giống như ngăn xếp, khi chúng ta đặt dữ liệu trên heap, chúng ta yêu cầu một lượng không gian nhất định. Bộ cấp phát bộ nhớ định vị một vị trí đủ lớn không bị chiếm dụng trong heap, đánh dấu nó là đang được sử dụng và trả về một tham chiếu đến địa chỉ của vị trí đó. Điều này được gọi là phân bổ .
Phân bổ trên heap chậm hơn so với đẩy vào ngăn xếp vì trình phân bổ không bao giờ phải tìm kiếm một vị trí trống để đặt dữ liệu mới. Hơn nữa, bởi vì chúng ta phải đi theo một con trỏ để truy cập dữ liệu trên heap, nó sẽ chậm hơn so với việc truy cập dữ liệu trên ngăn xếp. Không giống như ngăn xếp, được cấp phát và phân bổ tại thời điểm biên dịch, bộ nhớ heap được cấp phát và phân bổ trong quá trình thực thi các lệnh của chương trình.
Trong một số ngôn ngữ lập trình, để cấp phát bộ nhớ heap, chúng tôi sử dụng từ khóa new
. Từ khóa new
này (hay còn gọi là toán tử ) biểu thị yêu cầu cấp phát bộ nhớ trên heap. Nếu có đủ bộ nhớ trên heap, toán tử new
khởi tạo bộ nhớ và trả về địa chỉ duy nhất của bộ nhớ mới được cấp phát đó.
Điều đáng nói là bộ nhớ heap được phân bổ “rõ ràng” bởi lập trình viên hoặc thời gian chạy.
Khi nói đến quản lý bộ nhớ, đặc biệt là bộ nhớ heap, chúng tôi muốn các ngôn ngữ lập trình của mình có các đặc điểm sau:
An toàn bộ nhớ được đảm bảo theo nhiều cách khác nhau bằng các ngôn ngữ lập trình bằng các phương tiện:
Cả hai hệ thống quản lý bộ nhớ dựa trên vùng và hệ thống loại tuyến tính đều nằm ngoài phạm vi của bài đăng này.
Lập trình viên phải giải phóng hoặc xóa bộ nhớ được cấp phát “thủ công” khi sử dụng quản lý bộ nhớ rõ ràng. Toán tử “phân bổ” (ví dụ: delete
trong C) tồn tại trong các ngôn ngữ có phân bổ bộ nhớ rõ ràng.
Việc thu gom rác quá tốn kém trong các ngôn ngữ hệ thống như C và C ++, do đó việc cấp phát bộ nhớ rõ ràng vẫn tiếp tục tồn tại.
Để lại trách nhiệm giải phóng bộ nhớ cho lập trình viên có lợi là trao cho lập trình viên toàn quyền kiểm soát vòng đời của biến. Tuy nhiên, nếu các toán tử phân bổ giao dịch được sử dụng không chính xác, một lỗi phần mềm có thể xảy ra trong quá trình thực thi. Trên thực tế, quá trình phân bổ và phát hành thủ công này rất dễ xảy ra sai sót. Một số lỗi mã hóa phổ biến bao gồm:
Mặc dù vậy, chúng tôi ưu tiên quản lý bộ nhớ thủ công hơn là thu thập rác vì nó cho phép chúng tôi kiểm soát nhiều hơn và cung cấp hiệu suất tốt hơn. Lưu ý rằng mục tiêu của bất kỳ ngôn ngữ lập trình hệ thống nào là càng "gần với kim loại" càng tốt. Nói cách khác, họ ưu tiên hiệu suất tốt hơn so với các tính năng tiện lợi khi đánh đổi.
Chúng tôi (các nhà phát triển) hoàn toàn có trách nhiệm đảm bảo rằng không có con trỏ nào đến giá trị mà chúng tôi đã giải phóng được sử dụng.
Trong quá khứ gần đây, đã có một số mô hình đã được chứng minh để tránh những lỗi này, nhưng tất cả đều tóm lại để duy trì kỷ luật mã nghiêm ngặt, đòi hỏi áp dụng phương pháp quản lý bộ nhớ phù hợp một cách nhất quán.
Những điều chính cần rút ra là:
Quản lý bộ nhớ tự động đã trở thành một tính năng thiết yếu của tất cả các ngôn ngữ lập trình hiện đại, bao gồm cả Java.
Trong trường hợp phân bổ bộ nhớ tự động, bộ thu gom rác đóng vai trò là bộ quản lý bộ nhớ tự động. Những bộ thu gom rác này định kỳ đi qua đống và tái chế các phần bộ nhớ không được sử dụng. Họ thay mặt chúng tôi quản lý việc cấp phát và giải phóng bộ nhớ. Vì vậy, chúng ta không phải viết mã để thực hiện các tác vụ quản lý bộ nhớ. Điều đó thật tuyệt vì những người thu gom rác giải phóng chúng ta khỏi trách nhiệm quản lý bộ nhớ. Một ưu điểm khác là nó làm giảm thời gian phát triển.
Mặt khác, thu gom rác có một số hạn chế. Trong quá trình thu gom rác, chương trình nên tạm dừng và dành thời gian xác định những thứ cần dọn dẹp trước khi tiếp tục.
Hơn nữa, quản lý bộ nhớ tự động có nhu cầu bộ nhớ cao hơn. Điều này là do thực tế là bộ thu gom rác thực hiện phân bổ bộ nhớ cho chúng ta, việc này tiêu tốn cả chu kỳ bộ nhớ và CPU. Do đó, quản lý bộ nhớ tự động có thể làm giảm hiệu suất ứng dụng, đặc biệt là trong các ứng dụng lớn với tài nguyên hạn chế.
Những điều chính cần rút ra là:
Một số ngôn ngữ cung cấp tính năng thu gom rác , tìm kiếm bộ nhớ không còn được sử dụng trong khi chương trình chạy; những người khác yêu cầu lập trình viên cấp phát và giải phóng bộ nhớ một cách rõ ràng . Cả hai mô hình này đều có lợi ích và hạn chế. Thu gom rác, mặc dù có lẽ được sử dụng rộng rãi nhất, nhưng có một số hạn chế; nó làm cho cuộc sống của các nhà phát triển trở nên dễ dàng với chi phí tài nguyên và hiệu suất.
Phải nói rằng, một cái cung cấp khả năng kiểm soát quản lý bộ nhớ hiệu quả, trong khi cái kia cung cấp độ an toàn cao hơn bằng cách loại bỏ các tham chiếu lủng lẳng và rò rỉ bộ nhớ. Rust kết hợp những lợi ích của cả hai thế giới.
Rust có cách tiếp cận mọi thứ khác với hai cách còn lại, dựa trên mô hình quyền sở hữu với một bộ quy tắc mà trình biên dịch xác minh để đảm bảo an toàn cho bộ nhớ. Chương trình sẽ không biên dịch nếu bất kỳ quy tắc nào trong số này bị vi phạm. Trên thực tế, quyền sở hữu thay thế việc thu gom rác trong thời gian chạy bằng việc kiểm tra thời gian biên dịch để đảm bảo an toàn cho bộ nhớ.
Phải mất một thời gian để làm quen với quyền sở hữu vì nó là một khái niệm mới đối với nhiều lập trình viên, như bản thân tôi.
Đến đây, chúng ta đã hiểu cơ bản về cách dữ liệu được lưu trữ trong bộ nhớ. Hãy xem xét quyền sở hữu trong Rust kỹ hơn. Tính năng phân biệt lớn nhất của Rust là quyền sở hữu, đảm bảo an toàn cho bộ nhớ tại thời điểm biên dịch.
Để bắt đầu, hãy định nghĩa “quyền sở hữu” theo nghĩa đen nhất của nó. Quyền sở hữu là trạng thái “sở hữu” và “kiểm soát” quyền sở hữu hợp pháp đối với “thứ gì đó”. Như đã nói, chúng ta phải xác định chủ sở hữu là ai và chủ sở hữu sở hữu và kiểm soát những gì . Trong Rust, mỗi giá trị có một biến được gọi là chủ sở hữu của nó. Nói một cách đơn giản, một biến là một chủ sở hữu và giá trị của một biến là những gì chủ sở hữu sở hữu và kiểm soát.
Với mô hình sở hữu, bộ nhớ sẽ tự động được giải phóng (giải phóng) khi biến sở hữu nó vượt ra khỏi phạm vi. Khi các giá trị vượt ra khỏi phạm vi hoặc vòng đời của chúng kết thúc vì một số lý do khác, trình hủy của chúng được gọi. Một hàm hủy, đặc biệt là một hàm hủy tự động, là một hàm xóa dấu vết của một giá trị khỏi chương trình bằng cách xóa các tham chiếu và giải phóng bộ nhớ.
Rust thực hiện quyền sở hữu thông qua công cụ kiểm tra khoản vay ,
Như đã nêu trước đây, mô hình sở hữu được xây dựng dựa trên một tập hợp các quy tắc được gọi là quy tắc sở hữu và các quy tắc này tương đối đơn giản. Trình biên dịch Rust (gỉc) thực thi các quy tắc sau:
Các lỗi bộ nhớ sau được bảo vệ bởi các quy tắc sở hữu kiểm tra thời gian biên dịch này:
Trước khi đi vào chi tiết của từng quy tắc sở hữu, điều quan trọng là phải hiểu sự khác biệt giữa sao chép , di chuyển và sao chép .
Một kiểu có kích thước cố định (đặc biệt là các kiểu nguyên thủy) có thể được lưu trữ trên ngăn xếp và bật ra khi phạm vi của nó kết thúc và có thể được sao chép nhanh chóng và dễ dàng để tạo một biến mới, độc lập nếu một phần khác của mã yêu cầu cùng một giá trị trong một phạm vi khác. Vì sao chép bộ nhớ ngăn xếp rẻ và nhanh, các loại nguyên thủy có kích thước cố định được cho là có ngữ nghĩa sao chép . Nó rẻ tiền tạo ra một bản sao hoàn hảo (một bản sao).
Cần lưu ý rằng các kiểu nguyên thủy có kích thước cố định thực hiện đặc điểm sao chép để tạo bản sao.
let x = "hello"; let y = x; println!("{}", x) // hello println!("{}", y) // hello
Trong Rust, có hai loại chuỗi:
String
(heap được phân bổ và có thể phát triển) và&str
(kích thước cố định và không thể thay đổi).
Bởi vì x
được lưu trữ trên ngăn xếp, việc sao chép giá trị của nó để tạo ra một bản sao khác cho y
dễ dàng hơn. Đây không phải là trường hợp của một giá trị được lưu trữ trên heap. Đây là cách khung ngăn xếp trông:
Việc sao chép dữ liệu làm tăng thời gian chạy chương trình và tiêu thụ bộ nhớ. Do đó, sao chép không phù hợp với khối lượng lớn dữ liệu.
Theo thuật ngữ Rust, "di chuyển" có nghĩa là quyền sở hữu bộ nhớ được chuyển cho chủ sở hữu khác. Hãy xem xét trường hợp các kiểu phức tạp được lưu trữ trên heap.
let s1 = String::from("hello"); let s2 = s1;
Chúng ta có thể giả định rằng dòng thứ hai (tức là let s2 = s1;
) sẽ tạo một bản sao của giá trị trong s1
và liên kết nó với s2
. Nhưng đây không phải là trường hợp.
Hãy xem phần bên dưới để biết điều gì đang xảy ra với String
. Chuỗi được tạo thành từ ba phần, được lưu trữ trên ngăn xếp . Nội dung thực tế (xin chào, trong trường hợp này) được lưu trữ trên heap .
String
hiện đang sử dụng.String
đã nhận được từ bộ cấp phát.
Nói cách khác, siêu dữ liệu được giữ trên ngăn xếp trong khi dữ liệu thực tế được giữ trên đống.
Khi chúng tôi gán s1
cho s2
, siêu dữ liệu String
được sao chép, nghĩa là chúng tôi sao chép con trỏ, độ dài và dung lượng có trên ngăn xếp. Chúng tôi không sao chép dữ liệu trên heap mà con trỏ đề cập đến. Biểu diễn dữ liệu trong bộ nhớ trông giống như dưới đây:
Cần lưu ý rằng biểu diễn không giống như bên dưới, đó là bộ nhớ sẽ trông như thế nào nếu Rust cũng sao chép dữ liệu heap. Nếu Rust thực hiện điều này, hoạt động s2 = s1
có thể cực kỳ chậm về hiệu suất thời gian chạy nếu dữ liệu đống lớn.
Lưu ý rằng khi các kiểu phức tạp không còn trong phạm vi, Rust sẽ gọi hàm drop
để phân bổ rõ ràng bộ nhớ heap. Tuy nhiên, cả hai con trỏ dữ liệu trong Hình 6 đều trỏ đến cùng một vị trí, đó không phải là cách Rust hoạt động. Chúng tôi sẽ đi vào chi tiết ngay sau đây.
Như đã nêu trước đây, khi chúng ta gán s1
cho s2
, biến s2
sẽ nhận được một bản sao siêu dữ liệu của s1
(con trỏ, độ dài và dung lượng). Nhưng điều gì sẽ xảy ra với s1
khi nó được gán cho s2
? Rust không còn coi s1
là hợp lệ. Vâng, bạn đã đọc dúng điều đó.
Hãy suy nghĩ về điều này, let s2 = s1
trong giây lát. Hãy xem xét điều gì sẽ xảy ra nếu Rust vẫn coi s1
là hợp lệ sau lần gán này. Khi s2
và s1
vượt ra ngoài phạm vi, cả hai sẽ cố gắng giải phóng cùng một bộ nhớ. Uh-oh, điều đó không tốt. Đây được coi là một lỗi kép , và nó là một trong những lỗi an toàn cho bộ nhớ. Bộ nhớ bị hỏng có thể do giải phóng bộ nhớ hai lần, gây ra nguy cơ bảo mật.
Để đảm bảo an toàn cho bộ nhớ, Rust coi s1
không hợp lệ sau dòng let s2 = s1
. Do đó, khi s1
không còn trong phạm vi, Rust không cần giải phóng bất cứ thứ gì. Kiểm tra điều gì sẽ xảy ra nếu chúng ta cố gắng sử dụng s1
sau khi s2
đã được tạo.
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.
Chúng tôi sẽ gặp lỗi giống như lỗi bên dưới vì Rust ngăn bạn sử dụng tham chiếu không hợp lệ:
$ cargo run Compiling playground v0.0.1 (/playground) error[E0382]: borrow of moved value: `s1` --> src/main.rs:6:28 | 3 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait 4 | let s2 = s1; | -- value moved here 5 | 6 | println!("{}, world!", s1); | ^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info) For more information about this error, try `rustc --explain E0382`.
Khi Rust “chuyển” quyền sở hữu bộ nhớ của s1
sang s2
sau dòng let s2 = s1
, nó được coi là s1
không hợp lệ. Đây là biểu diễn bộ nhớ sau khi s1 đã bị vô hiệu:
Khi chỉ s2
còn hiệu lực, nó sẽ giải phóng bộ nhớ khi nó vượt ra khỏi phạm vi. Kết quả là, khả năng xảy ra lỗi kép được loại bỏ trong Rust. Điều đó thật tuyệt vời!
Nếu chúng ta muốn sao chép sâu dữ liệu đống của String
, không chỉ dữ liệu ngăn xếp, chúng ta có thể sử dụng một phương pháp gọi là clone
. Dưới đây là một ví dụ về cách sử dụng phương pháp nhân bản:
let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2);
Khi sử dụng phương thức sao chép, dữ liệu đống sẽ được sao chép vào s2. Điều này hoạt động hoàn hảo và tạo ra hành vi sau:
Việc sử dụng phương pháp nhân bản gây hậu quả nghiêm trọng; nó không chỉ sao chép dữ liệu mà còn không đồng bộ hóa bất kỳ thay đổi nào giữa cả hai. Nói chung, việc nhân bản nên được lên kế hoạch cẩn thận và có nhận thức đầy đủ về hậu quả.
Bây giờ, chúng ta đã có thể phân biệt giữa sao chép, di chuyển và nhân bản. Bây giờ chúng ta hãy xem xét từng quy tắc sở hữu chi tiết hơn.
Mỗi giá trị có một biến được gọi là chủ sở hữu của nó. Nó ngụ ý rằng tất cả các giá trị đều thuộc sở hữu của các biến. Trong ví dụ bên dưới, biến s
sở hữu con trỏ tới chuỗi của chúng ta và ở dòng thứ hai, biến x
sở hữu giá trị 1.
let s = String::from("Rule 1"); let n = 1;
Chỉ có thể có một chủ sở hữu của một giá trị tại một thời điểm nhất định. Một người có thể có nhiều vật nuôi, nhưng khi nói đến mô hình sở hữu, chỉ có một giá trị tại bất kỳ thời điểm nào :-)
Hãy xem ví dụ sử dụng các nguyên thủy , có kích thước cố định được biết đến tại thời điểm biên dịch.
let x = 10; let y = x; let z = x;
Chúng tôi đã lấy 10 và gán nó cho x
; nói cách khác, x
sở hữu 10. Sau đó, chúng ta lấy x
và gán nó cho y
và chúng ta cũng gán nó cho z
. Chúng tôi biết rằng chỉ có thể có một chủ sở hữu tại một thời điểm nhất định, nhưng chúng tôi không nhận được bất kỳ lỗi nào ở đây. Vì vậy, những gì đang xảy ra ở đây là trình biên dịch đang tạo bản sao của x
mỗi khi chúng ta gán nó cho một biến mới.
Khung ngăn xếp cho điều này sẽ như sau: x = 10
, y = 10
và z = 10
. Tuy nhiên, điều này dường như không xảy ra như sau: x = 10
, y = x
và z = x
. Như chúng ta đã biết, x
là chủ sở hữu duy nhất của giá trị 10 này và cả y
và z
đều không thể sở hữu giá trị này.
Vì sao chép bộ nhớ ngăn xếp rẻ và nhanh, các kiểu nguyên thủy có kích thước cố định được cho là có ngữ nghĩa sao chép , trong khi các kiểu phức tạp di chuyển quyền sở hữu, như đã nêu trước đây. Vì vậy, trong trường hợp này, trình biên dịch tạo ra các bản sao .
Tại thời điểm này, hành vi của
Hãy xem dữ liệu được lưu trữ trên heap và xem cách Rust hiểu khi nào cần dọn dẹp nó; kiểu Chuỗi là một ví dụ tuyệt vời cho trường hợp sử dụng này. Chúng tôi sẽ tập trung vào hành vi liên quan đến quyền sở hữu của String; Tuy nhiên, các nguyên tắc này cũng áp dụng cho các kiểu dữ liệu phức tạp khác.
Loại phức tạp, như chúng ta biết, quản lý dữ liệu trên heap và nội dung của nó không được biết tại thời điểm biên dịch. Hãy xem xét cùng một ví dụ mà chúng ta đã thấy trước đây:
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.
Trong trường hợp loại
String
, kích thước có thể mở rộng và được lưu trữ trên heap. Điều này có nghĩa là:
- Trong thời gian chạy, bộ nhớ phải được yêu cầu từ bộ cấp phát bộ nhớ (chúng ta hãy gọi nó là phần đầu tiên).
- Khi chúng ta sử dụng xong
String
của mình, chúng ta cần trả lại (giải phóng) bộ nhớ này trở lại bộ cấp phát (chúng ta hãy gọi nó là phần thứ hai).
Chúng tôi (các nhà phát triển) đã quan tâm đến phần đầu tiên: khi chúng tôi gọi
String::from
, việc triển khai nó sẽ yêu cầu bộ nhớ mà nó cần. Phần này hầu như phổ biến trên các ngôn ngữ lập trình.
Tuy nhiên, phần thứ hai thì khác. 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 và chúng ta không phải lo lắng về điều đó. Trong các ngôn ngữ không có bộ thu gom rác, chúng ta có trách nhiệm xác định khi nào bộ nhớ không còn cần thiết nữa và yêu cầu giải phóng bộ nhớ một cách rõ ràng. Luôn luôn là một nhiệm vụ lập trình đầy thách thức để thực hiện điều này một cách chính xác:
- Chúng ta sẽ lãng phí bộ nhớ nếu chúng ta quên.
- Chúng tôi sẽ có một biến không hợp lệ nếu chúng tôi làm điều đó quá sớm.
- Chúng tôi sẽ gặp lỗi nếu chúng tôi làm điều đó hai lần.
Rust xử lý việc phân bổ bộ nhớ theo một cách mới để giúp cuộc sống của chúng ta dễ dàng hơn: bộ nhớ sẽ tự động được trả lại khi biến sở hữu nó vượt ra khỏi phạm vi.
Hãy trở lại kinh doanh. Trong Rust, đối với các kiểu phức tạp, các thao tác như gán giá trị cho một biến, chuyển nó cho một hàm hoặc trả lại nó từ một hàm sẽ không sao chép giá trị: chúng di chuyển nó. Nói một cách đơn giản, các kiểu phức tạp sẽ di chuyển quyền sở hữu.
Khi các kiểu phức tạp không còn trong phạm vi, Rust sẽ gọi hàm drop để phân bổ rõ ràng bộ nhớ heap.
Khi chủ sở hữu đi ra khỏi phạm vi, giá trị sẽ bị giảm. Hãy xem xét lại trường hợp trước:
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. The value of s1 has already been dropped.
Giá trị của s1
đã giảm xuống sau khi s1
được gán cho s2
(trong câu lệnh gán let s2 = s1
). Do đó, s1
không còn giá trị sau lần gán này. Đây là biểu diễn bộ nhớ sau khi s1 đã bị loại bỏ:
Có ba cách để chuyển quyền sở hữu từ biến này sang biến khác trong chương trình Rust:
Truyền một giá trị cho một hàm có ngữ nghĩa tương tự như việc gán một giá trị cho một biến. Cũng giống như phép gán, việc chuyển một biến cho một hàm khiến nó di chuyển hoặc sao chép. Hãy xem ví dụ này, ví dụ này hiển thị cả trường hợp sử dụng sao chép và di chuyển:
fn main() { let s = String::from("hello"); // s comes into scope move_ownership(s); // s's value moves into the function... // so it's no longer valid from this // point forward let x = 5; // x comes into scope makes_copy(x); // x would move into the function // It follows copy semantics since it's // primitive, so we use x afterward } // Here, x goes out of scope, then s. But because s's value was moved, nothing // special happens. fn move_ownership(some_string: String) { // some_string comes into scope println!("{}", some_string); } // Here, some_string goes out of scope and `drop` is called. // The occupied 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 tôi cố gắng sử dụng s sau cuộc gọi đến move_ownership
, Rust sẽ gây ra lỗi thời gian biên dịch.
Giá trị trả về cũng có thể chuyển quyền sở hữu. Ví dụ dưới đây cho thấy một hàm trả về một giá trị, với các chú thích giống hệt với các chú thích trong ví dụ trước.
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 it 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 luôn tuân theo cùng một mẫu: một giá trị được di chuyển khi nó được gán cho một biến khác . Trừ khi quyền sở hữu dữ liệu đã được chuyển sang một biến khác, khi một biến bao gồm dữ liệu trên heap vượt ra khỏi phạm vi, giá trị sẽ bị xóa từng drop
.
Hy vọng rằng điều này cho chúng ta hiểu cơ bản về mô hình quyền sở hữu là gì và nó ảnh hưởng như thế nào đến cách Rust xử lý các giá trị, chẳng hạn như gán chúng cho nhau và chuyển chúng vào và ra khỏi các hàm.
Cầm giữ. Một điều nữa…
Mô hình sở hữu của Rust, cũng như với tất cả những điều tốt đẹp, có một số nhược điểm nhất định. Chúng tôi nhanh chóng nhận ra những bất tiện nhất định sau khi bắt đầu làm việc trên Rust. Chúng tôi có thể nhận thấy rằng việc giành quyền sở hữu và sau đó trả lại quyền sở hữu với mỗi chức năng là một chút bất tiện.
Thật khó chịu khi mọi thứ chúng ta truyền vào một hàm phải được trả về nếu chúng ta muốn sử dụng lại, ngoài bất kỳ dữ liệu nào khác được trả về bởi hàm đó. Điều gì sẽ xảy ra nếu chúng ta muốn một hàm sử dụng một giá trị mà không có quyền sở hữu nó?
Hãy xem xét ví dụ sau. Đoạn mã dưới đây sẽ dẫn đến lỗi vì biến, v
không còn được sử dụng bởi hàm main
(trong println!
) Đã sở hữu nó ban đầu khi quyền sở hữu được chuyển sang hàm print_vector
.
fn main() { let v = vec![10,20,30]; print_vector(v); println!("{}", v[0]); // this line gives us an error } fn print_vector(x: Vec<i32>) { println!("Inside print_vector function {:?}",x); }
Theo dõi quyền sở hữu có vẻ dễ dàng, nhưng nó có thể trở nên phức tạp khi chúng ta bắt đầu xử lý các chương trình lớn và phức tạp. Vì vậy, chúng ta cần một cách để chuyển giao các giá trị mà không cần chuyển quyền sở hữu, đó là lúc mà khái niệm vay mượn phát huy tác dụng.
Vay mượn, theo nghĩa đen của nó, là việc nhận một thứ gì đó với lời hứa sẽ trả lại. Trong ngữ cảnh của Rust, vay mượn là một cách tiếp cận giá trị mà không đòi quyền sở hữu nó, vì nó phải được trả lại cho chủ sở hữu của nó vào một thời điểm nào đó.
Khi chúng tôi mượn một giá trị, chúng tôi tham chiếu địa chỉ bộ nhớ của nó với toán tử &
. A &
được gọi là tham chiếu . Bản thân các tài liệu tham khảo không có gì đặc biệt — ẩn sâu bên trong, chúng chỉ là địa chỉ. Đối với những người quen thuộc với con trỏ C, một tham chiếu là một con trỏ tới bộ nhớ có chứa một giá trị thuộc về (hay còn gọi là thuộc sở hữu của) một biến khác. Cần lưu ý rằng một tham chiếu không được rỗng trong Rust. Trên thực tế, một tham chiếu là một con trỏ ; đó là loại con trỏ cơ bản nhất. Chỉ có một loại con trỏ trong hầu hết các ngôn ngữ, nhưng Rust có nhiều loại con trỏ khác nhau, thay vì chỉ một. Con trỏ và các loại khác nhau của chúng là một chủ đề khác sẽ được thảo luận riêng.
Nói một cách đơn giản, Rust đề cập đến việc tạo một tham chiếu đến một giá trị nào đó như việc mượn giá trị đó, giá trị này cuối cùng phải trả lại cho chủ sở hữu của nó.
Hãy xem một ví dụ đơn giản dưới đây:
let x = 5; let y = &x; println!("Value y={}", y); println!("Address of y={:p}", y); println!("Deref of y={}", *y);
Ở trên tạo ra kết quả sau:
Value y=5 Address of y=0x7fff6c0f131c Deref of y=5
Ở đây, biến y
mượn số thuộc sở hữu của biến x
, trong khi x
vẫn sở hữu giá trị. Chúng tôi gọi y
là một tham chiếu đến x
. Khoản vay kết thúc khi y
vượt ra khỏi phạm vi, và vì y
không sở hữu giá trị nên nó không bị phá hủy. Để mượn một giá trị, hãy tham khảo bởi toán tử &
. Định dạng p, {:p}
xuất ra dưới dạng vị trí bộ nhớ được trình bày dưới dạng thập lục phân.
Trong đoạn mã trên, "*" (tức là dấu hoa thị) là một toán tử tham chiếu hoạt động trên một biến tham chiếu. Toán tử hội nghị này cho phép chúng ta lấy giá trị được lưu trữ trong địa chỉ bộ nhớ của một con trỏ.
Hãy xem cách một hàm có thể sử dụng một giá trị mà không có quyền sở hữu thông qua việc vay mượn:
fn main() { let v = vec![10,20,30]; print_vector(&v); println!("{}", v[0]); // can access v here as references can't move the value } fn print_vector(x: &Vec<i32>) { println!("Inside print_vector function {:?}", x); }
Chúng tôi đang chuyển một tham chiếu ( &v
) (hay còn gọi là chuyển qua tham chiếu ) tới hàm print_vector
thay vì chuyển quyền sở hữu (tức là chuyển theo giá trị ). Kết quả là sau khi gọi hàm print_vector
trong hàm main, chúng ta có thể truy cập v
.
Như đã nêu trước đây, một tham chiếu là một loại con trỏ và một con trỏ có thể được coi như một mũi tên trỏ đến một giá trị được lưu trữ ở nơi khác. Hãy xem xét ví dụ dưới đây:
let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y);
Trong đoạn mã trên, chúng tôi tạo một tham chiếu đến giá trị kiểu i32
và sau đó sử dụng toán tử tham chiếu để theo dõi tham chiếu đến dữ liệu. Biến x
có giá trị kiểu i32
, 5
. Chúng tôi đặt y
bằng một tham chiếu đến x
.
Đây là cách bộ nhớ ngăn xếp xuất hiện:
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 giá trị bằng y
, chúng ta phải tuân theo tham chiếu đến giá trị mà nó đang đề cập đến bằng cách sử dụng *y
(do đó, dereference ở đây). Khi chúng ta bỏ qua y
, chúng ta có quyền truy cập vào giá trị số nguyên mà y
đang trỏ tới, giá trị này chúng ta có thể so sánh với 5
.
Nếu chúng tôi cố gắng viết assert_eq!(5, y);
thay vào đó, chúng tôi sẽ gặp lỗi biên dịch này:
error[E0277]: can't compare `{integer}` with `&{integer}` --> src/main.rs:11:5 | 11 | assert_eq!(5, y); | ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
Vì chúng là các loại khác nhau, nên việc so sánh một số và tham chiếu đến một số không được phép. Do đó, chúng ta phải sử dụng toán tử tham chiếu để theo dõi tham chiếu đến giá trị mà nó trỏ tới.
Giống như biến, một tham chiếu là không thể thay đổi theo mặc định — nó có thể được thực hiện có thể thay đổi với mut
, nhưng chỉ khi chủ sở hữu của nó cũng có thể thay đổi:
let mut x = 5; let y = &mut x;
Tham chiếu bất biến còn được gọi là tham chiếu chia sẻ, trong khi tham chiếu có thể thay đổi còn được gọi là tham chiếu độc quyền.
Hãy xem xét trường hợp dưới đây. Chúng tôi cấp quyền truy cập chỉ đọc cho các tham chiếu vì chúng tôi đang sử dụng toán tử &
thay vì &mut
. Ngay cả khi nguồn n
có thể thay đổi, ref_to_n
và another_ref_to_n
thì không, vì chúng là n mượn chỉ đọc.
let mut n = 10; let ref_to_n = &n; let another_ref_to_n = &n;
Trình kiểm tra khoản vay sẽ đưa ra lỗi dưới đây:
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable --> src/main.rs:4:9 | 3 | let x = 5; | - help: consider changing this to be mutable: `mut x` 4 | let y = &mut x; | ^^^^^^ cannot borrow as mutable
Người ta có thể đặt câu hỏi tại sao việc đi vay không phải lúc nào cũng được ưu tiên hơn là chuyển đi. Nếu đúng như vậy, tại sao Rust thậm chí còn có ngữ nghĩa chuyển động , và tại sao nó không mượn theo mặc định? Lý do là không phải lúc nào cũng có thể vay được một giá trị trong Rust. Chỉ được phép vay trong một số trường hợp nhất định.
Việc đi vay có bộ quy tắc riêng mà người kiểm tra khoản vay thực hiện nghiêm ngặt trong thời gian biên dịch. Những quy tắc này đã được đưa ra để ngăn chặn các cuộc chạy đua dữ liệu . Chúng như sau:
Phạm vi của tham chiếu phải được chứa trong phạm vi của chủ sở hữu giá trị. Nếu không, tham chiếu có thể tham chiếu đến một giá trị được giải phóng, dẫn đến lỗi sử dụng sau khi không sử dụng .
let x; { let y = 0; x = &y; } println!("{}", x);
Chương trình trên cố gắng bỏ tham chiếu x
sau khi chủ sở hữu y
đi ra khỏi phạm vi. Rust ngăn chặn lỗi sử dụng sau khi miễn phí này.
Chúng tôi có thể có bao nhiêu tham chiếu bất biến (còn gọi là tham chiếu được chia sẻ) đến một phần dữ liệu cụ thể tại một thời điểm, nhưng chỉ một tham chiếu có thể thay đổi (còn gọi là tham chiếu độc quyền) được phép tại một thời điểm. Quy tắc này tồn tại để loại bỏ các cuộc đua dữ liệu . Khi hai tham chiếu trỏ đến cùng một vị trí bộ nhớ tại cùng một thời điểm, ít nhất một trong số chúng đang ghi và hành động của chúng không được đồng bộ hóa, điều này được gọi là cuộc chạy đua dữ liệu.
Chúng tôi có thể có nhiều tham chiếu bất biến tùy thích vì chúng không thay đổi dữ liệu. Mặt khác, việc vay mượn hạn chế chúng ta chỉ giữ một tham chiếu có thể thay đổi ( &mut
) tại một thời điểm để ngăn chặn khả năng xảy ra các cuộc chạy đua dữ liệu tại thời điểm biên dịch.
Hãy xem cái này:
fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }
Đoạn mã trên cố gắng tạo hai tham chiếu có thể thay đổi ( r1
và r2
) thành s
sẽ không thành công:
error[E0499]: cannot borrow `s` as mutable more than once at a time --> src/main.rs:6:14 | 5 | let r1 = &mut s; | ------ first mutable borrow occurs here 6 | let r2 = &mut s; | ^^^^^^ second mutable borrow occurs here 7 | 8 | println!("{}, {}", r1, r2); | -- first borrow later used here
Hy vọng rằng điều này làm rõ các khái niệm về quyền sở hữu và đi vay. Tôi cũng đã đề cập sơ qua về công cụ kiểm tra khoản vay, xương sống của quyền sở hữu và việc đi vay. Như tôi đã đề cập ở phần đầu, quyền sở hữu là một ý tưởng mới lạ mà thoạt đầu có thể khó hiểu, ngay cả đối với các nhà phát triển dày dạn kinh nghiệm, nhưng càng ngày càng dễ dàng hơn khi bạn làm việc với nó. Đây chỉ là bản tóm tắt về cách thực thi an toàn bộ nhớ trong Rust. Tôi đã cố gắng làm cho bài đăng này dễ hiểu nhất có thể trong khi vẫn cung cấp đủ thông tin để nắm bắt các khái niệm. Để biết thêm chi tiết về tính năng sở hữu của Rust, hãy xem tài liệu trực tuyến của họ.
Rust là một lựa chọn tuyệt vời khi hiệu suất là vấn đề và nó giải quyết các điểm khó khăn làm phiền nhiều ngôn ngữ khác, dẫn đến một bước tiến đáng kể với đường cong học tập dốc. Trong năm thứ sáu liên tiếp, Rust là ngôn ngữ được yêu thích nhất của Stack Overflow , ngụ ý rằng nhiều người có cơ hội sử dụng nó đã phải lòng nó. Cộng đồng Rust tiếp tục phát triển.
Theo Kết quả khảo sát Rust năm 2021 : Năm 2021 chắc chắn là một trong những năm quan trọng nhất trong lịch sử của Rust. Nó chứng kiến sự thành lập của Rust Foundation, phiên bản năm 2021 và một cộng đồng lớn hơn bao giờ hết. Rust dường như đang trên đà phát triển mạnh mẽ khi chúng ta tiến tới tương lai.
Chúc các bạn học vui vẻ!