Bài 35.4: Hàm bạn và lớp bạn trong C++

Chào mừng các bạn đến với bài viết tiếp theo trong chuỗi blog về C++ của FullhouseDev! Hôm nay, chúng ta sẽ cùng khám phá một cặp khái niệm khá đặc biệt trong C++: Hàm bạn (Friend function) và Lớp bạn (Friend class). Đây là những "người bạn" đặc quyền, được một lớp trao quyền truy cập vào cả những thành viên riêng tư (private) và được bảo vệ (protected) của nó – điều mà thông thường chỉ có các hàm thành viên của lớp đó mới làm được.

Tính đóng gói và "người bạn" đặc biệt

Một trong những nguyên tắc cốt lõi của Lập trình hướng đối tượng (OOP) là tính đóng gói (encapsulation). Tính đóng gói giúp bảo vệ dữ liệu bên trong lớp bằng cách giới hạn quyền truy cập từ bên ngoài. Các thành viên dữ liệu thường được khai báo là private hoặc protected, và chỉ có thể được truy cập hoặc sửa đổi thông qua các hàm thành viên công khai (public) của lớp (như getter/setter).

Tuy nhiên, trong một số trường hợp đặc biệt, bạn có thể cần một hàm không phải là thành viên của lớp, hoặc một lớp khác, có thể "nhìn thấy" và tương tác trực tiếp với dữ liệu riêng tư của lớp. Đây chính là lúc Hàm bạnLớp bạn phát huy tác dụng. Chúng giống như việc bạn cho phép một người bạn cực kỳ thân thiết biết mật khẩu vào ngôi nhà của mình vậy – quyền truy cập được cấp có chủ đích, nhưng nó cũng tiềm ẩn rủi ro nếu không được sử dụng cẩn thận.

Hãy cùng đi sâu vào từng loại "người bạn" này nhé.

Hàm bạn (Friend Function)

Hàm bạn là một hàm không phải là hàm thành viên của một lớp, nhưng được lớp đó cấp quyền truy cập vào các thành viên privateprotected của nó.

Tại sao lại cần Hàm bạn?

Có một vài lý do bạn có thể muốn sử dụng hàm bạn:

  1. Thao tác với hai đối tượng thuộc các lớp khác nhau: Khi một hàm cần truy cập vào dữ liệu riêng tư của hai hoặc nhiều lớp khác nhau (ví dụ: một hàm hoán đổi giá trị giữa hai đối tượng).
  2. Quá tải toán tử (Operator Overloading): Đây là một trường hợp sử dụng rất phổ biến. Khi bạn muốn quá tải một toán tử nhị phân (binary operator) như +, -, <<, >> mà toán hạng bên trái không phải là một đối tượng của lớp đó (ví dụ: int + MyClass hoặc cout << MyClass). Trong trường hợp này, hàm quá tải không thể là hàm thành viên, và cần quyền truy cập vào dữ liệu riêng tư.
  3. Hàm tiện ích (Utility function): Đôi khi có một hàm tiện ích gắn bó chặt chẽ với hoạt động bên trong của lớp nhưng không nhất thiết phải là một phương thức của lớp đó.

Làm thế nào để khai báo Hàm bạn?

Bạn khai báo một hàm là "bạn" của một lớp bằng cách đặt từ khóa friend trước khai báo hàm đó bên trong định nghĩa lớp.

Ví dụ minh họa:

Giả sử chúng ta có một lớp đơn giản MyClass với một thành viên dữ liệu riêng tư. Chúng ta muốn viết một hàm bên ngoài lớp này để hiển thị giá trị của thành viên riêng tư đó.

#include <iostream>

// Khai báo lớp MyClass
class MyClass {
private:
    int privateData;

public:
    // Constructor để khởi tạo privateData
    MyClass(int data) : privateData(data) {}

    // Khai báo hàm accessPrivateData là hàm bạn của MyClass
    // Chú ý: friend được đặt trước khai báo hàm
    friend void accessPrivateData(const MyClass& obj);

    // Các hàm thành viên công khai khác nếu cần
};

