43. Sử dụng pimpl cho khôn ngoan

Estimated reading: 9 minutes 219 views

Tóm tắt

Vượt qua sự lo lắng về việc tách biệt (separation anxiety) của ngôn ngữ: C++ làm cho các thành viên private không thể truy cập (inaccessible), nhưng không phải vô hình (invisible). Ở những nơi mà lợi ích mang lại là xứng đáng, hãy cân nhắc làm cho các thành viên private thực sự vô hình bằng cách sử dụng thành ngữ Pimpl (Pimpl idiom) để triển khai các bức tường lửa biên dịch (compiler firewalls) và tăng cường việc che giấu thông tin. (Mục 11 và 41.)

Thảo luận

Khi việc tạo ra một “bức tường lửa biên dịch” để cách ly hoàn toàn mã gọi (calling code) khỏi các thành phần private của một lớp là hợp lý, hãy sử dụng thành ngữ Pimpl: Che giấu chúng đằng sau một con trỏ mờ (opaque pointer) (một con trỏ, tốt nhất là một con trỏ thông minh thích hợp, trỏ tới một lớp được khai báo nhưng chưa được định nghĩa). Ví dụ:

				
					class Map {
private:
    struct Impl;
    shared_ptr<Impl> pimpl_;
};				
			

Pimpl cùng tên nên được sử dụng để lưu trữ tất cả các thành viên private, cả dữ liệu thành viên lẫn hàm thành viên private. Điều này cho phép bạn thực hiện các thay đổi tùy ý đối với chi tiết cài đặt private của lớp mà không cần biên dịch lại bất kỳ mã gọi nào—một sự độc lập và tự do vốn là dấu ấn của thành ngữ này. (Mục 41)

Lưu ý: Hãy khai báo Pimpl bằng cách sử dụng hai khai báo như đã trình bày. Việc kết hợp hai dòng và khai báo trước (forward-declare) kiểu và con trỏ tới nó trong một câu lệnh như struct Impl* pimpl; là hợp lệ, nhưng mang một ý nghĩa khác—khi đó Impl nằm trong không gian tên bao quanh (enclosing namespace) chứ không phải là một kiểu lồng nhau (nested type) bên trong lớp của bạn.

Có ít nhất ba lý do bạn có thể dùng Pimpl, và tất cả đều bắt nguồn từ sự phân biệt của C++ giữa tính truy cập được (accessibility – liệu bạn có thể gọi hoặc sử dụng thứ gì đó không) và tính nhìn thấy được (visibility – liệu bạn có thể nhìn thấy nó và do đó phụ thuộc vào định nghĩa của nó không). Cụ thể, tất cả các thành viên private của một lớp là không thể truy cập bên ngoài các hàm thành viên và bạn bè (friends), nhưng lại nhìn thấy được đối với toàn thế giới—đối với tất cả mã nhìn thấy định nghĩa của lớp đó.

Hệ quả đầu tiên của điều này là thời gian xây dựng (build times) có thể lâu hơn do phải xử lý các định nghĩa kiểu không cần thiết. Đối với các thành viên dữ liệu private được giữ theo giá trị (held by value), và đối với các tham số của hàm thành viên private được lấy theo giá trị hoặc được sử dụng trong các cài đặt hàm nhìn thấy được, các kiểu dữ liệu đó phải được định nghĩa ngay cả khi chúng có thể không bao giờ cần thiết trong đơn vị biên dịch này. Điều này có thể dẫn đến thời gian xây dựng lâu hơn. Ví dụ:

				
					class C {
private:
    AComplicatedType act_;
};				
			

Tệp tiêu đề chứa định nghĩa của lớp C cũng phải #include tiêu đề chứa định nghĩa cho AComplicatedType, tiêu đề này lại bao gồm bắc cầu (transitively includes) mọi tiêu đề mà AComplicatedType có thể cần, v.v. Nếu các tiêu đề này bao quát rộng, thời gian biên dịch có thể bị ảnh hưởng đáng kể.

