1. Tại sao cần chỉ định cấp độ truy cập private cho các biến thành viên?

Trong bài học trước, chúng ta đã từng đề cập rằng các biến thành viên của class thường được đặt cho cấp độ truy cập là private. Các developers đang học về lập trình hướng đối tượng thường khó hiểu trong việc tại sao cần phải làm điều này. Để trả lời câu hỏi đó, hãy bắt đầu bằng một ví dụ tương tự với vấn đề chúng ta đang thắc mắc:

Trong cuộc sống hiện đại, chúng ta có quyền truy cập vào rất nhiều thiết bị điện tử. TV của bạn cómột chiếc điều khiển từ xa mà bạn có thể sử dụng để bật/tắt TV. Bạn lái một chiếc xe ôtô (hoặcxe máy) để đi làm. Bạn chụp ảnh trên smartphone của mình. Tất cả những điều này đều sử dụngmột mô hình chung: Chúng cung cấp một giao diện đơn giản để bạn sử dụng (một nút bấm, một chiếc vô lăng, v.v…) để thực hiện một hành động. Tuy nhiên, việc làm thế nào để các thiết bị này có thể thực sự hoạt động, lại được che giấu đối với bạn. Khi bạn nhấn vào một nút bấm trên điềukhiển từ xa, bạn không cần phải biết về việc nó đang làm những điều gì để có thể giao tiếp với TV.Khi bạn vít ga chiếc xe máy của mình, bạn không cần phải biết về việc động cơ đốt trong làm cho bánhxe quay như thế nào. Khi bạn chụp ảnh, bạn không cần phải biết về việc các cảm biến thu thập ánh sáng và phân chia các pixels để tạo nên một bức ảnh như thế nào. Sự tách biệt phần giao diện với phần cài đặt này cực kỳ hữu dụng vì nó cho phép chúng ta sử dụng các đối tượng mà không cần phải hiểu về cách chúng hoạt động. Điều này làm giảm đáng kể độ phức tạp của việc sử dụng các đối tượng và giúp gia tăng số lượng các đối tượng mà chúng ta có thể tương tác.

Vì những lý do tương tự như của ví dụ trên, việc tách biệt phần giao diện với phần cài đặt thực sự mang lại nhiều lợi ích trong việc lập trình.

2. Tính đóng gói(encapsulation)

Trong lập trình hướng đối tượng, tính đóng gói (còn được gọi là che giấu thông tin) là quá trình giữ cho các thông tin chi tiết về cách cài đặt một đối tượng, được ẩn đi khỏi người dùng của đối tượng. Thay vào đó, người dùng của đối tượng sẽ truy cập vào đối tượng thông qua một giao diện công khai (public interface). Bằng cách này, người dùng có thể sử dụng đối tượng mà không cần phải hiểu về cách mà nó được cài đặt.

Trong C++, chúng ta thực hiện tính đóng gói (encapsulation) thông qua các chỉ định phạm vi truy cập (access specifiers). Thường thì, tất cả các biến thành viên của một class đều được đặt ở cấp độ truy cập là private (bí mật) (nhằm ẩn đi các thông tin chi tiết về việc cài đặt), và hầu hết các hàm thành viên đều được đặt ở cấp độ truy cập là public (công khai) (nhằm hiển thị một giao diện công khai mà người dùng có thể thấy được). Mặc dù việc yêu cầu người dùng của một class sử dụng giao diện công khai có vẻ phiền toái hơn so với việc cung cấp trực tiếp quyền truy cập public (công khai) vào các biến thành viên, nhưng thực tế, điều này mang lại một số lượng lớn các lợi ích hữu dụng, giúp tăng cường khả năng tái sử dụng của các class nói riêng và code nói chung, đồng thời làm cho code dễ bảo trì hơn.

Lưu ý rằng: Từ khóa encapsulation (sự đóng gói) đôi khi cũng được sử dụng để chỉ việc đóng gói cùng với nhau các dữ liệu và hàm mà đang làm việc trên dữ liệu đó.

2.0 Lợi ích: Các class được đóng gói sẽ dễ sử dụng hơn và giảm thiểu độ phức tạp của chương trình của bạn.