// Định nghĩa hàm bạn accessPrivateData bên ngoài lớp
// Chú ý: Không có từ khóa friend ở đây khi định nghĩa
void accessPrivateData(const MyClass& obj) {
    // Hàm này có thể truy cập trực tiếp privateData của obj
    cout << "Du lieu private: " << obj.privateData << endl;
}

int main() {
    MyClass myObj(123);

    // Gọi hàm bạn. Hàm này có thể truy cập privateData của myObj
    accessPrivateData(myObj);

    return 0;
}

Giải thích code:

  • Trong lớp MyClass, chúng ta khai báo friend void accessPrivateData(const MyClass& obj);. Dòng này nói với trình biên dịch rằng accessPrivateData là một hàm bạn và có quyền truy cập vào các thành viên riêng tư của MyClass.
  • Hàm accessPrivateData được định nghĩa bên ngoài lớp, giống như bất kỳ hàm thông thường nào. Tuy nhiên, nhờ được khai báo là friend bên trong MyClass, nó có thể truy cập obj.privateData một cách hợp lệ, mặc dù privateData được khai báo là private.
  • Trong main, chúng ta tạo một đối tượng myObj và gọi accessPrivateData, truyền myObj vào. Hàm này thực hiện đúng như mong đợi, in ra giá trị của privateData.

Một ví dụ phổ biến hơn: Quá tải toán tử <<

Việc quá tải toán tử << (toán tử chèn luồng) để in đối tượng của lớp bạn ra cout là một trường hợp kinh điển cần đến hàm bạn. Toán tử << nhận ostream& làm toán hạng bên trái và đối tượng của bạn làm toán hạng bên phải (cout << myObj). Vì toán hạng bên trái là ostream, hàm quá tải không thể là hàm thành viên của lớp MyClass.

#include <iostream>

class Coordinate {
private:
    double x;
    double y;

public:
    Coordinate(double valX, double valY) : x(valX), y(valY) {}

    // Khai báo hàm quá tải toán tử << là hàm bạn
    // Nó cần truy cập x và y để in ra
    friend ostream& operator<<(ostream& os, const Coordinate& coord);
};

// Định nghĩa hàm quá tải toán tử <<
ostream& operator<<(ostream& os, const Coordinate& coord) {
    // Nhờ là friend, hàm này truy cập được coord.x và coord.y
    os << "(" << coord.x << ", " << coord.y << ")";
    return os; // Trả về luồng để có thể nối tiếp các thao tác << khác
}

int main() {
    Coordinate p1(10.5, 20.1);

    // Sử dụng toán tử << đã quá tải để in đối tượng Coordinate
    cout << "Toa do cua p1: " << p1 << endl;

    return 0;
}

Giải thích code:

  • Lớp Coordinate có hai thành viên riêng tư xy.
  • Chúng ta muốn dùng cú pháp cout << p1; để in tọa độ.
  • Hàm operator<< được khai báo là bạn của lớp Coordinate. Nó nhận ostream& (tham chiếu đến luồng xuất, ví dụ cout) và const Coordinate& (tham chiếu đến đối tượng cần in) làm đối số.
  • Trong định nghĩa hàm operator<<, nó truy cập trực tiếp coord.xcoord.y và chèn chúng vào luồng os.

Lưu ý quan trọng về Hàm bạn:

  • Hàm bạn không được gọi bằng đối tượng của lớp (ví dụ: myObj.accessPrivateData() là sai). Nó được gọi như một hàm thông thường (accessPrivateData(myObj);).
  • Hàm bạn không có con trỏ this. Nó phải truy cập các thành viên của lớp thông qua đối tượng được truyền vào làm đối số (nếu có).
  • Tính "bạn" không được kế thừa. Nếu lớp A là bạn của lớp B, và lớp C kế thừa từ A, thì C không tự động trở thành bạn của B.
  • Tính "bạn" không có tính bắc cầu. Nếu A là bạn của B, và B là bạn của C, thì A không tự động trở thành bạn của C.
Lớp bạn (Friend Class)

