1. Các hàm thuần (trừu tượng) và các lớp cơ sở trừu tượng(abstract)

Cho đến nay, tất cả các hàm ảo chúng ta đã viết đều có phần thân (phần body của hàm). Tuy nhiên, C ++ cho phép bạn tạo ra một loại hàm ảo đặc biệt gọi là hàm thuần ảo (hoặc hàm trừu tượng) hoàn toàn không có cơ thể! Một hàm thuần ảo hoạt động như một trình giữ chỗ có nghĩa là nó được định nghĩa lại bởi các lớp dẫn xuất.

Để tạo một hàm thuần ảo, thay vì định nghĩa phần thân cho hàm, chúng ta chỉ cần gán hàm cho giá trị 0.

class Base
{
public:
    const char* sayHi() { return "Hi"; } // a normal non-virtual function    
 
    virtual const char* getName() { return "Base"; } // a normal virtual function
 
    virtual int getValue() = 0; // a pure virtual function
 
    int doSomething() = 0; // Compile error: can not set non-virtual functions to 0
};

Khi chúng ta thêm một hàm thuần ảo vào lớp của mình, chúng ta đang nói một cách hiệu quả, đó là tùy thuộc vào các lớp dẫn xuất để thực hiện hàm này.

Sử dụng một hàm thuần ảo có hai hậu quả chính: Thứ nhất, bất kỳ lớp nào có một hoặc nhiều hàm thuần ảo sẽ trở thành một lớp cơ sở trừu tượng, điều đó có nghĩa là nó không thể được khởi tạo! Xem xét những gì sẽ xảy ra nếu chúng ta có thể tạo một thể hiện của Base:

int main()
{
    Base base; // We can't instantiate an abstract base class, but for the sake of example, pretend this was allowed
    base.getValue(); // what would this do?
 
    return 0;
}

Bởi vì không có định nghĩa phần thân nào cả cho getValue(), nên base.getValue() sẽ goị gì?

Thứ hai, bất kỳ lớp dẫn xuất nào cũng phải định nghĩa một thân cho hàm này hoặc lớp dẫn xuất đó cũng sẽ được coi là một lớp cơ sở trừu tượng.

2. Một ví dụ về hàm thuần ảo

Hãy cùng xem một ví dụ về một hàm thuần ảo đang hoạt động. Trong một bài học trước, chúng ta đã viết một lớp cơ sở Animal đơn giản và bắt nguồn một lớp Cat và Dog từ đó. Đây là code:

/**
* 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/
*/

#include <string>
class Animal
{
protected:
    std::string m_name;
 
    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(std::string name)
        : m_name(name)
    {
    }
 
public:
    std::string getName() { return m_name; }
    virtual const char* speak() { return "???"; }
};
 
class Cat: public Animal
{
public:
    Cat(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() { return "Woof"; }
};

Chúng ta đã ngăn không cho mọi người cấp phát các đối tượng thuộc loại Animal bằng cách protected. Tuy nhiên, có hai vấn đề với điều này:
1) Hàm tạo vẫn có thể truy cập được từ bên trong các lớp dẫn xuất, cho phép khởi tạo một đối tượng Động vật.
2) Vẫn có thể tạo các lớp dẫn xuất không định nghĩa lại hàm speak().

Ví dụ:

#include <iostream>
class Cow: public Animal
{
public:
    Cow(std::string name)
        : Animal(name)
    {
    }
 
    // We forgot to redefine speak
};
 
int main()
{
    Cow cow("Betsy");
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
 
    return 0;
}

Điều này sẽ in:

Betsy says ???

Chuyện gì đã xảy ra? Chúng ta đã quên định nghĩa lại hàm speak(), vì vậy cow.Speak() đã gọi Animal.speak(), đó là những gì chúng ta muốn.

Một giải pháp tốt hơn cho vấn đề này là sử dụng một hàm thuần ảo:

#include <string>
class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name;
 
public:
    Animal(std::string name)
        : m_name(name)
    {
    }
 
    std::string getName() { return m_name; }
    virtual const char* speak() = 0; // note that speak is now a pure virtual function
};

Có một vài điều cần lưu ý ở đây. Đầu tiên, speak() bây giờ là một hàm thuần ảo. Điều này có nghĩa là Animal hiện là một lớp cơ sở trừu tượng và không thể khởi tạo được. Do đó, chúng ta không cần phải làm cho protected nữa. Thứ hai, vì lớp Cow của chúng ta có nguồn gốc từ Animal, nhưng chúng ta không định nghĩa Cow :: speak(), Cow cũng là một lớp cơ sở trừu tượng. Bây giờ khi chúng ta cố gắng biên dịch code này:

#include <iostream>
class Cow: public Animal
{
public:
    Cow(std::string name)
        : Animal(name)
    {
    }
 
    // We forgot to redefine speak
};
 
int main()
{
    Cow cow("Betsy");
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
 
    return 0;
}

Trình biên dịch sẽ đưa ra cảnh báo vì Cow là một lớp cơ sở trừu tượng và chúng ta không thể tạo các thể hiện của các lớp cơ sở trừu tượng:

C:\Test.cpp(141) : error C2259: 'Cow' : cannot instantiate abstract class due to following members:
        C:Test.cpp(128) : see declaration of 'Cow'
C:\Test.cpp(141) : warning C4259: 'const char *__thiscall Animal::speak(void)' : pure virtual function was not defined

Điều này cho chúng ta biết rằng chúng ta sẽ chỉ có thể khởi tạo Cow nếu Cow cung cấp một cơ thể cho hàm speak().

Hãy làm điều đó như sau:

/**
* 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/
*/

#include <iostream>
class Cow: public Animal
{
public:
    Cow(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() { return "Moo"; }
};
 
int main()
{
    Cow cow("Betsy");
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
 
    return 0;
}

Bây giờ chương trình này sẽ biên dịch và in:

Betsy says Moo

Một hàm thuần ảo rất hữu ích khi chúng ta có một hàm mà chúng ta muốn đặt trong lớp cơ sở, nhưng chỉ các lớp dẫn xuất mới biết nó nên trả về cái gì. Một hàm thuần ảo làm cho nó không thể khởi tạo lớp cơ sở và các lớp dẫn xuất buộc phải định nghĩa các hàm này trước khi chúng có thể được khởi tạo. Điều này giúp đảm bảo các lớp dẫn xuất không quên định nghĩa lại các hàm mà lớp cơ sở đang mong đợi chúng.

3. Hàm thuần ảo với phần thân

Hóa ra chúng ta có thể định nghĩa các hàm thuần ảo có các phần tử:

#include <string>
class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name;
 
public:
    Animal(std::string name)
        : m_name(name)
    {
    }
 
    std::string getName() { return m_name; }
    virtual const char* speak() = 0; // The = 0 means this function is pure virtual
};
 
const char* Animal::speak()  // even though it has a body
{
    return "buzz";
}

Trong trường hợp này, speak() vẫn được coi là một hàm thuần ảo (mặc dù nó đã được cung cấp cho một cơ thể, bởi vì = 0) và Animal vẫn được coi là một lớp cơ sở trừu tượng (và do đó không thể được khởi tạo) . Bất kỳ lớp nào kế thừa từ Animal cần cung cấp định nghĩa riêng cho speak() hoặc nó cũng sẽ được coi là một lớp cơ sở trừu tượng.

Khi cung cấp một cơ thể cho một hàm thuần ảo, cơ thể phải được cung cấp riêng.

Mô hình này có thể hữu ích khi bạn muốn lớp cơ sở của mình cung cấp một triển khai mặc định cho một hàm, nhưng vẫn buộc bất kỳ lớp dẫn xuất nào phải cung cấp triển khai riêng của chúng. Tuy nhiên, nếu lớp dẫn xuất hài lòng với việc triển khai mặc định được cung cấp bởi lớp cơ sở, thì nó có thể chỉ cần gọi trực tiếp thực hiện lớp cơ sở. Ví dụ:

/**
* 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/
*/

#include <string>
#include <iostream>
 
class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name;
 
public:
    Animal(std::string name)
        : m_name(name)
    {
    }
 
    std::string getName() { return m_name; }
    virtual const char* speak() = 0; // note that speak is a pure virtual function
};
 
const char* Animal::speak()
{
    return "buzz"; // some default implementation
}
 
