Featured image of post Sao chép sâu có thể gây ra vấn đề hiệu suất - shbet love

Sao chép sâu có thể gây ra vấn đề hiệu suất - shbet love

Hãy khám phá shbet love với chúng tôi - trang web tình yêu lý tưởng cho bạn.

Ngày 04 tháng 4 năm 2020, Trần Hạo, Bình luận 110 nhận xét, 149.254 người đọc

Do các ngôn ngữ đều có ưu điểm và nhược điểm, vì vậy tôi không muốn dùng một ngôn ngữ khác để chỉ trích vấn đề của Rust, hoặc thổi phồng Rust thành thứ gì đó hoàn hảo để hạ thấp các ngôn ngữ khác. Viết kiểu bài viết như vậy là điều rất thiếu dinh dưỡng. Bài viết này chủ yếu tập trung vào việc xem xét các thách thức trong lập trình thông qua thiết kế ngôn ngữ của Rust, đặc biệt là các mô hình lập trình quan trọng của Rust. Điều này thực sự ý nghĩa hơn, vì nó giúp bạn hiểu sâu rộng hơn.

Bài viết này khá dài với nhiều đoạn mã nguồn, lượng thông tin có thể lớn, do đó trước khi đọc bài viết này, bạn cần chuẩn bị kiến thức sau:

  • Bạn phải tương đối quen thuộc với một số tính năng và vấn đề của ngôn ngữ C++, cụ thể là: con trỏ, tham chiếu, giá trị phải chuyển giao (move), quản lý đối tượng bộ nhớ, lập trình đa hình, con trỏ thông minh…
  • Tất nhiên, bạn cũng cần hiểu chút ít về Rust, dù không biết cũng không sao, nhưng bài viết này sẽ không phải là hướng dẫn Rust, bạn có thể tham khảo “Hướng dẫn chính thức của Rust” (bản tiếng Việt).

Vì bài viết quá dài, nên tôi cần viết phần TL;DR ——

Java và Rust đã đi theo hai con đường hoàn toàn khác nhau để cải tiến C/C++ về mặt an toàn lập trình. Các vấn đề mà họ chủ yếu giải quyết liên quan đến độ an toàn của C/C++ trong việc quản lý bộ nhớ. Những vấn đề này bao gồm:

  • Quản lý bộ nhớ
  • Dữ liệu chia sẻ xuất hiện “con trỏ vô hiệu” (“dangling pointer”)

Đối với các vấn đề này:

  • Java sử dụng thu gom rác (garbage collection) kết hợp với công nghệ mã byte mạnh mẽ từ máy ảo (VM), cho phép thực hiện các kỹ thuật phức tạp như phản xạ (reflection) hay sửa đổi mã byte.
  • Rust không sử dụng thu gom rác hay VM, vì vậy, với tư cách là ngôn ngữ tĩnh, nó chỉ có thể làm việc ở cấp biên dịch. Để cho phép biên dịch viên kiểm tra các vấn đề an toàn tại thời điểm biên dịch, Rust yêu cầu các quy tắc nhất định trong lập trình, quan trọng nhất là quyền sở hữu biến.

Các quy tắc quyền sở hữu của Rust gây ra nhiều khó khăn trong lập trình. Khi viết chương trình bằng Rust, hầu hết các chương trình của bạn sẽ không dễ dàng vượt qua giai đoạn biên dịch. Trong một số trường hợp cụ thể, chẳng hạn như đóng gói hàm (closure), dữ liệu bất biến chia sẻ giữa các luồng, hay đa hình, mọi thứ trở nên phức tạp hơn và khiến bạn cảm thấy mất phương hướng.

Rust cung cấp Trait giống như giao diện trong Java, cho phép triển khai các hoạt động như xây dựng sao chép, nạp chồng toán tử, đa hình…

Khúc học Rust không hề bằng phẳng. Mặc dù nếu chương trình của bạn vượt qua được biên dịch, thì nó thường vận hành an toàn và ít lỗi.

Nếu bạn chưa nắm vững các khái niệm cơ bản của Rust, bạn sẽ không thể viết nổi chương trình, ngay cả những đoạn mã đơn giản nhất. Điều này buộc lập trình viên phải hiểu tất cả các khái niệm trước khi viết mã. Tuy nhiên, mặt khác, điều này cũng cho thấy rằng ngôn ngữ này không phù hợp với người mới bắt đầu…

Mục lục:

  • Biến có thể thay đổi
  • Quyền sở hữu biến
  • Độ phức tạp từ ngữ nghĩa Owner
  • Vòng đời
  • Closure và quyền sở hữu
  • Con trỏ thông minh của Rust
  • Luồng và con trỏ thông minh
  • Đa hình và nhận dạng thời gian chạy
  • Nạp chồng toán tử với Trait
  • Kết luận

Biến có thể thay đổi

