18.7 – Чисто виртуальные функции, абстрактные базовые классы и интерфейсные классы

Добавлено 19 августа 2021 в 23:40

Чисто виртуальные (абстрактные) функции и абстрактные базовые классы

Пока что у всех виртуальных функций, которые мы написали, было тело (определение). Однако C++ позволяет создавать особый вид виртуальных функций, называемый чисто виртуальной функцией (или абстрактной функцией), которая вообще не имеет тела! Чисто виртуальная функция действует как просто заполнитель, который должен быть переопределен производными классами.

Чтобы создать чисто виртуальную функцию и не определять тело функции, мы просто присваиваем функции значение 0.

class Base
{
public:
    // обычная невиртуальная функция
    const char* sayHi() const { return "Hi"; }  
 
    // обычная виртуальная функция
    virtual const char* getName() const { return "Base"; } 
 
    // чисто виртуальная функция
    virtual int getValue() const = 0; 
 
    // ошибка компиляции: невозможно установить невиртуальной функции значение 0
    int doSomething() = 0; 
};

Когда мы добавляем в наш класс чисто виртуальную функцию, мы фактически говорим: «Реализовать эту функцию должны производные классы».

Использование чисто виртуальной функции имеет два основных последствия: во-первых, любой класс с одной или несколькими чисто виртуальными функциями становится абстрактным базовым классом, что означает, что его экземпляр не может быть создан! Подумайте, что бы произошло, если бы мы могли создать экземпляр Base:

int main()
{
    // Мы не можем создать экземпляр абстрактного базового класса,
    // но для примера представим, что это разрешено
    Base base; 
    base.getValue(); // что это сделало бы?
 
    return 0;
}

Поскольку для getValue() нет определения, во что будет разрешаться вызов base.getValue()?

Во-вторых, любой производный класс должен определять тело этой функции, иначе этот производный класс также будет считаться абстрактным базовым классом.

Пример чисто виртуальной функции

Давайте посмотрим на пример чисто виртуальной функции в действии. В предыдущем уроке мы написали простой базовый класс Animal и унаследовали от него классы Cat и Dog. Вот код в том виде, в каком мы его оставили:

#include <string>
#include <utility>
 
class Animal
{
protected:
    std::string m_name;
 
    // Мы делаем этот конструктор защищенным, потому что
    // не хотим, чтобы люди создавали объекты Animal напрямую,
    // но мы по-прежнему хотим, чтобы производные классы могли
    // его использовать.
    Animal(const std::string& name)
        : m_name{ name }
    {
    }
 
public:
    std::string getName() const { return m_name; }
    virtual const char* speak() const { return "???"; }
    
    virtual ~Animal() = default;
};
 
class Cat: public Animal
{
public:
    Cat(const std::string& name)
        : Animal{ name }
    {
    }
 
    const char* speak() const override { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(const std::string& name)
        : Animal{ name }
    {
    }
 
    const char* speak() const override { return "Woof"; }
};

Мы запретили пользователям размещать объекты типа Animal, сделав конструктор защищенным. Однако по-прежнему можно создавать объекты производных классов, которые не переопределяют функцию speak().

Например:

#include <iostream>
#include <string>
 
class Cow : public Animal
{
public:
    Cow(const std::string& name)
        : Animal{ name }
    {
    }
 
    // Мы забыли переопределить speak
};
 
int main()
{
    Cow cow{"Betsy"};
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
 
    return 0;
}

Этот код напечатает:

Betsy says ???

Что произошло? Мы забыли переопределить функцию speak(), поэтому вызов cow.speak() преобразовался в Animal::speak(), чего мы не хотели.

Лучшее решение этой проблемы – использовать чисто виртуальную функцию:

#include <string>
 
class Animal // Animal - абстрактный базовый класс
{
protected:
    std::string m_name;
 
public:
    Animal(const std::string& name)
        : m_name{ name }
    {
    }
 
    const std::string& getName() const { return m_name; }

    // обратите внимание, что теперь speak - это чисто виртуальная функция
    virtual const char* speak() const = 0;
    
