Trong bài này chúng ta sẽ học tính trừu tượng trong C++ (abstraction): hàm thuần ảo, lớp trừu tượng, interface và cách “lập trình theo interface”. Đây là tính chất cuối cùng trong 4 tính chất của OOP, và là thứ giúp các phần của một chương trình lớn tách biệt nhau, sửa phần này không kéo theo phải sửa phần kia.
Bài 21 trong Serie Lập trình C++ từ A tới Z
Tính trừu tượng trong C++ là gì?
Trừu tượng (abstraction) là chỉ đưa ra những gì người dùng cần biết để sử dụng, và giấu đi cách mọi thứ hoạt động bên trong.
Ví dụ: lái ô tô. Người lái chỉ cần biết vô lăng, chân ga, chân phanh. Bên dưới là phun xăng điện tử hay động cơ điện, hộp số sàn hay tự động, người lái không cần quan tâm. Xe điện và xe xăng khác nhau hoàn toàn bên trong, nhưng cùng một “giao diện” lái xe, nên ai biết lái xe này thì lái được xe kia.
Trong C++, trừu tượng được thể hiện bằng lớp trừu tượng và interface: lớp cha chỉ quy định “phải có những hàm gì”, còn mỗi lớp con tự quyết “làm thế nào”.
Hàm thuần ảo và lớp trừu tượng
Ở bài tính đa hình trong C++, lớp TaiLieu có hàm ảo soNgayMuonToiDa() trả về 14. Nhưng thực ra không có “tài liệu chung chung” nào trong thư viện, chỉ có sách, tạp chí… Con số 14 ở lớp cha là vô nghĩa.
C++ cho phép khai báo hàm ảo không có phần thân, bằng cách viết = 0 ở cuối. Đó là hàm thuần ảo (pure virtual function):
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class TaiLieu // lop truu tuong
{
public:
TaiLieu(const std::string& ten) : ten_(ten) {}
virtual ~TaiLieu() = default;
virtual int soNgayMuonToiDa() const = 0; // ham thuan ao: lop con bat buoc viet
void inThongTin() const // ham thuong dung chung
{
std::cout << ten_ << ": muon toi da " << soNgayMuonToiDa() << " ngay\n";
}
protected:
std::string ten_;
};
class Sach : public TaiLieu
{
public:
using TaiLieu::TaiLieu;
int soNgayMuonToiDa() const override { return 14; }
};
class TapChi : public TaiLieu
{
public:
using TaiLieu::TaiLieu;
int soNgayMuonToiDa() const override { return 3; }
};
int main()
{
std::vector<std::unique_ptr<TaiLieu>> kho;
kho.push_back(std::make_unique<Sach>("Lap trinh C++"));
kho.push_back(std::make_unique<TapChi>("Tap chi Dien tu"));
for (const auto& tl : kho)
tl->inThongTin();
return 0;
}
Class có ít nhất một hàm thuần ảo gọi là lớp trừu tượng (abstract class). Lớp trừu tượng vẫn có thể có thuộc tính và hàm bình thường như ten_ và inThongTin(). Để ý inThongTin() gọi soNgayMuonToiDa() dù chính nó không biết con số đó: phần chung viết một lần ở lớp cha, phần riêng để lớp con điền vào.
Không tạo được đối tượng từ lớp trừu tượng
Lớp trừu tượng là một bản thiết kế còn thiếu, nên không tạo đối tượng trực tiếp được:
#include <string>
class TaiLieu
{
public:
virtual ~TaiLieu() = default;
virtual int soNgayMuonToiDa() const = 0;
};
int main()
{
TaiLieu t; // loi: khong tao duoc doi tuong tu lop truu tuong
return 0;
}
g++ báo cannot declare variable 't' to be of abstract type 'TaiLieu' và liệt kê luôn những hàm còn thuần ảo. Nhưng con trỏ và tham chiếu kiểu TaiLieu*, const TaiLieu& thì vẫn dùng bình thường, như vector trong ví dụ trên. Đó chính là mục đích của lớp trừu tượng: làm kiểu chung cho đa hình.
Lớp con phải viết đủ mọi hàm thuần ảo
Lớp con chỉ tạo được đối tượng khi đã ghi đè tất cả hàm thuần ảo. Thiếu một hàm thì lớp con cũng trở thành lớp trừu tượng:
#include <string>
class TaiLieu
{
public:
virtual ~TaiLieu() = default;
virtual int soNgayMuonToiDa() const = 0;
virtual std::string loai() const = 0;
};
class BanDo : public TaiLieu
{
public:
int soNgayMuonToiDa() const override { return 7; }
// quen viet loai()
};
int main()
{
BanDo b;
return 0;
}
Đây là điểm mạnh của hàm thuần ảo so với hàm ảo thường: quên viết thì trình biên dịch nhắc ngay, chứ không lặng lẽ dùng bản mặc định của lớp cha. Thông báo lỗi còn chỉ ra đúng hàm loai() đang thiếu.
Interface trong C++
C++ không có từ khóa interface như Java hay C#. Một interface trong C++ là lớp trừu tượng đặc biệt: chỉ gồm các hàm thuần ảo và hàm hủy ảo, không có thuộc tính, không có hàm thường.
Thư viện cần nhắc độc giả trả sách quá hạn. Hôm nay gửi email, tháng sau có thể thêm SMS, năm sau thêm Zalo. Định nghĩa interface KenhThongBao, và hàm nghiệp vụ chỉ làm việc với interface:
#include <iostream>
#include <string>
// Interface: chi co ham thuan ao, khong co du lieu
class KenhThongBao
{
public:
virtual ~KenhThongBao() = default;
virtual void gui(const std::string& nguoiNhan, const std::string& noiDung) const = 0;
};
class Email : public KenhThongBao
{
public:
void gui(const std::string& nguoiNhan, const std::string& noiDung) const override
{
std::cout << "[Email toi " << nguoiNhan << "@thuvien.vn] " << noiDung << '\n';
}
};
class SMS : public KenhThongBao
{
public:
void gui(const std::string& nguoiNhan, const std::string& noiDung) const override
{
std::cout << "[SMS toi " << nguoiNhan << "] " << noiDung << '\n';
}
};
// Ham nghiep vu chi biet interface, khong biet gui bang cach nao
void nhacTraSach(const KenhThongBao& kenh, const std::string& docGia, const std::string& tenSach, int soNgayQuaHan)
{
kenh.gui(docGia, "Sach '" + tenSach + "' da qua han " + std::to_string(soNgayQuaHan) + " ngay");
}
int main()
{
Email email;
SMS sms;
nhacTraSach(email, "an.nguyen", "Lap trinh C++", 3);
nhacTraSach(sms, "0901234567", "Clean Code", 5);
return 0;
}
Hàm nhacTraSach không biết email hay SMS là gì, nó chỉ biết “có một kênh, gọi gui() là gửi được”. Đây gọi là lập trình theo interface:
- Thêm kênh Zalo: viết class
Zalokế thừaKenhThongBao,nhacTraSachkhông sửa gì. - Kiểm thử: viết một class
KenhGiachỉ ghi lại tin nhắn vào biến thay vì gửi thật, rồi truyền vàonhacTraSachđể kiểm tra nội dung. Không cần mạng, không cần điện thoại.
Interface cũng là cách an toàn nhất để dùng đa kế thừa trong C++: một class có thể thực thi nhiều interface mà không lo vấn đề kim cương, vì interface không có dữ liệu.
Lưu ý: Interface cũng phải có virtual ~TenInterface() = default;. Thường chúng ta sẽ giữ đối tượng qua con trỏ interface và xóa qua con trỏ đó, thiếu hàm hủy ảo là rò rỉ bộ nhớ như đã thấy ở bài đa hình.
Trừu tượng khác đóng gói thế nào?
Hai tính chất này hay bị nhầm vì cùng liên quan tới “giấu”:
| Đóng gói | Trừu tượng | |
|---|---|---|
| Giấu cái gì | Dữ liệu bên trong một class | Cách cài đặt, chỉ đưa ra “cái gì” chứ không phải “làm thế nào” |
| Công cụ C++ | private, getter, hàm có kiểm tra |
Hàm thuần ảo, lớp trừu tượng, interface |
| Câu hỏi trả lời | Ai được sửa dữ liệu này? | Người dùng cần biết những gì để dùng? |
| Ví dụ | Không ai sửa được soLuong_ âm |
nhacTraSach không biết gửi bằng email hay SMS |
Hai tính chất bổ sung cho nhau: tính đóng gói bảo vệ từng đối tượng, còn trừu tượng giúp các phần của chương trình làm việc với nhau qua những “hợp đồng” rõ ràng.
Bài tập
- Viết lớp trừu tượng
NhanVienvới hàm thuần ảotinhLuong(), và 2 lớp conNhanVienToanThoiGian(lương cố định) vàNhanVienTheoGio(số giờ nhân đơn giá). Tính tổng lương của một danh sách nhân viên. - Thử tạo đối tượng
NhanVientrực tiếp và đọc thông báo lỗi. - Viết interface
CoTheTimKiemvới hàmbool khop(const std::string& tuKhoa) const, cho cảSachvàDocGiathực thi nó. Viết hàm tìm trong danh sáchCoTheTimKiem*. - Thêm class
KenhGiavào ví dụ thông báo: thay vì in ra màn hình, lưu nội dung tin cuối cùng vào một thuộc tính để kiểm tra lại.
Lời giải sẽ có trong bài tổng hợp bài tập OOP C++ nhé.
Kết
Tính trừu tượng trong C++ là quy định “phải làm được gì” ở lớp cha và để lớp con quyết định “làm thế nào”. Hàm thuần ảo = 0 buộc mọi lớp con viết đủ, lớp trừu tượng không tạo được đối tượng nhưng dùng làm kiểu chung, còn interface (chỉ có hàm thuần ảo) giúp các phần của chương trình tách biệt nhau. Tới đây chúng ta đã đi hết 4 tính chất của OOP: đóng gói, kế thừa, đa hình và trừu tượng.
Muốn tra cứu chi tiết về lớp trừu tượng, các bạn xem thêm tài liệu chuẩn trên cppreference.
Bài trước là tính đa hình trong C++. Ở chương tiếp theo chúng ta sẽ học template, thư viện STL và xử lý ngoại lệ. Nếu thấy bài viết có ích, hãy chia sẻ cho bạn bè đang học OOP nhé.