Hệ quả thứ hai là tạo ra sự mơ hồ (ambiguity) và che khuất tên (name hiding) đối với mã đang cố gắng gọi một hàm. Mặc dù các hàm thành viên private không bao giờ có thể được gọi từ bên ngoài lớp và bạn bè của nó, chúng vẫn tham gia vào việc tìm kiếm tên (name lookup) và phân giải nạp chồng (overload resolution) và do đó có thể khiến các lời gọi trở nên không hợp lệ hoặc mơ hồ. C++ thực hiện tìm kiếm tên và sau đó là phân giải nạp chồng trước khi kiểm tra quyền truy cập. Đó là lý do tại sao tính nhìn thấy được (visibility) lại được ưu tiên:

 
				
					int Twice( int ); // 1

class Calc {
public:
    string Twice( string ); // 2
private:
    char* Twice( char* );   // 3

    int Test() {
        return Twice( 21 ); // A: lỗi, 2 và 3 đều không khả thi (1 sẽ khả thi,
                            // nhưng nó không được xem xét vì nó bị che khuất)
    }
};

Calc c;
c.Twice( "Hello" ); // B: lỗi, 3 là không thể truy cập (2 sẽ ổn, nhưng nó
                    // không được xem xét vì 3 khớp tốt hơn)				
			

Ở dòng A, giải pháp thay thế là định danh tường minh lời gọi là ::Twice( 21 ) để buộc việc tìm kiếm chọn hàm toàn cục. Ở dòng B, giải pháp thay thế là thêm một ép kiểu tường minh như c.Twice( string("Hello") ) để buộc phân giải nạp chồng chọn hàm mong muốn. Một số vấn đề gọi hàm này có thể được giải quyết bằng các cách khác ngoài thành ngữ Pimpl, ví dụ như không bao giờ viết các hàm nạp chồng private cho các hàm thành viên, nhưng không phải tất cả các vấn đề được Pimpl giải quyết đều có các giải pháp thay thế như vậy.

Hệ quả thứ ba là tác động của nó đối với xử lý lỗi và an toàn. Hãy xem xét ví dụ Widget của Tom Cargill:

				
					class Widget { //...
public:
    Widget& operator=( const Widget& );
private:
    T1 t1_;
    T2 t2_;
};				
			

Nói ngắn gọn, chúng ta không thể viết operator= để đưa ra đảm bảo mạnh (strong guarantee) hoặc thậm chí đảm bảo tối thiểu bắt buộc (cơ bản – basic guarantee) nếu các thao tác của T1 hoặc T2 có thể thất bại theo cách không thể đảo ngược (Mục 71). Tin tốt là sự chuyển đổi đơn giản sau đây luôn hoạt động để kích hoạt ít nhất là đảm bảo cơ bản cho phép gán an toàn ngoại lệ (error-safe assignment), và thường là đảm bảo mạnh miễn là các thao tác cần thiết của T1 và T2 (đáng chú ý là khởi tạo và hủy) không có tác dụng phụ: Giữ các đối tượng thành viên bằng con trỏ thay vì bằng giá trị, tốt nhất là tất cả đằng sau một con trỏ Pimpl duy nhất.

 
				
					class Widget { //...
public:
    Widget& operator=( const Widget& );
private:
    struct Impl;
    shared_ptr<Impl> pimpl_;
};

Widget& Widget::operator=( const Widget& ) {
    shared_ptr<Impl> temp( new Impl(/*...*/) );
    // thay đổi temp->t1_ và temp->t2_; nếu thất bại thì ném ngoại lệ, ngược lại cam kết (commit) bằng cách:
    pimpl_ = temp;
    return *this;
}				
			

Ngoại lệ

Chỉ thêm sự phức tạp, bao gồm cả các Pimpl, khi bạn biết mình được hưởng lợi từ mức độ gián tiếp (level of indirection) tăng thêm đó. (Mục 6 và 8.)

Leave a Comment

Chia sẻ:

43. Sử dụng pimpl cho khôn ngoan

Or copy link

CONTENTS