Giới thiệu
Bài viết này là phần thứ ba trong chuỗi bài viết về cách xử lý DOM. Nếu bạn chưa đọc các phần trước, hãy tham khảo:
- Ngôn ngữ nào xử lý mô hình giống DOM tốt nhất?
- Xây dựng DOM trong JavaScript: Quyền sở hữu, X-Refs và Semantics sao chép
C++ không cung cấp garbage collector hay borrow checker, nhưng các smart pointer của nó có thể mô hình hóa các cấu trúc quyền sở hữu - nếu bạn sử dụng chúng một cách thận trọng. Thách thức đặt ra là xây dựng một cấu trúc tương tự như DOM (Tài liệu → Thẻ → Mục thẻ) hỗ trợ các tham chiếu chéo, tài nguyên bất biến chia sẻ và sao chép sâu theo topological mà không có rò rỉ hoặc hành vi không xác định.
Kết quả: Một triển khai C++ của CardDOM trong khoảng 150 dòng mã, hoạt động tốt với Valgrind, đảm bảo tính bất biến tại thời điểm biên dịch, và tự động hết hạn các tham chiếu yếu - mặc dù điều này yêu cầu sự cẩn trọng liên tục để tránh lạm dụng con trỏ.
Quyết định thiết kế
Theo dõi quyền sở hữu và tham chiếu
- Sử dụng
shared_ptrđể giữ tất cả các nút, với các kiểm tra tại thời điểm chạy để ngăn chặn tình trạng đa cha mẹ hoặc vòng lặp. - Sử dụng
weak_ptrcho các liên kết chéo (kết nối, nút bấm) để đảm bảo tự động hết hạn khi xóa:- Không cần phải hủy liên kết thủ công; các
weak_ptrđã hết hạn được xử lý an toàn với.lock(). - Các con trỏ cha được xác thực tại thời gian chạy trong quá trình thêm/xóa để chặn việc tự lồng vào hoặc cha mẹ trùng lặp.
- Không cần phải hủy liên kết thủ công; các
Xử lý / Tránh trạng thái chia sẻ có thể thay đổi
- Các kiểu
Stylekhông thể thay đổi đảm bảo tính bất biến tại thời điểm biên dịch, ngăn chặn việc sửa đổi trực tiếp các tài nguyên chia sẻ. - Sao chép khi ghi qua hàm
clone(): Các phép biến đổi yêu cầu tạo một bản sao có thể thay đổi trước. - Không có trạng thái có thể thay đổi chia sẻ trong đồ thị; các bản sao được gán lại trở lại thành
constsau khi biến đổi.
Các đánh đổi
- Không sử dụng
unique_ptrcho các nút gốc của cấu trúc: vì tất cả các nút của cấu trúc tương tự như DOM đều có thể là mục tiêu của các tham chiếu chéo (weak-ptrs), điều này buộc phải sử dụngshared_ptrkhắp nơi, làm phức tạp quyền sở hữu đơn. - Sao chép sâu hai giai đoạn: Cần có một quá trình duyệt tùy chỉnh để giải quyết topological và nối lại các
weak_ptrmột cách chính xác. - Cần có kỷ luật: Smart pointers ngăn chặn việc sử dụng sau khi giải phóng nhưng không ngăn chặn rò rỉ từ các vòng lặp.
Đảm bảo an toàn
Từ ngôn ngữ/thời gian chạy
- Smart pointers tự động giải phóng và ngăn chặn việc sử dụng sau khi giải phóng thông qua đếm tham chiếu.
- Tính đúng đắn đối với
consttừ chối quyền truy cập có thể thay đổi vào dữ liệu chia sẻ tại thời điểm biên dịch. Tuy nhiên, điều này không ngăn chặn việc có các con trỏconstvà khôngconsttới cùng một đối tượng. Điều này cần phải được thực thi thủ công. Weak_ptrhết hạn tự động ngăn chặn các tham chiếu chéo không còn giá trị mà không cần dọn dẹp thủ công.
Từ thiết kế
- Các ngoại lệ tại thời gian chạy về tình trạng đa cha mẹ/vòng lặp duy trì tính toàn vẹn của đồ thị trong quá trình sửa đổi.
- Bảo vệ tham chiếu ngăn xếp: Tất cả các tham số nên được truyền theo giá trị/tham chiếu tới smart_ptr; không có sự tiếp xúc với con trỏ thô.
Kích thước mã & Năng lực nhận thức
- Kích thước: ~150 dòng mã, tập trung vào quản lý con trỏ, các khẳng định và logic sao chép hai giai đoạn.
- Tải nhận thức: Mỗi nút DOM mới phải tái triển khai logic sao chép/thêm/xóa trẻ.
Cách sử dụng
cpp
//// Tạo mới
auto doc = make_shared<Document>();
{
auto style = make_shared<const Style>("Times", 16.5, 600);
auto card = make_shared<Card>();
auto hello = make_shared<TextItem>("Hello", style);
auto button = make_shared<ButtonItem>("Click me", card);
auto conn = make_shared<ConnectorItem>(hello, button);
card->add_item(move(hello));
card->add_item(move(button));
card->add_item(move(conn));
card->add_item(make_shared<GroupItem>());
doc->add_item(move(card));
}
cpp
// Không chia sẻ khi sửa đổi
auto hello_text = dynamic_pointer_cast<TextItem>(doc->items[0]->items[0]);
// Ngăn chặn việc sửa đổi trực tiếp tại thời điểm biên dịch:
hello_text->style->size++; // LỖI
// Sao chép rõ ràng để sửa đổi:
auto new_style = hello_text->style->clone();
new_style->size++;
hello_text->style = new_style;
// Tham chiếu ngăn xếp ngăn cản các đối tượng bị xóa
{
auto hello_text = dynamic_pointer_cast<TextItem>(doc->items[0]->items[0]);
doc->items[0]->remove_item(hello_text);
// hello_text vẫn còn sống tại đây
assert(!dynamic_pointer_cast<ConnectorItem>(
doc->items[0]->items[1])->from.expired());
} // Thực tế xóa xảy ra tại đây
// Sao chép theo topological chính xác
auto new_doc = deep_copy(doc);
assert(new_doc->items[0]->items[0] ==
dynamic_pointer_cast<ConnectorItem>(
new_doc->items[0]->items[1])->to.lock());
assert(new_doc->items[0] ==
dynamic_pointer_cast<ButtonItem>(
new_doc->items[0]->items[0])->target_card.lock());
cpp
// Ngăn chặn đa cha mẹ tại thời gian chạy
try {
doc->add_item(new_doc->items[0]);
} catch (std::runtime_error&) {
std::cout << "đa cha mẹ!\n";
}
// Phát hiện vòng lặp tại thời gian chạy
try {
auto group = make_shared<GroupItem>();
auto subgroup = make_shared<GroupItem>();
group->add_item(subgroup);
subgroup->add_item(group);
} catch (std::runtime_error&) {
std::cout << "vòng lặp\n";
}
Bảng đánh giá
| Tiêu chí | Mô tả | Kết quả |
|---|---|---|
| An toàn bộ nhớ | Tránh các mẫu truy cập không an toàn | ⚠️ Smart pointers chặn UAF; const ngăn chặn các biến đổi chia sẻ, nhưng có thể xảy ra rò rỉ con trỏ thô |
| Ngăn ngừa rò rỉ | Tránh rò rỉ bộ nhớ | ⚠️ Đếm tham chiếu + RAII xử lý hầu hết các trường hợp; các vòng lặp hoặc bỏ quên cần Valgrind |
| Rõ ràng quyền sở hữu | Liệu các mối quan hệ quyền sở hữu có rõ ràng và được thực thi? | ⚠️ Shared_ptr cho phép các tham chiếu chéo nhưng làm mờ quyền sở hữu đơn; các kiểm tra tại thời gian chạy thực thi quy tắc |
| Semantics sao chép | Liệu các phép sao chép/sao chép hoạt động có thể dự đoán và đúng? | ⚠️ Sao chép sâu hai giai đoạn thủ công giữ nguyên topological và nối lại weak_ptrs |
| Xử lý yếu | Có tồn tại sau khi xóa một phần và tham chiếu không còn? | ✔️ Weak_ptrs tự động hết hạn; không cần dọn dẹp thủ công, tham chiếu ngăn xếp kéo dài thời gian sống an toàn |
| Khả năng chống lại thời gian chạy | Có thể các thao tác DOM làm hỏng ứng dụng? | ⚠️ Các ngoại lệ về vi phạm (đa cha mẹ, vòng lặp); tồn tại qua các xóa một phần thông qua RAII |
| Biểu thức mã | Ngắn gọn và dễ bảo trì? | ⚠️ Mã boilerplate về con trỏ nhiều nhưng API có thể đọc được; logic sao chép/vòng lặp thủ công tăng thêm tải trọng |
| Đánh đổi về độ dễ sử dụng | Khó khăn trong việc thực thi bất biến? | ❌ Cao; phụ thuộc vào kỷ luật của lập trình viên - không có quyền sở hữu tại thời điểm biên dịch ngoài const |
Kết luận
C++ có thể mô hình hóa các đồ thị phức tạp giống như DOM nếu:
- Bạn tiêu chuẩn hóa việc sử dụng
shared_ptrcho các nút được tham chiếu chéo, - Sử dụng const để đảm bảo tính bất biến tại thời điểm biên dịch,
- Thực hiện
deep_copyhai giai đoạn cho sao chép có ý thức topological, - Và nuôi dưỡng kỷ luật với con trỏ thông qua các công cụ bảo vệ tại thời gian chạy và Valgrind.
Điều này mạnh mẽ nhưng không tha thứ - yêu cầu sự cảnh giác.
Với 150 dòng mã, nó chứng minh rằng C++ hiện đại xử lý các cấu trúc chia sẻ bất biến một cách mạnh mẽ, mặc dù rò rỉ và UB luôn rình rập những ai không cẩn thận. Các ngôn ngữ đếm tham chiếu như Rust cung cấp nền tảng tương tự với các mạng an toàn dựa trên panic, nhưng không ngôn ngữ nào trong danh mục này hiểu rõ về các cấu trúc DOM.
Rust: Đề cập danh dự
Borrow checker của Rust nổi bật cho các giá trị ngăn xếp nhưng quay lại đếm tham chiếu cho các đồ thị heap, phản ánh các smart pointers của C++:
Box<t>=unique_ptr<t>Rc<t>=shared_ptr<const t>Rc<RefCell<t>>≈shared_ptr<t>Weak<RefCell<t>>≈weak_ptr<t>
Sự khác biệt giữa các ngôn ngữ tập trung vào cách xử lý việc loại bỏ đối tượng trong khi nó vẫn được tham chiếu từ ngăn xếp:
- C++ có nguy cơ UB (và hầu như luôn làm hỏng bộ nhớ)
- Rust xử lý tình huống này trong
RefCell.drop()và nếuRcbị xóa trong khiRefCellbên trong đang ở trạng thái được mượn, nó sẽ panic.
Vì vậy, đối với các cấu trúc DOM, Rust thêm sự an toàn thông qua panic nhưng hy sinh sự kiên cường và rõ ràng.
Phát hiện lớn hơn:
- Hầu hết mọi ứng dụng hiện đại đều phụ thuộc vào các cấu trúc giống như DOM.
- Tuy nhiên, không có ngôn ngữ GC (JS) hay ngôn ngữ đếm tham chiếu (C++) thực sự được trang bị để xử lý chúng.
Các thực tiễn tốt nhất
- Luôn sử dụng smart pointers khi làm việc với cấu trúc dữ liệu phức tạp.
- Kiểm tra và xử lý các ngoại lệ có thể xảy ra trong quá trình thực thi để đảm bảo tính ổn định của ứng dụng.
Các cạm bẫy thường gặp
- Không kiểm tra các tình huống đa cha mẹ và vòng lặp có thể dẫn đến các lỗi khó phát hiện.
Mẹo hiệu suất
- Tối ưu hóa việc sử dụng
shared_ptrvàweak_ptrđể giảm thiểu chi phí bộ nhớ và thời gian xử lý.
Hướng dẫn khắc phục sự cố
- Sử dụng Valgrind để phát hiện rò rỉ bộ nhớ và các vấn đề về sử dụng sau khi giải phóng.
Câu hỏi thường gặp
1. Smart pointer là gì?
Smart pointer là các đối tượng trong C++ giúp quản lý bộ nhớ tự động, giúp tránh rò rỉ bộ nhớ và các lỗi liên quan đến con trỏ.
2. Tại sao nên sử dụng shared_ptr?
shared_ptr cho phép nhiều con trỏ cùng tham chiếu đến một đối tượng, giúp quản lý tài nguyên một cách linh hoạt.
3. Có nên sử dụng weak_ptr không?
Có, weak_ptr giúp ngăn chặn các tham chiếu không còn giá trị và tránh rò rỉ bộ nhớ khi sử dụng shared_ptr.