    virtual ~Animal() = default;
};

Здесь нужно отметить несколько моментов. Во-первых, speak() теперь чисто виртуальная функция. Это означает, что Animal теперь является абстрактным базовым классом, и его экземпляр не может быть создан. Следовательно, нам больше не нужно делать конструктор защищенным (хотя это не повредит). Во-вторых, поскольку наш класс Cow был производным от Animal, но мы не определили Cow::speak(), Cow также является абстрактным базовым классом. Теперь, когда мы пытаемся скомпилировать этот код:

#include <iostream>
#include <string>
 
class Cow : public Animal
{
public:
    Cow(const std::string& name)
        : Animal{ name }
    {
    }
 
    // Мы забыли переопределить speak
};
 
int main()
{
    Cow cow{"Betsy"};
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
 
    return 0;
}

Компилятор выдаст нам предупреждение, что Cow является абстрактным базовым классом, и мы не можем создавать экземпляры абстрактных базовых классов (номера строк неверны, потому что класс Animal в приведенном выше примере был опущен):

<source>(33): error C2259: 'Cow': cannot instantiate abstract class
<source>(20): note: see declaration of 'Cow'
<source>(33): note: due to following members:
<source>(33): note: 'const char *Animal::speak(void) const': is abstract
<source>(15): note: see declaration of 'Animal::speak'

Это говорит нам о том, что мы сможем создать экземпляр Cow, только если Cow предоставит тело для speak().

Давайте продолжим и сделаем следующее:

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

Теперь эта программа скомпилируется и распечатает:

Betsy says Moo

Чисто виртуальная функция полезна, когда у нас есть функция, которую мы хотим поместить в базовый класс, но только производные классы знают, что она должна возвращать. Чисто виртуальная функция делает так, что экземпляр базового класса не может быть создан, а производные классы вынуждены определять эту функцию, чтобы могли быть созданы их экземпляры. Это помогает гарантировать, что производные классы не забудут переопределить функции, которые базовый класс ожидал от них.

Чисто виртуальные функции с телом

Оказывается, мы можем определять чисто виртуальные функции, у которых есть тело:

#include <string>
 
class Animal // Animal - абстрактный базовый класс
{
protected:
    std::string m_name;
 
public:
    Animal(const std::string& name)
        : m_name{ name }
    {
    }
 
    std::string getName() { return m_name; }

    // = 0 означает, что эта функция чисто виртуальная
    virtual const char* speak() const = 0;
    
    virtual ~Animal() = default;
};
 
const char* Animal::speak() const  // даже если у нее есть тело
{
    return "buzz";
}

В этом случае speak() по-прежнему считается чисто виртуальной функцией из-за "= 0" (несмотря на то, что ей было присвоено тело), а Animal по-прежнему считается абстрактным базовым классом (и, следовательно, его экземпляр не может быть создан). Любой класс, наследованный от Animal, должен предоставить для speak() собственное определение, иначе он также будет считаться абстрактным базовым классом.

При предоставлении тела для чисто виртуальной функции тело должно быть предоставлено отдельно (не встроенным).

Для пользователей Visual Studio


Visual Studio ошибочно допускает, чтобы объявления чисто виртуальных функций были определениями, например

// неправильно!
virtual const char* speak() const = 0
{
  return "buzz";
}

Это неправильно и не может быть отключено.

Эта парадигма может быть полезна, когда вы хотите, чтобы ваш базовый класс предоставлял для функции реализацию по умолчанию, но при этом заставлял любые производные классы предоставлять свои собственные реализации. Однако, если производный класс удовлетворен реализацией по умолчанию, предоставленной базовым классом, он может просто вызвать реализацию базового класса напрямую. Например:

#include <string>
#include <iostream>
 
class Animal // Animal - абстрактный базовый класс
{
protected:
    std::string m_name;
 
public:
    Animal(const std::string& name)
        : m_name(name)
    {
    }
 
    const std::string& getName() const { return m_name; }

    // обратите внимание, что speak - это чисто виртуальная функция
    virtual const char* speak() const = 0; 
    
    virtual ~Animal() = default;
};
 
const char* Animal::speak() const
{
    return "buzz"; // какая-то реализация по умолчанию
}
 
class Dragonfly: public Animal
{
 
public:
    Dragonfly(const std::string& name)
        : Animal{name}
    {
    }
 
