Bài 35.1: Giới thiệu lập trình hướng đối tượng trong C++

Sau hành trình khám phá những kiến thức cơ bản về C++, đã đến lúc chúng ta bước vào một thế giới mới, một triết lý lập trình mạnh mẽ và phổ biến: Lập trình hướng đối tượng (Object-Oriented Programming - OOP). C++ là một ngôn ngữ sinh ra để hỗ trợ OOP, và việc nắm vững các khái niệm này sẽ mở ra cánh cửa để bạn xây dựng các ứng dụng lớn hơn, phức tạp hơn, và dễ quản lý hơn rất nhiều.

Bạn đã quen với việc viết code theo kiểu "lập trình thủ tục" (procedural programming), nơi bạn tập trung vào các bước thực hiện (các hàm, các câu lệnh) để giải quyết vấn đề. OOP thay đổi góc nhìn đó. Thay vì tập trung vào "làm thế nào", chúng ta tập trung vào các đối tượng trong thế giới thực và mối quan hệ giữa chúng. Hãy nghĩ về thế giới xung quanh bạn: có những chiếc ô tô, những con chó, những cái cây, những cuốn sách... Mỗi thứ đó là một đối tượng với những đặc điểm (màu sắc, kích thước, tên...) và những hành động có thể thực hiện (chạy, sủa, rụng lá, mở ra...).

OOP cố gắng mô hình hóa thế giới thực này vào code của chúng ta, giúp code trở nên trực quan, dễ hiểu, và quan trọng nhất là dễ bảo trìmở rộng. C++ là một trong những ngôn ngữ tiêu biểu hỗ trợ đa mô hình lập trình, bao gồm cả OOP một cách mạnh mẽ.

Hãy cùng nhau khám phá những khái niệm cốt lõi làm nên sức mạnh của OOP trong C++!

1. Class (Lớp) và Object (Đối tượng)

Đây là hai khái niệm nền tảng của OOP.

  • Class (Lớp): Hãy tưởng tượng Class như một bản thiết kế hoặc một khuôn mẫu để tạo ra các đối tượng. Nó định nghĩa các đặc điểm (gọi là thuộc tính hay data members) và các hành động (gọi là phương thức hay member functions) mà các đối tượng được tạo ra từ nó sẽ có. Một Class không chiếm bộ nhớ khi chương trình chạy cho đến khi bạn tạo ra một Object từ nó.
  • Object (Đối tượng): Một Object là một thể hiện cụ thể được tạo ra từ một Class. Mỗi Object có giá trị riêng cho các thuộc tính được định nghĩa trong Class của nó, và có thể thực hiện các hành động (gọi các phương thức) được định nghĩa trong Class đó.

Hãy nghĩ đơn giản: Class "Ô tô" là bản thiết kế chung mô tả một chiếc ô tô sẽ có màu sắc, số bánh, nhãn hiệu (thuộc tính) và các hành động như chạy, phanh, bấm còi (phương thức). Object "Chiếc ô tô của tôi" là một thể hiện cụ thể từ bản thiết kế đó, có màu "đỏ", 4 bánh, nhãn hiệu "Toyota", và có thể thực hiện các hành động chạy, phanh, bấm còi.

Trong C++, chúng ta định nghĩa một Class sử dụng từ khóa class:

#include <iostream>
#include <string>

class Dog {
public:
    string s; // Tên
    int a;          // Tuổi

    void keu() { // Sủa
        cout << s << " (" << a << " tuoi) keu Woof! Woof!" << endl;
    }

    void an(string m) { // Ăn
        cout << s << " dang an " << m << "." << endl;
    }
};

int main() {
    Dog c1;

    c1.s = "Buddy";
    c1.a = 3;

    c1.keu();
    c1.an("thuc an hat");

    Dog c2;
    c2.s = "Max";
    c2.a = 5;
    c2.keu();

    return 0;
}

Output:

Buddy (3 tuoi) keu Woof! Woof!
Buddy dang an thuc an hat.
Max (5 tuoi) keu Woof! Woof!