Tương tự như hàm bạn, Lớp bạn là một lớp được một lớp khác cấp quyền truy cập vào tất cả các thành viên privateprotected của nó. Nếu một lớp A là bạn của lớp B, thì tất cả các hàm thành viên của lớp A có thể truy cập các thành viên riêng tư và được bảo vệ của đối tượng thuộc lớp B.

Tại sao lại cần Lớp bạn?

Lớp bạn thường được sử dụng khi có hai lớp gắn bó chặt chẽ với nhau và thường xuyên cần truy cập vào nội bộ của nhau để thực hiện chức năng chung, đặc biệt là khi việc cung cấp các hàm thành viên công khai để truy cập dữ liệu riêng tư sẽ làm phức tạp giao diện của lớp hoặc phá vỡ logic thiết kế. Ví dụ: khi thiết kế các cấu trúc dữ liệu phức tạp (như danh sách liên kết, cây), lớp nút (Node) có thể cần cho phép lớp quản lý cấu trúc đó (ví dụ: List, Tree) truy cập trực tiếp vào các con trỏ liên kết của nó.

Làm thế nào để khai báo Lớp bạn?

Bạn khai báo một lớp là "bạn" của một lớp khác bằng cách đặt từ khóa friend class theo sau là tên lớp bạn bên trong định nghĩa lớp được kết bạn.

Ví dụ minh họa:

Hãy xem xét hai lớp SecretKeeper (người giữ bí mật) và SecretAgent (điệp viên bí mật). Chỉ SecretAgent mới được phép biết bí mật bên trong SecretKeeper.

#include <iostream>
#include <string>

// Khai báo trước (forward declaration) lớp SecretAgent
// Cần thiết vì SecretKeeper nhắc đến SecretAgent trước khi nó được định nghĩa đầy đủ
class SecretAgent;

// Khai báo lớp SecretKeeper
class SecretKeeper {
private:
    string topSecretData;

public:
    SecretKeeper(const string& secret) : topSecretData(secret) {}

    // Khai báo SecretAgent là lớp bạn của SecretKeeper
    // Điều này cho phép MỌI hàm thành viên của SecretAgent
    // truy cập vào privateData của SecretKeeper
    friend class SecretAgent;

    // Các hàm thành viên công khai khác nếu cần
    void somePublicMethod() const {
        cout << "SecretKeeper: Day la mot phuong thuc cong khai." << endl;
    }
};

// Khai báo và định nghĩa lớp SecretAgent
class SecretAgent {
public:
    SecretAgent() {}

    // Hàm thành viên của SecretAgent có thể truy cập privateData của SecretKeeper
    void revealSecret(const SecretKeeper& sk) const {
        cout << "SecretAgent: Bi mat da bi he lo! Data: " << sk.topSecretData << endl;
    }

    // Một hàm thành viên khác của SecretAgent, cũng có quyền truy cập
    void anotherAgentMethod(const SecretKeeper& sk) const {
        cout << "SecretAgent: Phuong thuc khac cung biet bi mat: " << sk.topSecretData << endl;
    }
};

int main() {
    SecretKeeper vault("Du lieu cuc mat!!!");
    SecretAgent 007;

    // Agent 007 co the goi ham de he lo bi mat cua vault
    007.revealSecret(vault);
    007.anotherAgentMethod(vault);

    // Thu truy cap truc tiep (se loi bien dich)
    // cout << vault.topSecretData << endl; // Lỗi: topSecretData là private

    return 0;
}

Giải thích code:

  • Chúng ta cần khai báo trước (forward declare) SecretAgent bởi vì SecretKeeper sử dụng tên SecretAgent trong khai báo friend class SecretAgent; trước khi SecretAgent được định nghĩa đầy đủ.
  • Trong SecretKeeper, dòng friend class SecretAgent; cấp quyền truy cập đầy đủ cho SecretAgent.
  • Bây giờ, trong lớp SecretAgent, bất kỳ hàm thành viên nào (như revealSecretanotherAgentMethod) đều có thể truy cập các thành viên riêng tư (topSecretData) của đối tượng SecretKeeper được truyền vào.
  • Thao tác cout << vault.topSecretData; trong main vẫn sẽ gây lỗi biên dịch vì main không phải là hàm bạn hay lớp bạn của SecretKeeper.