Với một class được đóng gói hoàn toàn (fully encapsulated), bạn chỉ cần biết về việc có bao nhiêu hàm thành viên đang khả dụng công khai để sử dụng class này, những đối số nào sẽ được các hàm này nhận vào, và các hàm này sẽ trả về giá trị gì.  Chúng ta sẽ không quan tâm đến việc class này được cài đặt cụ thể như thế nào ở bên trong. Ví dụ, một class chứa một danh sách các tên có thể đã được cài đặt bằng cách sử dụng một mảng động kiểu C chứa các chuỗi, hoặc cũng có thể bằng std::array, std::vector, std::map, std::list hoặc một trong những cấu trúc dữ liệu khác. Để sử dụng một class, bạn không cần phải biết (hoặc quan tâm) về chi tiết cài đặt bên trong của nó. Điều này giúp giảm đáng kể độ phức tạp của các chương trình của bạn, và cũng giảm đi các lỗi sai do nhầm lẫn. Hơn bất kỳ lý do nào khác, đây chính là lợi thế chủ đạo của tính đóng gói (encapsulation) trong lập trình hướng đối tượng.

Tất cả các class trong thư viện standard của C++ đều được đóng gói. Bạn hãy thử tưởng tượng rằng C++ sẽ phức tạp nhiều hơn thế nào nếu bạn phải hiểu cách cài đặt của std::string, std::vector hoặc std::cout, để sử dụng chúng!

2.1 Lợi ích: Các class được đóng gói giúp bảo vệ dữ liệu của bạn và ngăn chặn việc sử dụng sai

Các biến toàn cục (global variables) rất nguy hiểm bởi vì bạn không thể kiểm soát chặt chẽ được về việc ai sẽ có quyền truy cập vào biến toàn cục, hoặc họ sẽ sử dụng nó như thế nào. Các class sở hữu những thành viên công khai (public) cũng tiềm ẩn vấn đề tương tự, nhưng ở một quy mô nhỏ hơn.

Ví dụ, giả sử chúng ta đang viết một class string. Chúng ta có thể sẽ bắt đầu như thế này:

class MyString
{
    char *m_string; // we'll dynamically allocate our string here
    int m_length; // we need to keep track of the string length
};

Giữa hai biến này có tồn tại một liên kết: biến m_length phải luôn có giá trị bằng với độ dài của chuỗi được giữ bởi biến m_string (đây được gọi là một liên kết bất biến). Nếu m_length là public, ai cũng sẽ có thể thay đổi độ dài của chuỗi mà không cần phải thay đổi biến m_string (hoặc ngược lại). Điều này sẽ đưa class vào một trạng thái không nhất quán, có thể gây ra đủ loại vấn đề kỳ quái. Bằng việc thiết lập cấp độ kiểm soát truy cập cho cả m_length và m_string là private (bí mật), người dùng sẽ buộc phải sử dụng bất kỳ hàm thành viên public khả dụng nào để làm việc với class (và các hàm thành viên đó có thể đảm bảo rằng m_length và m_string sẽ luôn được gán cho các giá trị phù hợp).

Chúng ta cũng có thể giúp người dùng tránh được những nhầm lẫn trong khi sử dụng class. Cùng xét ví dụ dưới đây về một class sở hữu một biến thành viên kiểu mảng đang ở trạng thái public:

class IntArray
{
public:
    int m_array[10];
};

Nếu có quyền truy cập trực tiếp vào biến array, người dùng sẽ có thể truy cập phần tử mảng bằng một chỉ số (index) không hợp lệ, dẫn đến các kết quả không mong muốn:

int main()
{
    IntArray array;
    array.m_array[16] = 2; // invalid array index, now we overwrote memory that we don't own
}

Tuy nhiên, nếu đặt biến array ở trạng thái private (bí mật), chúng ta có thể buộc người dùng phải sử dụng một hàm mà trong đó, đoạn code kiểm tra tính hợp lệ của chỉ số phần tử mảng (index) người dùng đã nhập vào, sẽ được thực thi trước nhất:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

class IntArray
{
private:
    int m_array[10]; // user can not access this directly any more
 
public:
    void setValue(int index, int value)
    {
        // If the index is invalid, do nothing
        if (index < 0 || index >= 10)
            return;
 
        m_array[index] = value;
    }
};

Bằng cách này, chúng ta đã bảo vệ được tính toàn vẹn cho chương trình của mình. Thêm một lưu ý nhỏ, hàm at() của kiểu std::array và kiểu std::vector có chức năng rất giống nhau!

2.2 Lợi ích: Các class được đóng gói sẽ dễ sửa đổi hơn

Xét ví dụ đơn giản sau:

#include <iostream>
 
class Something
{
public:
    int m_value1;
    int m_value2;
    int m_value3;
};
 