Giải thích:

  • Chúng ta định nghĩa class Dog { ... };.
  • Bên trong Class, chúng ta khai báo các thuộc tính (name, age) và các phương thức (bark(), eat()).
  • Từ khóa public: chỉ ra rằng các thuộc tính và phương thức được khai báo sau nó có thể được truy cập từ bên ngoài Class.
  • Trong main(), chúng ta tạo ra các Object myDoganotherDog từ Class Dog. Mỗi Object này có bộ nhớ riêng để lưu trữ nameage của nó.
  • Chúng ta dùng toán tử dấu chấm (.) để truy cập các thuộc tính và gọi các phương thức của một Object cụ thể (ví dụ: myDog.name, myDog.bark()).

2. Encapsulation (Đóng gói)

Encapsulation là nguyên tắc đóng gói dữ liệu (thuộc tính) và các phương thức xử lý dữ liệu đó vào làm một đơn vị duy nhất (chính là Class). Đồng thời, nó cũng đề cập đến việc che giấu chi tiết triển khai (information hiding).

Thay vì cho phép truy cập trực tiếp vào tất cả các thuộc tính của Object từ bên ngoài, chúng ta thường đặt các thuộc tính ở chế độ riêng tư (private) và chỉ cung cấp các phương thức công khai (public) để truy cập hoặc sửa đổi chúng. Các phương thức này thường được gọi là getter (để lấy giá trị) và setter (để đặt giá trị).

Tại sao lại làm vậy?

  • Kiểm soát truy cập: Chúng ta có thể đặt các quy tắc hoặc kiểm tra hợp lệ khi dữ liệu bị thay đổi thông qua setter (ví dụ: tuổi không được âm).
  • Bảo vệ dữ liệu: Ngăn chặn việc thay đổi dữ liệu một cách tùy tiện từ bên ngoài, giúp dữ liệu luôn ở trạng thái hợp lệ.
  • Linh hoạt: Nếu sau này bạn quyết định thay đổi cách lưu trữ một thuộc tính nào đó bên trong Class, bạn chỉ cần sửa code bên trong Class mà không ảnh hưởng đến code bên ngoài đang sử dụng getter/setter đó.

C++ cung cấp các mức độ truy cập (access specifiers):

  • public:: Có thể truy cập từ bất kỳ đâu.
  • private:: Chỉ có thể truy cập từ bên trong cùng một Class.
  • protected:: Chỉ có thể truy cập từ bên trong cùng một Class hoặc các Class kế thừa từ nó (chúng ta sẽ nói về kế thừa sau).

Ví dụ về Encapsulation với Class Dog:

#include <iostream>
#include <string>

class Dog {
private:
    string s;
    int a;

public:
    Dog(string _s, int _a) {
        s = _s;
        if (_a > 0) {
            a = _a;
        } else {
            a = 0;
            cerr << "Loi: Tuoi khong hop le. Dat 0." << endl;
        }
    }

    string layTen() const {
        return s;
    }

    void datTen(string _s) {
        s = _s;
    }

    int layTuoi() const {
        return a;
    }

    void datTuoi(int _a) {
        if (_a > 0) {
            a = _a;
        } else {
             cerr << "Loi: Tuoi khong hop le. Tuoi khong thay doi." << endl;
        }
    }

    void keu() const {
        cout << s << " (" << a << " tuoi) keu Woof!" << endl;
    }
};

int main() {
    Dog c1("Buddy", 3);

    // c1.s = "Ten Moi"; // Lỗi compile!

    cout << "Ten: " << c1.layTen() << endl;

    c1.datTen("Buddy Vĩ Đại");
    cout << "Ten moi: " << c1.layTen() << endl;

    c1.datTuoi(4);
    cout << "Tuoi moi: " << c1.layTuoi() << endl;

    c1.datTuoi(-2);

    c1.keu();

    return 0;
}

Output:

Ten: Buddy
Ten moi: Buddy Vĩ Đại
Tuoi moi: 4
Loi: Tuoi khong hop le. Tuoi khong thay doi.
Buddy Vĩ Đại (4 tuoi) keu Woof!

Giải thích:

  • Các thuộc tính nameage được đặt là private. Bạn sẽ gặp lỗi biên dịch nếu cố gắng truy cập chúng trực tiếp từ main().
  • Các phương thức getName(), setName(), getAge(), setAge(), và bark() được đặt là public. Chúng là giao diện công khai để tương tác với Object Dog.
  • Phương thức setAge() chứa logic kiểm tra if (a > 0) để đảm bảo age luôn là số dương. Đây là cách Encapsulation giúp bảo vệ tính toàn vẹn của dữ liệu.
  • Constructor Dog(string n, int a) cũng là một phần của Encapsulation, đảm bảo rằng khi một Object Dog được tạo ra, nó được khởi tạo với các giá trị ban đầu đã qua kiểm tra.

3. Abstraction (Trừu tượng)

Abstraction là nguyên tắc che giấu những chi tiết phức tạp và chỉ phơi bày những tính năng thiết yếu cho người dùng. Nó tập trung vào điều gì một đối tượng làm được, hơn là làm thế nào nó làm điều đó.

Hãy quay lại ví dụ chiếc ô tô: Khi bạn lái xe, bạn sử dụng bàn đạp ga, bàn đạp phanh, vô lăng. Bạn không cần phải hiểu chi tiết cơ chế hoạt động của động cơ, hệ thống phanh thủy lực hay hệ thống lái. Giao diện (bàn đạp, vô lăng) đã trừu tượng hóa sự phức tạp bên dưới.

Trong OOP, Abstraction đạt được thông qua:

  • Sử dụng Encapsulation: Ẩn đi các thuộc tính và phương thức nội bộ, chỉ công khai các phương thức cần thiết để tương tác.
  • Thiết kế giao diện: Định nghĩa các phương thức công khai rõ ràng, mô tả hành động mà Object có thể thực hiện mà không tiết lộ cách thức thực hiện chi tiết.
  • (Nâng cao) Abstract Classes và Interfaces: C++ có khái niệm về lớp trừu tượng và phương thức thuần ảo (pure virtual functions), cho phép định nghĩa một "hợp đồng" về các phương thức mà các lớp con phải triển khai.

Với ví dụ Class Dog ở trên: Khi bạn gọi myDog.bark(), bạn chỉ cần biết rằng con chó sẽ sủa. Bạn không cần quan tâm bên trong phương thức bark() đó có những dòng code nào, nó tạo ra âm thanh bằng cách nào, hay nó sử dụng những thuộc tính nội bộ nào (ví dụ: có thể có một thuộc tính soundFrequency ẩn bên trong). bark() là một giao diện trừu tượng cho hành động sủa.

Để minh họa rõ hơn sự trừu tượng, hãy thêm một chút chi tiết nội bộ vào phương thức bark():

#include <iostream>
#include <string>

class Dog {
private:
    string s;
    int a;
    double v;

    void taoAm() const {
        cout << "Tao song am o do to " << v << "... ";
        cout << "Xong!" << endl;
    }

public:
    Dog(string _s, int _a, double _v = 1.0) : s(_s), a(_a), v(_v) {}

    string layTen() const { return s; }
    int layTuoi() const { return a; }
    void datDoTo(double _v) { v = _v; }

    void keu() const {
        taoAm();
        cout << s << " (" << a << " tuoi) keu Woof! (do to " << v << ")" << endl;
    }
};

int main() {
    Dog c1("Max", 5, 0.8);

    c1.keu();

    return 0;
}

Output:

Tao song am o do to 0.8... Xong!
Max (5 tuoi) keu Woof! (do to 0.8)

