Cuộc thảo luận về an toàn bộ nhớ giữa C++ và Rust đã trở thành một trong những chủ đề sôi nổi nhất trong ngành kỹ thuật phần mềm. Các cơ quan chính phủ đã lên tiếng, các buổi thuyết trình tại hội nghị được dành riêng cho vấn đề này, và quan điểm từ cả hai phía đều rất mạnh mẽ.
Tôi xin nói thẳng: Rust là một ngôn ngữ xuất sắc. Mô hình sở hữu và borrow checker của nó thực sự mang tính đột phá, và chúng phát hiện được nguyên cả nhóm lỗi ngay tại thời điểm biên dịch. Nếu bạn đang bắt đầu một dự án mới và Rust phù hợp với đội ngũ cũng như hệ sinh thái của bạn, đó là một lựa chọn tuyệt vời.
Đồng thời, C++ vẫn là nền tảng cốt lõi của những phần mềm đòi hỏi hiệu năng cao nhất thế giới: nhân hệ điều hành, engine game, trình duyệt, cơ sở dữ liệu và hệ thống tài chính. Điều đó không phải ngẫu nhiên, và cũng không phải vì những đội ngũ đó chưa từng nghe đến Rust.
Điều tôi muốn khám phá trong bài viết này là một luận điểm thường bị bỏ qua trong cuộc tranh luận: C++ hiện đại (C++11 trở lên) cung cấp các công cụ mạnh mẽ để viết mã an toàn bộ nhớ. Con trỏ thông minh, RAII, container có kiểm tra biên, và thư viện chuẩn phong phú giải quyết cùng các nhóm lỗi mà trình biên dịch của Rust thực thi. Hai ngôn ngữ đơn giản là áp dụng các cách tiếp cận thiết kế khác nhau cho cùng một vấn đề: thực thi bởi trình biên dịch so với các idiom do lập trình viên lựa chọn.
Thay vì tranh luận trừu tượng, hãy để tôi minh họa ý mình bằng các ví dụ cụ thể, có thể chạy được.
Tuyên bố miễn trừ: Các quan điểm trong bài viết này là của riêng tôi và dựa trên kinh nghiệm chuyên môn của tôi. Tất cả các ví dụ mã đều được cố tình đơn giản hóa cho mục đích giáo dục. Mỗi ngôn ngữ đều có những đánh đổi riêng; bài viết này nhằm khuyến khích thảo luận có chiều sâu, không phải để hạ thấp bất kỳ ngôn ngữ hay cộng đồng nào. Kết quả thực tế có thể khác nhau tùy trường hợp.
Ví dụ 1: Con trỏ treo (Use-After-Free)
Đây là một trong những lỗi an toàn bộ nhớ phổ biến nhất. Một con trỏ tiếp tục được sử dụng sau khi vùng nhớ mà nó trỏ tới đã bị giải phóng.
C++ tệ (Thực hành kém)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(42);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 // ptr is now dangling (it still holds the address of freed memory)
8 delete ptr;
9
10 // Undefined behavior: reading freed memory
11 std::cout << "After delete: " << *ptr << std::endl;
12
13 return 0;
14}
Vấn đề là gì:
Lập trình viên cấp phát bộ nhớ thủ công bằng new, giải phóng thủ công bằng delete, rồi tiếp tục sử dụng con trỏ thô. Đây là lỗi use-after-free kinh điển. Vấn đề không phải C++ “cho phép” điều này xảy ra — vấn đề là lập trình viên đã dùng mẫu code từ thập niên 1990 trong khi các giải pháp hiện đại đã tồn tại.
C++ tốt (Thực hành hiện đại)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(42);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Explicitly releases the memory
9 ptr.reset();
10
11 if (!ptr) {
12 std::cout << "Pointer is null, safe to handle." << std::endl;
13 }
14
15 return 0;
16}
Đã thay đổi gì:
std::unique_ptr sở hữu tài nguyên. Khi nó ra khỏi phạm vi hoặc bị reset, bộ nhớ được giải phóng và con trỏ có thể kiểm tra null. Không có con trỏ treo. Không có hành vi không xác định. Đây là C++ chuẩn từ năm 2011.
Ví dụ 2: Tràn bộ đệm
Đọc hoặc ghi vượt quá giới hạn của một mảng là một nguồn lỗ hổng bảo mật kinh điển khác.
C++ tệ (Thực hành kém)
1#include <iostream>
2#include <cstring>
3
4int main() {
5 char buffer[10];
6
7 // Copying more data than the buffer can hold
8 const char* source = "This string is way too long for the buffer";
9 std::strcpy(buffer, source);
10
11 std::cout << "Buffer: " << buffer << std::endl;
12 return 0;
13}
Vấn đề là gì:
Lập trình viên đã sử dụng hàm từ thời C (strcpy) với mảng C kích thước cố định và hoàn toàn không kiểm tra biên. Đây là loại mã khiến C++ mang tiếng xấu, nhưng nó không phải C++ hiện đại. Đó là mã C được biên dịch bằng trình biên dịch C++.
C++ tốt (Thực hành hiện đại)
1#include <iostream>
2#include <string>
3
4int main() {
5 std::string buffer;
6
7 // std::string manages its own memory and grows as needed
8 buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
9
10 std::cout << "Buffer: " << buffer << std::endl;
11
12 // Need a substring? Bounds-checked:
13 try {
14 std::string sub = buffer.substr(0, 10);
15 std::cout << "Substring: " << sub << std::endl;
16
17 // This would throw std::out_of_range:
18 // std::string bad_sub = buffer.substr(9999, 10);
19 } catch (const std::out_of_range& e) {
20 std::cout << "Caught: " << e.what() << std::endl;
21 }
22
23 return 0;
24}
Đã thay đổi gì:
std::string tự quản lý bộ nhớ của nó. Nó không thể bị tràn. Cố gắng thực hiện thao tác vượt biên với .at() hoặc .substr() với chỉ số không hợp lệ sẽ ném ra ngoại lệ có thể bắt được. Tính năng này đã có trong tiêu chuẩn từ C++98.
Ví dụ 3: Truy cập mảng vượt biên
C++ tệ (Thực hành kém)
1#include <iostream>
2
3int main() {
4 int arr[5] = {10, 20, 30, 40, 50};
5
6 // Accessing index 10 (way out of bounds)
7 std::cout << "arr[10] = " << arr[10] << std::endl;
8
9 return 0;
10}
Vấn đề là gì:
Mảng C thô hoàn toàn không có kiểm tra biên. Lập trình viên truy cập chỉ số không hợp lệ và nhận được giá trị rác (hoặc tệ hơn, một lỗ hổng bảo mật). Một lần nữa, đây là lựa chọn của lập trình viên khi sử dụng cấu trúc dữ liệu nguyên thủy nhất có sẵn.
C++ tốt (Thực hành hiện đại)
1#include <iostream>
2#include <array>
3#include <stdexcept>
4
5int main() {
6 std::array<int, 5> arr = {10, 20, 30, 40, 50};
7
8 try {
9 // .at() performs bounds checking and throws on invalid access
10 std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11 std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12 } catch (const std::out_of_range& e) {
13 std::cout << "Caught out-of-range: " << e.what() << std::endl;
14 }
15
16 return 0;
17}
Đã thay đổi gì:
std::array là một container kích thước cố định biết được kích thước của chính nó. Phương thức .at() thực hiện kiểm tra biên tại thời điểm chạy và ném ngoại lệ nếu bạn vượt quá giới hạn. Có sẵn từ C++11.
Ví dụ 4: Giải phóng đôi (Double Free)
C++ tệ (Thực hành kém)
1#include <iostream>
2
3int main() {
4 int* ptr = new int(100);
5 std::cout << "Value: " << *ptr << std::endl;
6
7 delete ptr;
8 // Double free (undefined behavior, potential crash or exploit)
9 delete ptr;
10
11 return 0;
12}
Vấn đề là gì:
Lập trình viên đã giải phóng cùng một vùng nhớ hai lần. Điều này có thể làm hỏng trạng thái nội bộ của bộ cấp phát heap và là một vector khai thác nổi tiếng. Đây, một lần nữa, là lỗi quản lý bộ nhớ thủ công.
C++ tốt (Thực hành hiện đại)
1#include <iostream>
2#include <memory>
3
4int main() {
5 auto ptr = std::make_unique<int>(100);
6 std::cout << "Value: " << *ptr << std::endl;
7
8 // Memory is freed exactly once when ptr goes out of scope.
9 // You literally cannot double-free with unique_ptr.
10 // Calling reset() is also safe:
11 ptr.reset();
12 // No-op. No crash. No UB.
13 ptr.reset();
14
15 std::cout << "Done safely." << std::endl;
16 return 0;
17}
Đã thay đổi gì:
std::unique_ptr đảm bảo ngữ nghĩa sở hữu đơn. Bộ nhớ được giải phóng chính xác một lần — hoặc khi con trỏ ra khỏi phạm vi hoặc khi reset() được gọi. Gọi reset() trên một unique_ptr đã null là thao tác an toàn không làm gì cả.
Ví dụ 5: Sở hữu chia sẻ sai vs. đúng
C++ tệ (Thực hành kém)
1#include <iostream>
2
3void use(int* p) {
4 std::cout << "Using: " << *p << std::endl;
5}
6
7int main() {
8 int* shared = new int(99);
9
10 use(shared);
11
12 delete shared;
13
14 // Another function tries to use the same pointer (use-after-free)
15 use(shared);
16
17 return 0;
18}
Vấn đề là gì:
Nhiều phần của mã chia sẻ một con trỏ thô, và không có quyền sở hữu rõ ràng. Khi một phần xoá bộ nhớ, mọi phần khác đều giữ một con trỏ treo.
C++ tốt (Thực hành hiện đại)
1#include <iostream>
2#include <memory>
3
4void use(std::shared_ptr<int> p) {
5 std::cout << "Using: " << *p
6 << " (ref count: " << p.use_count() << ")" << std::endl;
7}
8
9int main() {
10 auto shared = std::make_shared<int>(99);
11
12 use(shared);
13 use(shared);
14
15 // Memory freed automatically when the last shared_ptr goes out of scope.
16 std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18 return 0;
19}
Đã thay đổi gì:
std::shared_ptr sử dụng đếm tham chiếu để quản lý sở hữu chia sẻ. Bộ nhớ chỉ được giải phóng khi shared_ptr cuối cùng tham chiếu đến nó bị huỷ. Không có con trỏ treo. Không cần delete thủ công. Có sẵn từ C++11.
Ví dụ 6: RAII - An toàn trong C++ hiện đại
RAII (Resource Acquisition Is Initialization) có lẽ là idiom quan trọng nhất trong C++. Ý tưởng rất đơn giản: gắn vòng đời của tài nguyên với vòng đời của đối tượng. Khi đối tượng được tạo, nó thu nhận tài nguyên. Khi đối tượng bị huỷ (ra khỏi phạm vi), nó giải phóng tài nguyên. Không cần dọn dẹp thủ công. Không có cơ hội để quên.
Con trỏ thông minh chỉ là một ứng dụng của RAII. Mẫu thiết kế này hoạt động với bất kỳ tài nguyên nào: tệp, mutex, socket mạng, kết nối cơ sở dữ liệu, handle GPU — bất kể là gì.
C++ tệ (Quản lý tài nguyên thủ công)
1#include <iostream>
2#include <fstream>
3#include <stdexcept>
4
5void process_file(const std::string& filename) {
6 FILE* f = fopen(filename.c_str(), "r");
7 if (!f) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 char buf[256];
12 if (!fgets(buf, sizeof(buf), f)) {
13 // Oops - if we throw or return here, we leak the file handle
14 throw std::runtime_error("Cannot read file");
15 }
16
17 // If any exception is thrown above, fclose is never called
18 fclose(f);
19 std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23 try {
24 process_file("example.txt");
25 } catch (const std::exception& e) {
26 std::cerr << "Error: " << e.what() << std::endl;
27 }
28 return 0;
29}
Vấn đề là gì:
Lập trình viên đã sử dụng fopen/fclose kiểu C. Nếu một ngoại lệ được ném ra giữa thao tác mở và đóng, handle tệp sẽ bị rò rỉ. Khi số lượng tài nguyên và đường dẫn lỗi tăng lên, việc đóng mọi thứ một cách đáng tin cậy trở thành cơn ác mộng bảo trì.
C++ tốt (RAII với std::ifstream)
1#include <iostream>
2#include <fstream>
3#include <string>
4
5void process_file(const std::string& filename) {
6 std::ifstream file(filename);
7 if (!file.is_open()) {
8 throw std::runtime_error("Cannot open file");
9 }
10
11 std::string line;
12 if (!std::getline(file, line)) {
13 throw std::runtime_error("Cannot read file");
14 }
15
16 // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17 std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21 try {
22 process_file("example.txt");
23 } catch (const std::exception& e) {
24 std::cerr << "Error: " << e.what() << std::endl;
25 }
26 return 0;
27}
Đã thay đổi gì:
std::ifstream là một wrapper RAII bao bọc handle tệp. Tệp được mở trong constructor và đóng trong destructor — đảm bảo, bất kể hàm thoát bằng cách nào (return bình thường, ngoại lệ, return sớm). Không rò rỉ tài nguyên theo thiết kế.
RAII cho tài nguyên tuỳ chỉnh (Khoá Mutex)
RAII không giới hạn ở thư viện chuẩn. Đây là cách nó hoạt động với đồng bộ hoá luồng:
1#include <iostream>
2#include <mutex>
3#include <thread>
4#include <vector>
5
6std::mutex mtx;
7int shared_counter = 0;
8
9void increment(int times) {
10 for (int i = 0; i < times; ++i) {
11 // std::lock_guard locks the mutex on construction,
12 // unlocks it on destruction (end of scope, even if an exception occurs)
13 std::lock_guard<std::mutex> lock(mtx);
14 ++shared_counter;
15 }
16}
17
18int main() {
19 std::vector<std::thread> threads;
20 for (int i = 0; i < 4; ++i) {
21 threads.emplace_back(increment, 10000);
22 }
23
24 for (auto& t : threads) {
25 t.join();
26 }
27
28 // Always prints 40000 (no data race, no forgotten unlock)
29 std::cout << "Counter: " << shared_counter << std::endl;
30
31 return 0;
32}
Viết wrapper RAII của riêng bạn
Bạn có thể áp dụng RAII cho bất kỳ tài nguyên nào. Đây là một ví dụ tối giản:
1#include <iostream>
2#include <stdexcept>
3
4// A generic RAII wrapper (acquires in constructor, releases in destructor)
5class SocketHandle {
6 int fd_;
7public:
8 explicit SocketHandle(int fd) : fd_(fd) {
9 if (fd_ < 0) throw std::runtime_error("Invalid socket");
10 std::cout << "Socket " << fd_ << " acquired." << std::endl;
11 }
12
13 ~SocketHandle() {
14 if (fd_ >= 0) {
15 std::cout << "Socket " << fd_ << " released." << std::endl;
16 fd_ = -1;
17 }
18 }
19
20 // Prevent copying (single ownership, like unique_ptr)
21 SocketHandle(const SocketHandle&) = delete;
22 SocketHandle& operator=(const SocketHandle&) = delete;
23
24 // Allow moving
25 SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26 other.fd_ = -1;
27 }
28
29 int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33 SocketHandle sock(42); // "Acquired"
34 std::cout << "Using socket " << sock.get() << std::endl;
35 // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39 do_network_stuff();
40 std::cout << "Back in main - no resource leak." << std::endl;
41 return 0;
42}
Điểm mấu chốt:
RAII không phải là giải pháp tạm thời hay “mẹo thực hành tốt” — nó là một mẫu thiết kế nền tảng của C++ khiến rò rỉ tài nguyên trở nên bất khả thi về mặt cấu trúc khi được áp dụng nhất quán. Nó ra đời trước mô hình sở hữu của Rust và, theo nhiều cách, đã truyền cảm hứng cho nó.
Rust so sánh như thế nào?
Borrow checker của Rust phát hiện nhiều vấn đề này trước khi mã của bạn chạy, ngay tại thời điểm biên dịch. Đó là một ưu điểm thực sự và đáng kể, xứng đáng được công nhận.
Đây là cách tôi trình bày các đánh đổi một cách trung thực:
Rust thực thi an toàn ở cấp trình biên dịch; C++ cung cấp an toàn thông qua thư viện chuẩn và các idiom. Cả hai cách tiếp cận đều hợp lệ; chúng phản ánh các triết lý thiết kế khác nhau.
C++ hiện đại ngăn chặn cùng các nhóm lỗi khi các công cụ được khuyến nghị được sử dụng. Con trỏ thông minh, RAII,
std::array,std::string,std::span,std::optional— đây không phải là những tính năng thư viện ít người biết. Chúng là cách được khuyến nghị để viết C++ và đã như vậy hơn một thập kỷ.Không ngôn ngữ nào là giải pháp vạn năng. Từ khoá
unsafecủa Rust tồn tại vì một lý do — đôi khi bạn cần bước ra ngoài lưới an toàn cho FFI, hiệu năng, hoặc các thao tác cấp thấp. Và lỗi logic, race condition (trong một số mẫu), cùng lỗi thiết kế tồn tại trong mọi ngôn ngữ.Tính thực dụng rất quan trọng. Hàng tỷ dòng C++ đã qua thử thách đang chạy trong sản phẩm ngày nay. Đối với nhiều đội ngũ, việc áp dụng các thực hành C++ hiện đại, công cụ tốt hơn, và phân tích tĩnh là con đường thực tế hơn so với viết lại toàn bộ — và nó mang lại những cải thiện an toàn thực sự.
Còn về công cụ hỗ trợ thì sao?
Các lập trình viên C++ hiện đại có quyền truy cập vào các công cụ an toàn mạnh mẽ giúp phát hiện vấn đề vượt xa những gì bản thân ngôn ngữ thực thi:
| Công cụ | Mục đích |
|---|---|
| AddressSanitizer (ASan) | Phát hiện use-after-free, tràn bộ đệm, tràn ngăn xếp |
| MemorySanitizer (MSan) | Phát hiện đọc bộ nhớ chưa khởi tạo |
| UndefinedBehaviorSanitizer (UBSan) | Phát hiện tràn số có dấu, truy cập con trỏ null, và hơn nữa |
| Valgrind | Phát hiện rò rỉ bộ nhớ và profiling |
| Clang-Tidy | Phân tích tĩnh với gợi ý hiện đại hoá |
| C++ Core Guidelines | Hướng dẫn tiêu chuẩn ngành cho C++ hiện đại và an toàn |
Biên dịch các ví dụ “tệ” từ bài viết này với -fsanitize=address sẽ ngay lập tức đánh dấu các vấn đề tại thời điểm chạy. Công cụ đã có sẵn — câu hỏi là liệu lập trình viên có sử dụng chúng hay không.
Kết luận
C++ và Rust tiếp cận an toàn bộ nhớ từ các góc độ khác nhau:
- Rust biến an toàn thành mặc định và yêu cầu bạn phải chủ động tắt nó bằng
unsafe. - C++ trao toàn quyền kiểm soát cho lập trình viên và cung cấp an toàn thông qua thư viện chuẩn, RAII, và các idiom đã được thiết lập vững chắc.
Phần lớn các vấn đề an toàn bộ nhớ trong mã nguồn C++ đến từ việc sử dụng các mẫu lỗi thời — new/delete thô, mảng kiểu C, các hàm như strcpy — thay vì các công cụ hiện đại mà ngôn ngữ đã cung cấp hơn một thập kỷ qua.
Tin tốt là con đường phía trước cho C++ rất rõ ràng và được tài liệu hoá đầy đủ:
- Sử dụng con trỏ thông minh (
std::unique_ptr,std::shared_ptr) thay vìnew/deletethô - Sử dụng container chuẩn (
std::string,std::vector,std::array) thay vì bộ đệm kiểu C - Áp dụng RAII cho mọi quản lý tài nguyên
- Tận dụng sanitizer (ASan, MSan, UBSan) và phân tích tĩnh trong pipeline CI
- Tuân theo C++ Core Guidelines
Cả hai ngôn ngữ đều là công cụ mạnh mẽ, và cả hai đều có chỗ đứng trong phát triển phần mềm hiện đại. Lựa chọn tốt nhất phụ thuộc vào dự án, đội ngũ và ràng buộc của bạn — không phải vào các cuộc tranh luận trên internet.
Nếu bạn thấy bài viết này hữu ích hoặc đáng suy ngẫm, hãy chia sẻ nó. Và nếu bạn có quan điểm khác, tôi rất muốn nghe ý kiến của bạn trong phần bình luận bên dưới. Những cuộc thảo luận kỹ thuật hay nhất diễn ra khi chúng ta tranh luận bằng mã, không chỉ bằng ý kiến.
Bình luận