13. Chắc chắn rằng tài nguyên được sở hữu bởi các đối tượng (objects). Hãy sử dụng ngăn xếp RAII và con trỏ thông minh (smart pointers)

Estimated reading: 8 minutes 309 views

Tóm tắt

Đừng dùng cưa tay khi bạn có máy cưa điện: Quy tắc “resource acquisition is initialization” (RAII) của C++ là công cụ mạnh mẽ để xử lý tài nguyên đúng cách. RAII cho phép trình biên dịch cung cấp các đảm bảo mạnh mẽ và tự động mà ở một số ngôn ngữ khác phải dựa vào các quy tắc thủ công dễ sai sót. Khi cấp phát một tài nguyên thô, hãy ngay lập tức chuyển nó vào một đối tượng sở hữu. Không bao giờ cấp phát nhiều hơn một tài nguyên trong một câu lệnh.

Thảo luận

C++ đảm bảo tính đối xứng giữa constructor/destructor, phản ánh đúng bản chất đối xứng của các cặp hàm cấp phát/giải phóng tài nguyên như fopen/fclose, lock/unlock, và new/delete. Điều này giúp tạo ra các đối tượng có vòng đời trên stack (hoặc được quản lý bằng reference counting) với constructor thực hiện việc cấp phát tài nguyên và destructor thực hiện việc giải phóng tài nguyên. Cách tiếp cận này giúp quản lý tài nguyên một cách tự động, dễ dàng, hiệu quả và an toàn.

Nếu bạn không sử dụng RAII, bạn sẽ phải tự quản lý việc cấp phát và giải phóng tài nguyên một cách thủ công, bao gồm xử lý các luồng điều khiển phân nhánh và ngoại lệ. Việc dựa vào cách tiếp cận thủ công như C là không thể chấp nhận được khi C++ cung cấp cơ chế tự động hoá thông qua RAII.

Mỗi khi làm việc với một tài nguyên cần gọi các cặp hàm cấp phát/giải phóng, hãy đóng gói tài nguyên đó trong một đối tượng thực hiện việc quản lý vòng đời. Ví dụ, thay vì gọi trực tiếp cặp hàm OpenPort/ClosePort, hãy sử dụng một lớp như sau:

				
					class Port {
public:
    Port(const std::string& destination); // Gọi OpenPort
    ~Port(); // Gọi ClosePort
    
    // Vô hiệu hóa sao chép và gán
    Port(const Port&) = delete; // Ngăn chặn việc khởi tạo một Port mới từ một Port khác.
    Port& operator=(const Port&) = delete; // Ngăn chặn việc gán một Port này cho một Port khác.
    /* Nhờ vậy, code đảm bảo rằng mỗi đối tượng Port chỉ có thể được khởi tạo một lần và duy trì quyền sở hữu duy nhất đối với tài nguyên của nó. */
};

// Sử dụng đối tượng Port trong phạm vi khối lệnh:
void DoSomething() {
    Port port1("server1:80");
} // port1 sẽ tự động đóng khi ra khỏi phạm vi

// Ngoài ra, có thể sử dụng shared_ptr để quản lý vòng đời động:
std::shared_ptr<Port> port2 = /*...*/;
// port2 sẽ tự động đóng khi shared_ptr cuối cùng bị hủy				
			

Bạn cũng có thể sử dụng các thư viện hỗ trợ mẫu thiết kế RAII (xem [Alexandrescu00c]). Khi triển khai RAII, hãy lưu ý về việc sao chép và gán (Mục 49). Nếu sao chép không hợp lý, hãy vô hiệu hóa constructor sao chép và toán tử gán như trên ví dụ (Mục 53). Nếu cần sao chép, hãy đảm bảo quản lý tài nguyên đúng cách bằng cách nhân bản tài nguyên hoặc sử dụng bộ đếm tới số lần sử dụng (reference counting). Một lỗi phổ biến là giải phóng tài nguyên cũ trước khi sao chép tài nguyên mới thành công (Mục 71).

Hãy đảm bảo mọi tài nguyên đều được sở hữu bởi các đối tượng. Nên sử dụng smart pointer thay vì con trỏ thô. Luôn thực hiện mỗi lệnh cấp phát tài nguyên (ví dụ: new) trong một câu lệnh riêng biệt và ngay lập tức chuyển tài nguyên được cấp phát vào một đối tượng quản lý (ví dụ: shared_ptr). Nếu không, có thể xảy ra lỗi rò rỉ tài nguyên do thứ tự đánh giá tham số của một hàm không được xác định rõ ràng (Mục 31). Ví dụ:

				
					void Fun(std::shared_ptr<Widget> sp1, std::shared_ptr<Widget> sp2);
//...
Fun(std::shared_ptr<Widget>(new Widget), std::shared_ptr<Widget>(new Widget));				
			

Đoạn mã trên là không an toàn. Chuẩn C++ cung cấp cho trình biên dịch nhiều không gian để sắp xếp lại phương thức xây dựng hai đối số của hàm. Đặc biệt, trình biên dịch có thể xen kẽ việc thực thi hai công việc sau: Việc cấp phát bộ nhớ (bằng cách gọi toán tử new) có thể được thực hiện trước cho cả hai đối tượng, sau đó là các nỗ lực gọi 2 hàm tạo (constructor) Widget. Điều này có thể dẫn đến tình huống một hàm tạo ném ngoại lệ, thì bộ nhớ của đối tượng kia sẽ không bao giờ được giải phóng, gây rò rỉ bộ nhớ (memory leak). Cách giải quyết đơn giản là giống như phần khái quát đã nói, không bao giờ cấp phát nhiều hơn một tài nguyên trong một câu lệnh, hãy cấp phát tài nguyên trong các câu lệnh riêng biệt và ngay lập tức chuyển tài nguyên cho đối tượng sở hữu. Ví dụ:

				
					std::shared_ptr<Widget> sp1(new Widget), sp2(new Widget);
Fun(sp1, sp2);				
			

Xem thêm Mục 31 để biết thêm lợi ích của cách tiếp cận này.

→ Giải thích chi tiết kèm ví dụ

Ngoại lệ

Smart pointer có thể bị lạm dụng. Con trỏ thô vẫn phù hợp trong những trường hợp mà đối tượng được trỏ đến chỉ được sử dụng trong một phạm vi hẹp, chẳng hạn như con trỏ điều hướng trong các cấu trúc dữ liệu nội bộ của một lớp (Tree node pointers).

Tham khảo

[AlexandrescuOOc] • [Cline99] §31.03-05 • [DewhurstO3] §24, §67 • [Meyers96] §9-10 • [MilewskiOl] • [StroustrupOO] §14.3-4, §25.7, §E.3, §E.6 • [SutterOO] §16 • [SutterO2] §20-21 • [Vandevoorde03] §20.1.4

Link

Leave a Comment

Chia sẻ:

13. Chắc chắn rằng tài nguyên được sở hữu bởi các đối tượng (objects). Hãy sử dụng ngăn xếp RAII và con trỏ thông minh (smart pointers)

Or copy link

CONTENTS