Trước tiên, trong Rust, biến mặc định là “không thể thay đổi”. Nếu bạn khai báo một biến let x = 5;, biến x sẽ không thể thay đổi. Nghĩa là, nếu bạn cố gắng thay đổi giá trị của x như x = y + 10;, trình biên dịch sẽ báo lỗi. Nếu bạn muốn biến có thể thay đổi, bạn cần sử dụng từ khóa mut, tức là khai báo thành let mut x = 5;. Đây là một điều thú vị, vì hầu hết các ngôn ngữ phổ biến khác mặc định biến là có thể thay đổi, còn Rust lại ngược lại. Điều này có thể hiểu được, vì biến không thể thay đổi thường mang lại sự ổn định tốt hơn, trong khi biến có thể thay đổi sẽ gây ra sự không ổn định. Vì vậy, Rust dường như muốn trở thành một ngôn ngữ an toàn hơn, do đó biến mặc định là immutable.

Rust cũng có kiểu hằng số const. Vì vậy, Rust có thể tạo ra các cấu trúc như sau:

  • Hằng số: const LENGTH:u32 = 1024; - trong đó LENGTH là một hằng số kiểu u32 (số nguyên không dấu 32-bit), được sử dụng trong thời gian biên dịch.
  • Biến có thể thay đổi: let mut x = 5; - tương tự như các ngôn ngữ khác, được sử dụng trong thời gian chạy.
  • Biến không thể thay đổi: let x = 5; - với loại biến này, bạn không thể thay đổi nó, nhưng bạn có thể sử dụng let x = x + 10; để xác định lại một biến mới x. Điều game nổ hũ đăng ký tặng code này trong Rust được gọi là Shadowing, biến thứ hai che phủ biến đầu tiên.

Biến không thể thay đổi giúp tăng cường sự ổn định trong chương trình, đây là một dạng “thỏa thuận” lập trình. Khi xử lý các biến không thể thay đổi, chương trình sẽ ổn định hơn, đặc biệt trong môi trường đa luồng, vì không thể thay đổi có nghĩa là chỉ đọc không viết. Ngoài ra, so với đối tượng có thể thay đổi, chúng dễ hiểu và suy luận hơn, đồng thời cung cấp mức độ an toàn cao hơn. Với “thỏa thuận” này, trình biên dịch có thể dễ dàng phát hiện lỗi trong thời gian biên dịch. Đây là lý do tại sao trình biên dịch Rust có thể giúp bạn kiểm tra nhiều vấn đề lập trình trong thời gian biên dịch.

Để đánh dấu biến không thể thay đổi, trong C/C++ chúng ta sử dụng const, trong Java sử dụng final, trong C# sử dụng readonly, và trong Scala sử dụng val…(Trong các ngôn ngữ động như JavaScript và Python, các kiểu nguyên thủy thường là không thể thay đổi, trong khi các kiểu tự định nghĩa là có thể thay đổi).

Về Shadowing trong Rust, cá nhân tôi cảm thấy đây là một điều khá nguy hiểm. Trong suốt sự nghiệp của mình, tôi đã gặp nhiều lỗi từ việc sử dụng cùng tên biến trong các phạm vi lồng nhau, và những lỗi này rất khó tìm. Thông thường, mỗi biến nên có tên phù hợp nhất, tránh trùng tên.

Quyền sở hữu biến

Đây là một khái niệm quan trọng trong ngôn ngữ Rust. Trong lập trình, hầu hết các tình huống chúng ta đều truyền một đối tượng (biến) từ nơi này sang nơi khác. Trong quá trình truyền, chúng ta truyền một bản sao hay chính đối tượng đó, tức là câu hỏi “truyền giá trị hay truyền tham chiếu”.

  • Truyền bản sao (truyền giá trị): Một bản sao của đối tượng được truyền vào hàm hoặc đặt vào cấu trúc dữ liệu nào đó, có thể yêu cầu sao chép, và sao chép này cần phải sâu mới an toàn. Sao chép sâu có thể gây ra vấn đề hiệu suất.
  • Truyền đối tượng gốc (truyền tham chiếu): Truyền tham chiếu không cần lo lắng về chi phí sao chép, nhưng cần cân nhắc vấn đề nhiều biến cùng tham chiếu. Ví dụ, khi truyền một tham chiếu đối tượng vào danh sách hoặc một hàm nào đó, có nghĩa là nhiều đối tượng cùng có quyền kiểm soát. Nếu ai đó giải phóng đối tượng đó, các đối tượng khác sẽ gặp rắc rối. Do đó, thông thường người ta sử dụng đếm tham chiếu để chia sẻ đối tượng. Ngoài ra, tham chiếu còn có vấn đề về phạm vi, ví dụ: khi trả về một tham chiếu đối tượng từ vùng nhớ ngăn xếp của hàm, đối tượng đó có thể đã bị giải phóng khi hàm kết thúc.

Những vấn đề này cần được giải quyết trong bất kỳ ngôn ngữ lập trình nào để đảm bảo linh hoạt và đáp ứng nhu cầu của lập trình viên.