    // этот класс больше не является абстрактным,
    // потому что мы определили эту функцию
    const char* speak() const override
    {
        // использовать реализацию по умолчанию в Animal
        return Animal::speak(); 
    }
};
 
int main()
{
    Dragonfly dfly{"Sally"};
    std::cout << dfly.getName() << " says " << dfly.speak() << '\n';
 
    return 0;
}

Приведенный выше код печатает:

Sally says buzz

Эта возможность используется не очень часто.

Интерфейсные классы

Интерфейсный класс – это класс, не имеющий переменных-членов, а все функции в котором являются чисто виртуальными! Другими словами, этот класс имеет только определение и не имеет реальной реализации. Интерфейсы полезны, когда вы хотите определить функциональные возможности, которые должны реализовывать производные классы, но оставляете детали того, как производный класс реализует эту функциональность, полностью на усмотрение производного класса.

Интерфейсные классы часто называют именами, начинающимися с I. Вот пример класса интерфейса:

class IErrorLog
{
public:
    virtual bool openLog(const char *filename) = 0;
    virtual bool closeLog() = 0;
 
    virtual bool writeError(const char *errorMessage) = 0;
 
    // создаем виртуальный деструктор на случай, если будем
    // удалять объект через указатель IErrorLog, чтобы
    // вызывался соответствующий производный деструктор
    virtual ~IErrorLog() {} 
};

Любой класс, наследованный от IErrorLog, чтобы его экземпляры можно было создавать, должен предоставлять реализации для всех трех функций. Вы можете создать класс с именем FileErrorLog, где openLog() открывает файл на диске, closeLog() закрывает файл, а writeError() записывает сообщение в файл. Вы можете создать другой класс под названием ScreenErrorLog, где openLog() и closeLog() ничего не делают, а writeError() печатает сообщение во всплывающем окне на экране.

Теперь предположим, что вам нужно написать код, который использует журнал ошибок. Если вы напишете свой код так, чтобы он напрямую включал FileErrorLog или ScreenErrorLog, то вы, по сути, застряли в использовании только такого типа журнала ошибок (по крайней мере, без переписывания вашей программы). Например, следующая функция фактически заставляет вызывающих mySqrt() использовать FileErrorLog, который может быть им нужен, а может быть и не нужен.

#include <cmath> // для 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 std::sqrt(value);
    }
}

Намного лучший способ реализовать эту функцию – использовать IErrorLog:

#include <cmath> // для 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 std::sqrt(value);
    }
}

Теперь вызывающий может передать любой класс, соответствующий интерфейсу IErrorLog. Если он захочет, чтобы сообщение об ошибке шло в файл, он может передать экземпляр FileErrorLog. Если он захочет, чтобы оно отображалось на экране, он может передать экземпляр ScreenErrorLog. Или, если он захочет сделать что-то, о чем вы даже не задумывались, например, отправить кому-то электронное письмо при возникновении ошибки, он может получить из IErrorLog новый класс (например, EmailErrorLog) и использовать его экземпляр! Используя IErrorLog, ваша функция становится более независимой и гибкой.

Не забудьте добавлять для интерфейсных классов виртуальный деструктор, чтобы при удалении через указатель на интерфейс вызывался соответствующий производный деструктор.

Интерфейсные классы стали чрезвычайно популярными, потому что их легко использовать, легко расширять и легко поддерживать. Фактически, некоторые современные языки, такие как Java и C#, добавили ключевое слово "interface", которое позволяет программистам напрямую определять класс интерфейса без необходимости явно отмечать все функции-члены как абстрактные. Более того, хотя Java (до версии 8) и C# не позволяют использовать множественное наследование с обычными классами, они допускают множественное наследование любого количества интерфейсов. Поскольку интерфейсы не имеют данных и тел функций, они позволяют избежать многих традиционных проблем с множественным наследованием, сохраняя при этом большую гибкость.

Чисто виртуальные функции и виртуальная таблица

В абстрактных классах по-прежнему есть виртуальные таблицы, поскольку их можно использовать, если у вас есть указатель или ссылка на абстрактный класс. Запись виртуальной таблицы для чисто виртуальной функции, если не предусмотрено переопределение, обычно либо содержит нулевой указатель, либо указывает на универсальную функцию, которая выводит ошибку (иногда эта функция называется __purecall).

Теги

C++ / CppLearnCppvtableАбстрактный базовый класс / ABC (Abstract Base Class)Виртуальная таблицаВиртуальная функцияДля начинающихИнтерфейсный классОбучениеПрограммированиеЧисто виртуальная функция

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.