int main()
{
    Something something;
    something.m_value1 = 5;
    std::cout << something.m_value1 << '\n';
}

Mặc dù đoạn chương trình này vẫn chạy được, nhưng điều gì sẽ xảy ra nếu chúng ta quyết định đổi tên biến m_value1, hoặc thay đổi kiểu dữ liệu của nó? Chúng ta sẽ không chỉ phá vỡ chương trình này, mà còn có khả năng là hầu hết các chương trình sử dụng class Something cũng sẽ chịu tình trạng tương tự!

Tính đóng gói cho chúng ta khả năng thay đổi cách các class được cài đặt mà không phá vỡ tất cả các chương trình sử dụng chúng. 

Dưới đây là phiên bản đã được đóng gói của class trong ví dụ bên trên, class này sử dụng các hàm để truy cập vào biến m_value!

#include <iostream>
 
class Something
{
private:
    int m_value1;
    int m_value2;
    int m_value3;
 
public:
    void setValue1(int value) { m_value1 = value; }
    int getValue1() { return m_value1; }
};
 
int main()
{
    Something something;
    something.setValue1(5);
    std::cout << something.getValue1() << '\n';
}

Bây giờ, hãy thử thay đổi cách cài đặt class này:

#include <iostream>
 
class Something
{
private:
    int m_value[3]; // note: we changed the implementation of this class!
 
public:
    // We have to update any member functions to reflect the new implementation
    void setValue1(int value) { m_value[0] = value; }
    int getValue1() { return m_value[0]; }
};
 
int main()
{
    // But our program still works just fine!
    Something something;
    something.setValue1(5);
    std::cout << something.getValue1() << '\n';
}

Lưu ý rằng, vì chúng ta đã không thay đổi các nguyên mẫu (prototypes) của bất kỳ hàm nào trong phần code có chỉ định phạm vi truy cập là public của class Something, đoạn chương trình đã được thay đổi về cách cài đặt class Something trong ví dụ này vẫn sẽ hoạt động bình thường.

Tương tự như vậy, nếu lũ quỷ lùn (gnomes) lẻn vào nhà của bạn vào ban đêm, và thay thế phần bên trong của điều khiển tivi bằng một công nghệ khác (vẫn tương thích với tivi của bạn), bạn có thể sẽ không bao giờ nhận ra được điều này.

2.3 Lợi ích: Các class được đóng gói giúp việc debug dễ dàng hơn

Và cuối cùng, tính đóng gói có thể giúp bạn debug (gỡ lỗi) chương trình khi có sự cố. Thông thường, khi một chương trình không hoạt động chính xác, đó là do một trong các biến thành viên của chúng ta có giá trị không chính xác. Nếu mọi người đều có thể truy cập trực tiếp vào biến này, thì việc truy dấu đoạn code nào đã sửa đổi biến này có thể trở nên rất khó khăn (bất kỳ đoạn code nào sửa đổi biến này, đều có khả năng là nguyên nhân gây sai giá trị của biến, và bạn sẽ phải đặt breakpoint ở tất cả các đoạn code đang nghi ngờ này, để tìm thấy nơi gây ra lỗi). Do đó, khi áp dụng tính đóng gói của lập trình hướng đối tượng vào việc viết code, mọi người tương tác với code đều phải gọi cùng một hàm public (công khai) để sửa đổi một giá trị, lúc này bạn sẽ chỉ cần đặt breakpoint tại hàm public này và theo dõi quá trình thay đổi giá trị của từng đối tượng gọi hàm (caller), cho đến khi tìm thấy được nơi gây ra lỗi.

3. Truy cập Hàm – Access functions

Tùy thuộc vào chức năng của class, ta có thể cân nhắc để thiết lập các hàm public có khả năng truy cập trực tiếp (get) hoặc thay đổi trực tiếp (set) giá trị của các biến thành viên private.

Một “Hàm truy cập” (acess function) là một hàm public, ngắn, có nhiệm vụ lấy về hoặc thay đổi giá trị của biến thành viên private. Trong một class về String, bạn có thể từng thấy một số đoạn code tương tự với ví dụ dưới đây:

class MyString
{
private:
    char *m_string; // we'll dynamically allocate our string here
    int m_length; // we need to keep track of the string length
 
public:
    int getLength() { return m_length; } // access function to get value of m_length
};

getLength() là một hàm truy cập, có chức năng trả về giá trị của biến private m_length.