Giải thích:

  • Phương thức generateSoundWaves() là chi tiết triển khai nội bộ (giả định là phức tạp). Nó được đặt là private hoặc sử dụng bên trong các phương thức công khai.
  • Phương thức bark() là giao diện trừu tượng. Người dùng chỉ cần biết rằng gọi bark() sẽ làm con chó phát ra âm thanh sủa. Họ không cần biết (và cũng không nên biết) về sự tồn tại của generateSoundWaves() hay cách nó hoạt động.
  • Abstraction giúp đơn giản hóa việc sử dụng Object và cho phép thay đổi chi tiết triển khai nội bộ mà không ảnh hưởng đến code sử dụng Object đó.

4. Inheritance (Kế thừa)

Inheritance là một trong những sức mạnh lớn nhất của OOP, cho phép bạn tạo ra một Class mới (lớp con hay lớp dẫn xuất - Derived Class) dựa trên một Class đã có (lớp cha hay lớp cơ sở - Base Class).

Khi một lớp con kế thừa từ lớp cha, nó sẽ tự động có được (kế thừa) các thuộc tính và phương thức công khai (public) và được bảo vệ (protected) của lớp cha. Lớp con có thể thêm các thuộc tính và phương thức mới của riêng mình, hoặc thay đổi (ghi đè - override) các phương thức của lớp cha để phù hợp với đặc điểm riêng.

Mối quan hệ giữa lớp cha và lớp con thường được mô tả là mối quan hệ "is-a" (là một). Ví dụ: "Chó là một Động vật", "Ô tô là một Phương tiện giao thông".

Kế thừa giúp:

  • Tái sử dụng code: Không cần viết lại code cho các thuộc tính và phương thức chung.
  • Xây dựng hệ thống phân cấp: Tổ chức các Class thành cấu trúc cây logic, phản ánh mối quan hệ trong thế giới thực.
  • Hỗ trợ Đa hình: Đây là nền tảng cho Đa hình runtime (sẽ nói sau).

Trong C++, bạn sử dụng dấu hai chấm : sau tên lớp con, theo sau là mức độ truy cập (thường là public) và tên lớp cơ sở:

#include <iostream>
#include <string>
#include <vector>

class DongVat {
private:
    string loai;

protected:
    string ten;

public:
    DongVat(string _l, string _t) : loai(_l), ten(_t) {
        cout << "Tao DongVat: " << ten << " (" << loai << ")" << endl;
    }

    void an() const {
        cout << ten << " thuoc loai " << loai << " dang an." << endl;
    }

    virtual void keu() const {
        cout << ten << " thuoc loai " << loai << " phat ra am thanh chung chung." << endl;
    }

    virtual ~DongVat() {
         cout << "Huy DongVat: " << ten << " (" << loai << ")" << endl;
    }
};

class Cho : public DongVat {
private:
    string giong;

public:
    Cho(string _t, string _g) : DongVat("Cho", _t), giong(_g) {
        cout << "Tao Cho: " << ten << " (" << giong << ")" << endl;
    }

    void sua() const {
        cout << ten << " thuoc giong " << giong << " keu Woof! Woof!" << endl;
    }

    void keu() const override {
         cout << ten << " thuoc giong " << giong << " keu Woof! (ghi de)" << endl;
    }

    ~Cho() override {
         cout << "Huy Cho: " << ten << " (" << giong << ")" << endl;
    }
};

class Meo : public DongVat {
public:
    Meo(string _t) : DongVat("Meo", _t) {
        cout << "Tao Meo: " << ten << endl;
    }

    void meoMeo() const {
        cout << ten << " thuoc loai Meo keu Meow!" << endl;
    }

    void keu() const override {
         cout << ten << " thuoc loai Meo keu Meow! (ghi de)" << endl;
    }

    ~Meo() override {
         cout << "Huy Meo: " << ten << endl;
    }
};


int main() {
    Cho c1("Buddy", "Golden Retriever");

    c1.an();
    c1.sua();
    c1.keu();

    cout << "\n--- Minh hoa ke thua va IS-A ---\n";

    DongVat* dv1 = &c1;
    Meo m1("Whiskers");
    DongVat& dv2 = m1;

    dv1->an();
    dv2.an();

    dv1->keu();
    dv2.keu();

    return 0;
}

