42. Không làm lộ thông tin nội bộ

Estimated reading: 9 minutes 201 views

Tóm tắt

Đừng tự nguyện cung cấp quá nhiều: Tránh việc trả về các thẻ điều khiển (handles) tới dữ liệu nội bộ được quản lý bởi lớp của bạn, để các mã khách (clients) không thể sửa đổi một cách không kiểm soát trạng thái mà đối tượng của bạn nghĩ rằng nó đang sở hữu.

Thảo luận

Hãy xem xét:

				
					class Socket {
public:
    //... constructor mở handle_, destructor đóng handle_, v.v. ...
    int GetHandle() const { return handle_; } // tránh làm điều này
private:
    int handle_; // có thể là một handle tài nguyên hệ điều hành
};				
			

Che giấu dữ liệu (data hiding) là một thiết bị mô-đun hóa và trừu tượng hóa (abstraction) mạnh mẽ (Mục 11 và 41). Nhưng việc che giấu dữ liệu rồi lại trao đi các handle truy cập nó là một việc làm tự đánh bại chính mình (self-defeating), giống như việc khóa cửa nhà nhưng lại để chìa khóa ngay trong ổ khóa vậy. Điều này là bởi vì:

  • Các mã khách (clients) bây giờ có hai cách để thực hiện chức năng: Họ có thể sử dụng sự trừu tượng hóa của lớp bạn (Socket) hoặc thao tác trực tiếp lên phần cài đặt mà lớp bạn dựa vào (handle kiểu C của socket). Trong trường hợp thứ hai, đối tượng không hề hay biết về những thay đổi quan trọng đối với tài nguyên mà nó nghĩ rằng nó đang sở hữu. Lúc này, lớp không thể làm giàu hoặc tô điểm chức năng một cách đáng tin cậy (ví dụ: ủy quyền – proxying, ghi nhật ký – logging, thu thập thống kê) bởi vì các client có thể đi đường vòng qua phần cài đặt đã được kiểm soát và tô điểm đó—cũng như bỏ qua bất kỳ bất biến (invariants) nào mà lớp nghĩ rằng nó đang thêm vào, điều này khiến việc xử lý lỗi chính xác gần như là không thể (Mục 70).

  • Lớp không thể thay đổi phần cài đặt cơ sở (underlying implementation) của sự trừu tượng hóa đó vì các client đã phụ thuộc vào nó: Nếu Socket sau này được nâng cấp để hỗ trợ một giao thức khác với một tập hợp các nguyên thủy cấp thấp khác, mã gọi (calling code) nào đã lấy handle_ cơ sở và thao tác sai trên nó sẽ bị hỏng một cách âm thầm (silently broken).

  • Lớp không thể thực thi các bất biến (invariants) của nó vì mã gọi có thể thay đổi trạng thái mà lớp không hề hay biết: Ví dụ, ai đó có thể đóng cái handle đang được sử dụng bởi một đối tượng Socket mà không đi qua một hàm thành viên của Socket, do đó làm cho đối tượng trở nên không hợp lệ.

  • Mã khách có thể lưu trữ các handle mà lớp bạn trả về, và cố gắng sử dụng chúng sau khi mã của lớp bạn đã vô hiệu hóa chúng.

Một sai lầm phổ biến là quên rằng const là nông (shallow) và không lan truyền qua các con trỏ (Mục 15). Ví dụ, Socket::GetHandle là một thành viên const; theo quan điểm của trình biên dịch, việc trả về handle_ bảo toàn tính hằng (constness) hoàn toàn ổn. Tuy nhiên, các lời gọi thô (raw calls) tới các hàm hệ thống sử dụng giá trị của handle_ chắc chắn có thể sửa đổi dữ liệu mà handle_ tham chiếu gián tiếp tới.

Ví dụ về con trỏ sau đây cũng tương tự, mặc dù chúng ta sẽ thấy tình huống tốt hơn một chút vì ít nhất một kiểu trả về là const có thể giảm thiểu các việc sử dụng sai vô tình:

				
					class String {
    char* buffer_;
public:
    char* GetBuffer() const { return buffer_; } // tệ: nên trả về const char*
    //...
};				
			

Mặc dù GetBuffer là const, đoạn mã này về mặt kỹ thuật là hợp lệ và đúng luật. Rõ ràng, một client có thể sử dụng GetBuffer này để thay đổi một đối tượng String theo những cách khá lớn mà không cần ép kiểu tường minh (explicit casting) và do đó có thể là vô tình; ví dụ, strcpy( s.GetBuffer(), "Very Long String..." ) là mã hợp lệ; trong thực tế, mọi trình biên dịch chúng tôi thử đều biên dịch nó mà không có cảnh báo. Việc thay vào đó trả về const char* từ hàm thành viên này ít nhất sẽ gây ra lỗi tại thời điểm biên dịch (compile-time error) cho những việc sử dụng sai như vậy để chúng không thể xảy ra vô tình; mã gọi như vậy sẽ phải viết một lệnh ép kiểu tường minh (Mục 92 đến 95).

Thậm chí việc trả về các con trỏ tới hằng (pointers to const) cũng không loại bỏ được tất cả các việc sử dụng sai vô tình, bởi vì một vấn đề khác của việc cho đi các nội bộ đối tượng có liên quan đến tính hợp lệ (validity) của nội bộ đó. Trong ví dụ String ở trên, mã gọi có thể lưu trữ con trỏ được trả về bởi GetBuffer, sau đó thực hiện một thao tác khiến String tăng kích thước (và di chuyển) bộ đệm của nó, và cuối cùng (và thảm khốc hơn) cố gắng sử dụng con trỏ treo (dangling pointer) đã lưu-và-giờ-đã-bị-vô-hiệu tới một bộ đệm không còn tồn tại nữa. Do đó, nếu bạn thực sự nghĩ rằng bạn có lý do chính đáng để cung cấp trạng thái nội bộ như vậy, bạn vẫn phải tài liệu hóa chi tiết về việc giá trị trả về duy trì hợp lệ trong bao lâu và những thao tác nào sẽ vô hiệu hóa nó (hãy so sánh điều này với các đảm bảo về tính hợp lệ của iterator (iterator validity guarantees) của thư viện chuẩn; xem [C++03]).

Ngoại lệ

Đôi khi các lớp bắt buộc phải cung cấp quyền truy cập tới các handle nội bộ vì lý do tương thích (compatibility), chẳng hạn như giao tiếp với mã cũ (legacy code) hoặc các hệ thống khác. Ví dụ, std::basic_string cung cấp quyền truy cập tới handle nội bộ của nó thông qua các hàm thành viên data và c_str để tương thích với các hàm mong đợi con trỏ kiểu C—nhưng người ta giả định rằng các hàm đó không lưu trữ các con trỏ đó hoặc cố gắng ghi dữ liệu thông qua chúng! Các hàm truy cập “cửa sau” (backdoor access) như vậy là một cái ác cần thiết (necessary evil) nên được sử dụng hiếm hoi và thận trọng, và các điều kiện mà theo đó handle vẫn còn hợp lệ phải được tài liệu hóa cẩn thận.

Leave a Comment

Chia sẻ:

42. Không làm lộ thông tin nội bộ

Or copy link

CONTENTS