Trong C++, nếu bạn muốn truyền một đối tượng, có nhiều cách:

  • Tham chiếu hoặc con trỏ: Không sao chép, hoàn toàn chia sẻ, nhưng có thể xuất hiện con trỏ vô hiệu (“dangling pointer”), tức là một con trỏ hoặc tham chiếu chỉ đến một vùng nhớ đã bị giải phóng. Để giải quyết vấn đề này, C++ sử dụng lớp quản lý như shared_ptr.
  • Truyền bản sao: Truyền một bản sao, yêu cầu ghi đè hàm “xây dựng bản sao” và “gán”.
  • Chuyển quyền sở hữu (Move): Để giải quyết chi phí xây dựng đối tượng tạm thời, C++ cung cấp phép toán Move, di chuyển quyền sở hữu đối tượng từ một đối tượng sang đối tượng khác, giải quyết vấn đề hiệu suất khi truyền đối tượng.

Các phép toán này trong C++ cho phép bạn rất linh hoạt trong nhiều tình huống, nhưng cũng làm tăng độ phức tạp của ngôn ngữ. Ngược lại, Java loại bỏ con trỏ của C/C++ và sử dụng tham chiếu an toàn hơn, kèm theo đếm tham chiếu và thu gom rác, giảm đáng kể độ phức tạp. Trong Java, nếu bạn muốn truyền bản sao của đối tượng, bạn cần định nghĩa một hàm xây dựng bản sao hoặc sử dụng mẫu thiết kế clone(). Nếu bạn muốn hủy tham chiếu, cần gán rõ ràng tham chiếu thành đỏ 99 vip null.

Tóm lại, bất kỳ ngôn ngữ nào cũng cần giải quyết tốt vấn đề truyền đối tượng để cung cấp phương pháp lập trình linh hoạt.

Trong Rust, Rust nhấn mạnh khái niệm “quyền sở hữu”. Dưới đây là ba nguyên tắc cốt lõi về quyền sở hữu trong Rust:

  1. Mỗi giá trị trong Rust đều có một biến được gọi là “chủ sở hữu” của nó.
  2. Giá trị chỉ có một chủ sở hữu duy nhất.
  3. Khi chủ sở hữu (biến) rời khỏi phạm vi, giá trị đó sẽ bị hủy.

Điều này có nghĩa là 888b gì?

Nếu bạn muốn truyền một bản sao của đối tượng, bạn cần triển khai trait Copy cho đối tượng đó. Trait có thể hiểu là một loạt các phương thức đặc biệt của đối tượng (có thể dùng để thỏa thuận các hoạt động, ví dụ: Copy dùng để sao chép, Display dùng để in ra, v.v.).

Đối với các kiểu nguyên thủy như số nguyên, boolean, số thực, ký tự, và tuple, đều đã triển khai Copy, vì vậy khi truyền, chúng sẽ được sao chép bit-by-bit. Tuy nhiên, đối với các đối tượng phức tạp, cần sử dụng trait Clone.

Vậy là xuất hiện hai khái niệm tương tự nhưng khác nhau: CopyClone. Copy dành cho các kiểu nguyên thủy hoặc đối tượng chỉ chứa các thành phần hỗ trợ Copy, trong khi Clone dành cho lập trình viên tự định nghĩa sao chép sâu.

Xem ví dụ dưới đây để hiểu thêm về việc Rust tự động chuyển quyền sở hữu:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// takes_ownership lấy quyền sở hữu của tham số truyền vào, vì không trả về, nên biến đó sẽ không thể sử dụng nữa
fn takes_ownership(some_string: String) {
  println!("{}", some_string);
} // Tại đây, some_string rời khỏi phạm vi và phương thức drop được gọi. Bộ nhớ được giải phóng
// gives_ownership sẽ trả về giá trị và chuyển nó cho hàm gọi
fn gives_ownership() -> String {
  let some_string = String::from("hello"); // some_string vào phạm vi.
  some_string // Trả về some_string và chuyển nó cho hàm gọi
}
// takes_and_gives_back nhận chuỗi và trả về giá trị đó
fn takes_and_gives_back(mut a_string: String) -> String {
  a_string.push_str(", world");
  a_string // Trả về a_string và chuyển quyền sở hữu cho hàm gọi
}
fn main() {
  // gives_ownership sẽ chuyển giá trị trả về cho s1
  let s1 = gives_ownership();
  // Quyền sở hữu được chuyển cho takes_ownership, s1 không còn khả dụng
  takes_ownership(s1);
  // Nếu biên dịch đoạn mã này, sẽ xuất hiện lỗi s1 không còn khả dụng
  // println!("s1= {}", s1);
  //          ^^ value borrowed here after move
  let s2 = String::from("hello");// Khai báo s2
  // s2 được chuyển vào takes_and_gives_back, và giá trị trả về được chuyển cho s3.
  // s2 không còn khả dụng.
  let s3 = takes_and_gives_back(s2);
  // Nếu biên dịch đoạn mã này, sẽ xuất hiện lỗi s2 không còn khả dụng
  // println!("s2={}, s3={}", s2, s3);
  //             ^^ value borrowed here after move
  println!("s3={}", s3);
}

Phương pháp Move này rất hiệu quả về hiệu suất và an toàn. Trình biên dịch Rust sẽ giúp bạn phát hiện lỗi khi sử dụng biến đã bị Move.

Tiếp tục với phần tiếp theo…

Built with Hugo
Theme Stack thiết kế bởi Jimmy