Output:

Tao DongVat: Buddy (Cho)
Tao Cho: Buddy (Golden Retriever)
Buddy thuoc loai Cho dang an.
Buddy thuoc giong Golden Retriever keu Woof! Woof!
Buddy thuoc giong Golden Retriever keu Woof! (ghi de)

--- Minh hoa ke thua va IS-A ---
Tao DongVat: Whiskers (Meo)
Tao Meo: Whiskers
Buddy thuoc loai Cho dang an.
Whiskers thuoc loai Meo dang an.
Buddy thuoc giong Golden Retriever keu Woof! (ghi de)
Whiskers thuoc loai Meo keu Meow! (ghi de)
Huy Meo: Whiskers
Huy DongVat: Whiskers (Meo)
Huy Cho: Buddy (Golden Retriever)
Huy DongVat: Buddy (Cho)

Giải thích:

  • Chúng ta định nghĩa class Animal làm lớp cơ sở. Nó có thuộc tính protected name và các phương thức eat()makeSound(). makeSound() được đánh dấu là virtual.
  • class Dog : public Animal định nghĩa lớp Dog kế thừa công khai từ Animal.
  • Trong constructor của Dog, chúng ta phải gọi constructor của lớp cơ sở Animal bằng cú pháp : Animal("Dog", n).
  • Dog tự động có phương thức eat(). Nó thêm thuộc tính breed và phương thức riêng bark(). Nó cũng ghi đè phương thức makeSound() bằng cách định nghĩa lại nó với cùng chữ ký và thêm từ khóa override.
  • Thuộc tính protected name có thể được truy cập trực tiếp trong các lớp dẫn xuất như Dog.
  • Phần main() minh họa cách tạo object Dog và gọi các phương thức kế thừa (eat()) và các phương thức riêng (bark()), cũng như phương thức đã được ghi đè (makeSound()).
  • Quan trọng nhất là phần minh họa sử dụng con trỏ/tham chiếu lớp cơ sở (Animal* animalPtr1 = &myDog;). Một con trỏ tới Animal có thể trỏ tới một Object Dog (hoặc Cat), vì "Dog is-a Animal" và "Cat is-a Animal".

5. Polymorphism (Đa hình)

Polymorphism có nghĩa là "đa hình thái" hay "nhiều hình thức". Trong OOP, nó cho phép bạn xử lý các đối tượng thuộc các Class khác nhau (nhưng có chung lớp cơ sở) thông qua một giao diện chung (thường là con trỏ hoặc tham chiếu tới lớp cơ sở). Khi một phương thức được gọi thông qua giao diện chung này, chương trình sẽ thực thi phương thức phù hợp với kiểu dữ liệu thực tế của đối tượng tại thời điểm chạy chương trình.

C++ hỗ trợ hai loại đa hình chính:

  1. Compile-time Polymorphism (Đa hình lúc biên dịch): Đạt được thông qua Function Overloading (nhiều hàm cùng tên nhưng khác tham số) và Operator Overloading (định nghĩa lại toán tử). Trình biên dịch biết sẽ gọi hàm/toán tử nào dựa vào chữ ký (số lượng và kiểu tham số) lúc biên dịch.
  2. Runtime Polymorphism (Đa hình lúc chạy): Đạt được thông qua Virtual FunctionsBase Class Pointers/References. Quyết định gọi phương thức nào được đưa ra lúc chương trình đang chạy, dựa vào kiểu dữ liệu thực tế của đối tượng mà con trỏ/tham chiếu đang trỏ tới.

Runtime Polymorphism là đặc trưng mạnh mẽ của OOP, cho phép viết code linh hoạtmở rộng.