class Dragonfly: public Animal
{
 
public:
    Dragonfly(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() // this class is no longer abstract because we defined this function
    {
        return Animal::speak(); // use Animal's default implementation
    }
};
 
int main()
{
    Dragonfly dfly("Sally");
    std::cout << dfly.getName() << " says " << dfly.speak() << '\n';
 
    return 0;
}

Các code trên in:

Sally says buzz

Khả năng này được sử dụng rất phổ biến.

4. Lớp Interface

Một lớp Interface là một lớp không có các biến thành viên và trong đó tất cả các hàm là thuần ảo! Nói cách khác, lớp hoàn toàn là một định nghĩa và không có triển khai thực tế. Các Interface rất hữu ích khi bạn muốn xác định chức năng mà các lớp dẫn xuất phải triển khai, và cách mà lớp dẫn xuất thực hiện hàm đó.

Các lớp Interface thường được đặt tên bắt đầu bằng một I. Ở đây, một lớp Interface mẫu:

class IErrorLog
{
public:
    virtual bool openLog(const char *filename) = 0;
    virtual bool closeLog() = 0;
 
    virtual bool writeError(const char *errorMessage) = 0;
 
    virtual ~IErrorLog() {} // make a virtual destructor in case we delete an IErrorLog pointer, so the proper derived destructor is called
};

Bất kỳ lớp nào kế thừa từ IErrorLog phải cung cấp các triển khai cho cả ba hàm để được khởi tạo. Bạn có thể lấy ra một lớp có tên FileErrorLog, trong đó openLog() mở tệp trên đĩa, closeLog() đóng tệp và writeError() ghi thông điệp vào tệp. Bạn có thể lấy được một lớp khác gọi là ScreenErrorLog, trong đó openLog() và closeLog() không làm gì và writeError() in thông báo trong hộp thông báo bật lên trên màn hình.

Bây giờ, hãy nói rằng bạn cần phải viết một số code sử dụng nhật ký lỗi ở trên. Nếu bạn viết code của mình để nó sử dụng cả FileErrorLog hoặc ScreenErrorLog một cách trực tiếp, thì bạn đã không sử dụng một cách hiệu quả khi sử dụng interface IErrorLog đó. Ví dụ, hàm sau đây buộc người gọi mySqrt() phải sử dụng FileErrorLog, có thể hoặc không thể là thứ họ muố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/
*/

#include <cmath> // for sqrt()
double mySqrt(double value, FileErrorLog &log)
{
    if (value < 0.0)
    {
        log.writeError("Tried to take square root of value less than 0");
        return 0.0;
    }
    else
        return sqrt(value);
}

Một cách tốt hơn để thực hiện hàm này là sử dụng IErrorLog thay thế:

#include <cmath> // for sqrt()
double mySqrt(double value, IErrorLog &log)
{
    if (value < 0.0)
    {
        log.writeError("Tried to take square root of value less than 0");
        return 0.0;
    }
    else
        return sqrt(value);
}

Bây giờ người gọi có thể vượt qua trong bất kỳ lớp nào phù hợp với interface IErrorLog. Nếu họ muốn lỗi đi đến một tệp, họ có thể vượt qua trong một phiên bản của FileErrorLog. Nếu họ muốn nó đi đến màn hình, họ có thể vượt qua trong một phiên bản của ScreenErrorLog. Hoặc nếu họ muốn làm điều gì đó mà bạn chưa từng nghĩ đến, chẳng hạn như gửi email cho ai đó khi có lỗi, họ có thể lấy một lớp mới từ IErrorLog (ví dụ: EmailErrorLog) và sử dụng một ví dụ về điều đó! Bằng cách sử dụng IErrorLog, chức năng của bạn trở nên độc lập và linh hoạt hơn.

Không quên bao gồm một hàm hủy ảo cho các lớp giao diện của bạn, do đó, hàm hủy có nguồn gốc thích hợp sẽ được gọi nếu một con trỏ tới interface bị xóa.

Các lớp interface đã trở nên cực kỳ phổ biến vì chúng dễ sử dụng, dễ mở rộng và dễ bảo trì. Trên thực tế, một số ngôn ngữ hiện đại, chẳng hạn như Java và C#, đã thêm một từ khóa interface, cho phép các lập trình viên xác định trực tiếp một lớp interface mà không cần phải đánh dấu rõ ràng tất cả các hàm thành viên là trừu tượng. Hơn nữa, mặc dù Java (trước phiên bản 8) và C # sẽ không cho phép bạn sử dụng nhiều kế thừa trên các lớp thông thường, nhưng chúng sẽ cho phép bạn thừa kế nhiều interface như bạn muốn. Bởi vì các interface không có dữ liệu và không có các định nghĩa hàm, chúng tránh được rất nhiều vấn đề truyền thống với nhiều kế thừa trong khi vẫn cung cấp nhiều tính linh hoạt.

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