Lưu ý quan trọng về Lớp bạn:

  • Nếu lớp A là bạn của lớp B, thì B không tự động là bạn của A. Quan hệ bạn bè là đơn hướng (unidirectional).
  • Tính "bạn" không được kế thừa hay có tính bắc cầu, giống như hàm bạn.
Khi nào nên (và không nên) sử dụng Friend?

Việc sử dụng friend về cơ bản là phá vỡ nguyên tắc đóng gói. Nó giống như việc tạo ra một "lỗ hổng" có chủ đích trong lớp của bạn. Do đó, bạn nên sử dụng friend một cách cẩn trọng và chỉ khi thực sự cần thiết.

Nên sử dụng khi:

  • Quá tải các toán tử nhị phân như <<, >> hoặc các toán tử mà toán hạng bên trái không phải là đối tượng của lớp (như + trong int + MyClass).
  • Thiết kế các hàm tiện ích hoặc hàm trợ giúp có mối quan hệ rất chặt chẽ với nội bộ của lớp và việc cung cấp các hàm thành viên công khai sẽ làm phức tạp lớp một cách không cần thiết.
  • Trong một số mẫu thiết kế (design patterns) hoặc cấu trúc dữ liệu phức tạp nơi hai lớp thực sự cần làm việc ở mức độ "nội bộ" với nhau.

Không nên sử dụng khi:

  • Chỉ vì "tiện". Nếu bạn có thể đạt được mục tiêu bằng cách sử dụng các hàm thành viên công khai (ví dụ: thông qua getter/setter), hãy ưu tiên cách đó.
  • Khi quan hệ giữa các lớp không thực sự gắn bó chặt chẽ ở mức độ nội bộ.
  • Như một cách để "lách luật" truy cập dữ liệu riêng tư mà không suy nghĩ kỹ về thiết kế tổng thể.

Việc sử dụng friend quá mức có thể làm giảm khả năng bảo trì và hiểu code, vì nó làm mờ ranh giới giữa giao diện công khai và triển khai nội bộ của lớp.

Bài tập ví dụ: C++ Bài 24.B1: Quản lý điểm thi (OOP)

Quản lý điểm thi (OOP)

Đề bài

Trường FullHouse Dev muốn quản lý điểm thi của sinh viên. Quy tắc đánh giá như sau:

  • Điểm trung bình = (Điểm giữa kỳ × 40%) + (Điểm cuối kỳ × 60%)
  • Xếp loại:
    • Nếu Điểm trung bình ≥ 9.0: Xuất sắc
    • Nếu Điểm trung bình ≥ 7.5: Giỏi
    • Nếu Điểm trung bình ≥ 5.0: Trung bình
    • Nếu Điểm trung bình < 5.0: Yếu

Hãy nhập thông tin điểm thi của các sinh viên và tính toán điểm trung bình, xếp loại, điểm cao nhất, điểm thấp nhất và điểm trung bình của lớp.

Input Format

  • Dòng đầu ghi số sinh viên (không quá 100 sinh viên)
  • Mỗi sinh viên ghi trên 4 dòng:
    • Tên sinh viên
    • Điểm giữa kỳ
    • Điểm cuối kỳ
    • Mã sinh viên

Output Format

Ghi ra danh sách sinh viên đã được tính điểm gồm các thông tin:

  • Mã sinh viên
  • Tên sinh viên
  • Điểm giữa kỳ
  • Điểm cuối kỳ
  • Điểm trung bình
  • Xếp loại

Dòng cuối ghi điểm cao nhất, điểm thấp nhất và điểm trung bình của lớp (theo mẫu trong ví dụ).

Ví dụ