Để có Runtime Polymorphism trong C++, bạn cần:

  • Một hệ thống Class có Kế thừa.
  • Các phương thức trong lớp cơ sở cần được gọi một cách đa hình phải được đánh dấu bằng từ khóa virtual.
  • Các phương thức này được ghi đè trong lớp dẫn xuất.
  • Bạn phải sử dụng con trỏ hoặc tham chiếu tới lớp cơ sở để trỏ tới các Object của lớp dẫn xuất.

Ví dụ tiếp nối từ Kế thừa, minh họa Đa hình Runtime:

#include <iostream>
#include <vector>
#include <memory>

class DV { // Dong Vat
protected:
    string t; // ten
public:
    DV(string _t) : t(_t) {}

    virtual void keu() const {
        cout << t << " keu am chung." << endl;
    }

    virtual ~DV() {
        cout << "Huy DV: " << t << endl;
    }
};

class C : public DV { // Cho
public:
    C(string _t) : DV(_t) {}

    void keu() const override {
        cout << t << " keu Woof!" << endl;
    }

    ~C() override {
        cout << "Huy C: " << t << endl;
    }
};

class M : public DV { // Meo
public:
    M(string _t) : DV(_t) {}

    void keu() const override {
        cout << t << " keu Meow!" << endl;
    }

    ~M() override {
        cout << "Huy M: " << t << endl;
    }
};

int main() {
    C c("Buddy");
    M m("Whiskers");

    cout << "\n--- Da hinh Runtime ---\n";

    DV* p1 = &c;
    DV* p2 = &m;

    p1->keu();
    p2->keu();

    cout << "\n--- Da hinh voi Collection (vector) ---\n";

    vector<unique_ptr<DV>> trangTrai;

    trangTrai.push_back(make_unique<C>("Rocky"));
    trangTrai.push_back(make_unique<M>("Mittens"));
    trangTrai.push_back(make_unique<C>("Spike"));
    trangTrai.push_back(make_unique<M>("Leo"));

    cout << "Tieng keu cua cac dv trong trang trai:" << endl;
    for (const auto& x : trangTrai) {
        x->keu();
    }

    return 0;
}

Output:

Buddy keu Woof!
Whiskers keu Meow!

--- Da hinh Runtime ---
Buddy keu Woof!
Whiskers keu Meow!

--- Da hinh voi Collection (vector) ---
Tieng keu cua cac dv trong trang trai:
Rocky keu Woof!
Mittens keu Meow!
Spike keu Woof!
Leo keu Meow!
Huy C: Rocky
Huy DV: Rocky
Huy M: Mittens
Huy DV: Mittens
Huy C: Spike
Huy DV: Spike
Huy M: Leo
Huy DV: Leo
Huy C: Buddy
Huy DV: Buddy
Huy M: Whiskers
Huy DV: Whiskers

Giải thích:

  • Lớp Animal có phương thức virtual void makeSound(). Các lớp con DogCat ghi đè phương thức này.
  • Trong main(), chúng ta tạo con trỏ Animal* và trỏ chúng đến các đối tượng DogCat.
  • Khi gọi animalPtr1->makeSound() (con trỏ kiểu Animal trỏ tới Dog), chương trình không gọi Animal::makeSound(). Nhờ từ khóa virtual, nó nhìn vào kiểu dữ liệu thực tế của đối tượng được trỏ tới (là Dog) và gọi Dog::makeSound().
  • Tương tự, animalPtr2->makeSound() gọi Cat::makeSound().
  • Ví dụ với vector<unique_ptr<Animal>> minh họa cách bạn có thể lưu trữ các đối tượng thuộc các loại khác nhau (Dog, Cat) trong cùng một collection bằng cách sử dụng con trỏ (hoặc smart pointer như unique_ptr) tới lớp cơ sở. Vòng lặp for có thể xử lý tất cả chúng một cách thống nhất thông qua giao diện Animal*, và khi gọi animal->makeSound(), hành vi cụ thể của từng loại động vật sẽ được thực thi nhờ Đa hình Runtime.

Comments

There are no comments at the moment.