Các hàm truy cập thường có hai loại: getters (hàm giúp lấy về giá trị) và setters (hàm giúp thay đổi giá trị). Các hàm getters (đôi khi còn được gọi là accessors) là những hàm trả về giá trị của một biến thành viên private. Các hàm setters (đôi khi còn được gọi là mutators) là những hàm gán/thay đổi giá trị của một biến thành viên private.

Sau đây là ví dụ về một class có cả getters và setters cho tất cả các thành viên của nó:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

class Date
{
private:
    int m_month;
    int m_day;
    int m_year;
 
public:
    int getMonth() { return m_month; } // getter for month
    void setMonth(int month) { m_month = month; } // setter for month
 
    int getDay() { return m_day; } // getter for day
    void setDay(int day) { m_day = day; } // setter for day
 
    int getYear() { return m_year; } // getter for year
    void setYear(int year) { m_year = year; } // setter for year
};

Class Date ở trên về cơ bản là một cấu trúc dữ liệu đã được đóng gói, được cài đặt theo cách thông thường, và những người sử dụng class này có thể truy cập hoặc gán/thay đổi giá trị của các biến day, month, hoặc year.

Class MyString ở ví dụ trước đó không chỉ được sử dụng để vận chuyển dữ liệu – nó có chức năng phức tạp hơn và có một biến cần được duy trì giá trị (tức là giá trị của biến này không đổi). Không có hàm setter nào được cung cấp cho biến m_length, bởi vì chúng ta không muốn người dùng có thể gán lại trực tiếp giá trị độ dài của chuỗi (độ dài chuỗi chỉ nên được gán lại mỗi khi chuỗi thay đổi). Trong class này, việc cho phép người dùng lấy trực tiếp được độ dài chuỗi là hoàn toàn hợp lý, vì vậy, một hàm getter dành cho độ dài chuỗi đã được cung cấp.

Các hàm getters sẽ cung cấp quyền truy cập “read-only” (chỉ đọc) cho dữ liệu. Do đó, thực hành tốt nhất ở đây là chúng nên trả về giá trị thuần túy hoặc tham chiếu hằng (const reference) (chứ không phải tham chiếu không hằng: non-cont reference). Một hàm getter mà trả về tham chiếu không hằng (non-const reference) sẽ cho phép đối tượng gọi hàm sửa đổi được đối tượng thực sự đang được tham chiếu tới, điều này vi phạm bản chất chỉ đọc (read-only) của hàm getter (và vi phạm cả tính đóng gói).

Thực hành tốt: Các hàm getters nên trả về giá trị hoặc tham chiếu hằng.

4. Những lo ngại về truy cập vào hàm – access functions

Có một số cuộc tranh luận xung quanh việc nên sử dụng hay tránh sử dụng các hàm truy cập trong những trường hợp nào. Mặc dù chúng không vi phạm tính đóng gói, nhưng một số developers đã lập luận rằng việc sử dụng các hàm truy cập sẽ vi phạm khái niệm về thiết kế một class tốt theo lập trình hướng đối tượng (một chủ đề mà có thể dễ dàng lấp đầy toàn bộ một cuốn sách).

Hiện tại, chúng tôi sẽ đề xuất một cách tiếp cận thực dụng. Khi bạn tạo ra các class, hãy xem xét những điều sau:

  • Nếu không có ai nằm ngoài class của bạn cần truy cập vào một biến thành viên, thì hãy đừng cung cấp các hàm truy cập cho thành viên đó.
  • Nếu ai đó nằm bên ngoài class của bạn cần quyền truy cập tới một biến thành viên, hãy suy nghĩ xem liệu rằng bạn có thể đưa ra một hành vi hoặc hành động khác (ví dụ: Thay vì đưa ra một hàm setter setAlive(bool), hãy cài đặt một hàm kill()), thay cho việc cho phép truy cập trực tiếp vào biến thành viên hoặc cung cấp hàm setter hay không.
  • Nếu không thể, hãy xem xét xem liệu rằng bạn có thể chỉ cung cấp một hàm getter hay không.

5. Tổng kết

Như bạn có thể thấy, tính đóng gói cung cấp rất nhiều lợi ích, trong khi việc cài đặt nó không hề tốn nhiều công sức hay khó khăn. Lợi ích chính mà tính đóng gói mang lại đó là cho phép chúng ta sử dụng một class mà không cần phải biết chi tiết về việc nó đã được cài đặt như thế nào. Điều này làm cho việc sử dụng các class mà chúng ta không quen thuộc trở nên dễ dàng hơn nhiều.

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!