Dữ liệu vào:
3
Nguyen Van A
8.5
9.0
SV001
Tran Thi B
7.0
8.0
SV002
Le Van C
4.5
6.0
SV003
Dữ liệu ra:
SV001 Nguyen Van A 8.5 9.0 8.7 Xuat sac
SV002 Tran Thi B 7.0 8.0 7.6 Gioi
SV003 Le Van C 4.5 6.0 5.4 Trung binh
Diem cao nhat: 8.7
Diem thap nhat: 5.4
Diem trung binh cua lop: 7.23
Giải thích ví dụ mẫu:
  • Đầu vào: Nguyen Van A 8.5 9.0 SV001
  • Đầu ra: SV001 Nguyen Van A 8.5 9.0 8.7 Xuat sac

Dòng này hiển thị thông tin chi tiết về sinh viên SV001, bao gồm mã sinh viên, tên, điểm giữa kỳ, điểm cuối kỳ, điểm trung bình và xếp loại. Tổng điểm cao nhất, điểm thấp nhất và điểm trung bình của lớp cũng được tính toán và hiển thị ở dòng cuối.

Cách tính điểm trung bình cho Nguyễn Văn A: Điểm trung bình = (8.5 × 40%) + (9.0 × 60%) = 3.4 + 5.4 = 8.7 Chào bạn, đây là hướng dẫn giải bài tập Quản lý điểm thi sử dụng lập trình hướng đối tượng (OOP) trong C++, tập trung vào việc sử dụng các thư viện chuẩn (std) và cấu trúc code rõ ràng.

1. Phân tích bài toán và Xác định đối tượng:

Bài toán yêu cầu quản lý thông tin và điểm thi của nhiều sinh viên. Rõ ràng, đối tượng chính mà chúng ta cần mô hình hóa là SinhVien. Mỗi SinhVien sẽ có các thuộc tính và các hành vi liên quan đến điểm số của họ.

2. Thiết kế Lớp (Class) SinhVien:

  • Thuộc tính (Member Variables): Một đối tượng SinhVien cần lưu trữ các thông tin sau:

    • Mã sinh viên (kiểu string)
    • Tên sinh viên (kiểu string)
    • Điểm giữa kỳ (kiểu double)
    • Điểm cuối kỳ (kiểu double)
    • Điểm trung bình (kiểu double - sẽ được tính toán)
    • Xếp loại (kiểu string - sẽ được tính toán)
  • Phương thức (Member Functions): Lớp SinhVien nên có các hành vi sau:

    • Phương thức để nhập thông tin cho một sinh viên.
    • Phương thức để tính điểm trung bình từ điểm giữa kỳ và cuối kỳ.
    • Phương thức để xác định xếp loại dựa trên điểm trung bình.
    • Phương thức để hiển thị thông tin chi tiết của sinh viên (bao gồm cả điểm trung bình và xếp loại đã tính).

3. Chi tiết về các Phương thức trong lớp SinhVien:

  • Nhập thông tin: Phương thức này sẽ đọc mã sinh viên, tên, điểm giữa kỳ, điểm cuối kỳ từ luồng nhập chuẩn (cin). Lưu ý: Khi đọc tên sinh viên sau khi đã đọc điểm (là số), cần xử lý ký tự xuống dòng còn lại trong bộ đệm nhập (ví dụ: dùng cin.ignore() hoặc đọc rỗng một dòng trước khi dùng getline để đọc tên).
  • Tính điểm trung bình: Phương thức này sẽ áp dụng công thức: diemTrungBinh = diemGiuaKy * 0.4 + diemCuoiKy * 0.6. Sau khi tính toán, lưu kết quả vào thuộc tính diemTrungBinh.
  • Xếp loại: Phương thức này sẽ sử dụng các câu lệnh điều kiện (if-else if-else) dựa trên giá trị của diemTrungBinh để gán giá trị tương ứng ("Xuất sắc", "Giỏi", "Trung bình", "Yếu") vào thuộc tính xepLoai.
  • Hiển thị thông tin: Phương thức này sẽ in ra các thuộc tính của sinh viên theo đúng định dạng yêu cầu: Mã SV, Tên SV, Điểm giữa kỳ, Điểm cuối kỳ, Điểm trung bình, Xếp loại. Cần sử dụng các công cụ định dạng của cout (ví dụ: fixed, setprecision từ <iomanip>) để in điểm trung bình với số chữ số thập phân theo mẫu.

4. Quản lý Danh sách Sinh viên:

  • Chúng ta cần một nơi để lưu trữ nhiều đối tượng SinhVien. vector<SinhVien> là lựa chọn phù hợp và linh hoạt trong C++ để tạo một danh sách động các sinh viên.
  • Đọc số lượng sinh viên từ đầu vào.
  • Sử dụng một vòng lặp để đọc thông tin cho từng sinh viên:
    • Trong mỗi lần lặp, tạo một đối tượng SinhVien mới.
    • Gọi phương thức nhập thông tin của đối tượng đó.
    • Sau khi nhập xong điểm giữa kỳ và cuối kỳ, gọi các phương thức tính điểm trung bình và xếp loại cho đối tượng này.
    • Thêm đối tượng SinhVien đã hoàn chỉnh vào vector.

5. Tính toán Thống kê Lớp:

  • Sau khi đã đọc và xử lý thông tin của tất cả sinh viên và lưu vào vector, chúng ta cần duyệt qua vector này để tính toán điểm cao nhất, điểm thấp nhất và điểm trung bình của cả lớp.
  • Khởi tạo các biến để lưu trữ điểm cao nhất (ví dụ: bắt đầu bằng một giá trị rất nhỏ), điểm thấp nhất (bắt đầu bằng một giá trị rất lớn), và tổng điểm trung bình của tất cả sinh viên (bắt đầu bằng 0).
  • Duyệt qua từng đối tượng SinhVien trong vector:
    • So sánh diemTrungBinh của sinh viên hiện tại với điểm cao nhất đã lưu, cập nhật nếu cần.
    • So sánh diemTrungBinh của sinh viên hiện tại với điểm thấp nhất đã lưu, cập nhật nếu cần.
    • Cộng diemTrungBinh của sinh viên hiện tại vào biến tổng điểm.
  • Sau khi duyệt hết vector, tính điểm trung bình của lớp bằng cách chia tổng điểm cho số lượng sinh viên.
  • In kết quả thống kê theo đúng định dạng yêu cầu, cũng sử dụng định dạng số thập phân (fixed, setprecision).

6. Các Thư viện Cần Thiết:

  • <iostream>: Để nhập/xuất dữ liệu (cin, cout).
  • <string>: Để sử dụng kiểu dữ liệu string.
  • <vector>: Để sử dụng vector để lưu danh sách sinh viên.
  • <iomanip>: Để định dạng đầu ra số thập phân (fixed, setprecision).
  • <limits> hoặc <cfloat> (tùy chọn): Để khởi tạo giá trị ban đầu cho min/max điểm một cách an toàn, hoặc đơn giản là dùng điểm của sinh viên đầu tiên nếu chắc chắn có ít nhất 1 sinh viên.

Tóm lại, các bước thực hiện chính:

  1. Khai báo và định nghĩa lớp SinhVien với các thuộc tính và phương thức cần thiết (nhập, tính điểm TB, xếp loại, in thông tin).
  2. Trong hàm main:
    • Đọc số lượng sinh viên N.
    • Tạo một vector<SinhVien> rỗng.
    • Lặp N lần để đọc thông tin từng sinh viên, tạo đối tượng SinhVien, gọi các phương thức tính toán, và thêm vào vector.
    • Duyệt vector để in thông tin từng sinh viên và đồng thời tính toán tổng điểm, tìm min/max điểm trung bình.
    • Tính điểm trung bình của lớp từ tổng điểm.
    • In ra kết quả thống kê lớp (min, max, trung bình).

Đây là cấu trúc và hướng đi chi tiết để giải bài toán bằng C++ và OOP. Chú trọng vào việc chia nhỏ bài toán thành các trách nhiệm của lớp SinhVien và sử dụng vector để quản lý danh sách.

Làm thêm nhiều bài tập miễn phí tại đây

Comments

There are no comments at the moment.