View Categories

Наследование и полиморфизм

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

Допустим, у нас есть класс (1), который нужно использовать в другом классе (2). Для начала создадим объект класса (1) как член класса (2):

class Class1 {
  public:
    void test() {}      // # 1
};

class Class2 {
  public:
    Class1 obj1;

    void test() {       // #2
        obj1.test();    // вызов #1
    }
};

Class2 obj;
obj.obj1.test();    // вызвали #1 вложенного экземпляра Class1
obj.test();         // вызвали #2, которая внутри вызовет #1

Такой подход называется композицией классов - один класс присутствует в другом в качестве своего экземпляра

Здесь мы также сделали следующее - создали в классе (2) метод, такой же как в классе (1), и вызвали в нём метод класса (1), то есть "пробросили" вызов на метод внутреннего класса. Вызывая obj.test() мы вызываем одноимённый метод из класса (1). Таким образом можно дополнить другой класс из условной "библиотеки" новым функционалом. Но удобно ли это?

Родители и дети #

Есть гораздо более удобный механизм - наследование, когда один класс наследует другой, ребёнок наследует свойства родителя. Тут появляются термины, в разной литературе и статьях можно встретить их в разных сочетаниях:

  • Класс, который наследуется: родительский (parent), базовый (base), суперкласс (superclass)
  • Класс, который наследует: дочерний (child), производный (derived), подкласс (subclass)

Наследие #

Производный класс наследует только public и protected члены базового класса (переменные и методы), private члены будут недоступны в новом классе. Синтаксис наследования: class Child : доступ Parent {};, где доступ - модификатор наследования public, private или protected. Модификаторы позволяют очень гибко настраивать доступ в сложной иерархии наследования классов:

  • public наследование - члены наследуются без изменения уровня доступа (остаются public и protected)
  • protected наследование - public члены становятся protected
  • private наследование - public и protected члены становятся private
  • Отсутствие модификатора считается private наследованием - class Child : Parent

Наследование можно представить так: код базового класса просто "копируется" как текст в производный класс

// базовый класс
class Parent {
  public:
    void publicF() {}       // #1

  protected:
    void _protectedF() {}   // #2

  private:
    void _privateF() {}     // #3
};

// производный класс
class Child : public Parent {
  public:
    void test() {
        publicF();      // вызов метода #1 родителя
        _protectedF();  // вызов метода #2 родителя
        //_privateF();  // ошибка, приватные не наследуются - #3
    }
};

// тест
int main() {
    Child child;
    child.test();           // личный метод Child
    child.publicF();        // наследованный у Parent метод - #1
    //child._protectedF();  // ошибка, защищённый - #2
    //child._privateF();    // ошибка, приватный - #3
}

Таким образом мы сделали класс Child, который наследует класс Parent и может использовать его публичный метод publicF() и защищённый _protectedF(). Из программы доступен только метод publicF().

Выборочный уровень доступа #

При наследовании можно выборочно задать членам нужный уровень доступа, переопределив общий тип наследования при помощи оператора using - using имя_класса::член. Это не касается private членов базового класса, так как они не наследуются:

class Parent {
  public:
    void publicF() {}

  protected:
    void _protectedF() {}

  private:
    void _privateF() {}
};

class Child : public Parent {
  public:
    using Parent::_protectedF;  // был protected, стал публичным
    //using Parent::_privateF;  // ошибка, это private, его вообще нельзя using

  private:
    using Parent::publicF;      // был публичным, стал приватным
};

int main() {
    Child child;
    //child.publicF();      // ошибка, теперь приватный
    child._protectedF();
}

Наследование конструктора #

Здесь всё просто - конструкторы не наследуются:

class Parent {
  public:
    Parent(int a) {}
    Parent(int a, int b) {}
};

class Child : public Parent {
  public:
};

Child child1;       // ошибка, нет конструктора по умолчанию
Child child2(1);    // ошибка, конструктор не наследуется
Child child3(1, 2); // ошибка, конструктор не наследуется

При "расширении" другого класса нужно либо вручную переносить конструкторы и вызывать их в списке инициализации, либо использовать using базовый_класс::базовый_класс:

class Parent {
  public:
    Parent(int a) {}
    Parent(int a, int b) {}
};

// перенос конструкторов вручную с параметрами и вызов при инициализации
class Child1 : public Parent {
  public:
    Child1(int a) : Parent(a) {}
    Child1(int a, int b) : Parent(a, b) {}
};

// ошибок нет
Child1 child1(1);
Child1 child2(1, 2);

// "копирование" ВСЕХ конструкторов базового класса
class Child2 : public Parent {
  public:
    using Parent::Parent;
};

// ошибок нет
Child2 child3(1);
Child2 child4(1, 2);

А если конструкторы не переносятся, то где они вызываются? При инициализации объекта:

class Parent {
  public:
    Parent() {}
};

class Child : public Parent {
  public:
    Child() {}  // здесь вызывается конструктор Parent

    // точнее автоматически вот здесь, даже если не указан вручную
    //Child() : Parent() {}
};

Конструкторы вызываются друг за другом по иерархии, начиная с базового класса и заканчивая последним производным. Деструкторы вызываются в обратном порядке

Конфликт имён #

Если класс наследует класс, в котором есть метод с таким же именем, то он его "перекроет". Для обращения к конкретному методу нужно использовать имя_класса:::

class Parent {
  public:
    void test() {}      // #1
};

class Child : public Parent {
  public:
    void test() {}      // #2

    void foo() {
        test();             // вызовет #2
        Child::test();      // вызовет #2
        Parent::test();     // вызовет #1
    }
};

int main() {
    Child child;
    child.test();           // вызовет #2
    child.Child::test();    // вызовет #2
    child.Parent::test();   // вызовет #1
}

Таким образом можно всегда обратиться к конкретной сущности.

Множественное наследование #

Класс может наследовать несколько других классов с разными модификаторами наследования, имена классов перечисляются через запятую. Здесь тоже может возникнуть конфликт имён - неоднозначность, если наследуемые классы имеют одинаковые члены. Давайте сделаем два класса с одинаковым методом и пусть их наследует третий класс:

class Parent1 {
  public:
    void test() {}  // #1
};

class Parent2 {
  public:
    void test() {}  // #2
};

// повесточка - родитель 1 и родитель 2
class Child : public Parent1, public Parent2 {
  public:
    void foo() {
        //test();           // ошибка, неоднозначно
        Parent1::test();    // #1
        Parent2::test();    // #2
    }
};

int main() {
    Child child;
    //child.test();         // ошибка, неоднозначно
    child.Parent1::test();  // #1
    child.Parent2::test();  // #2
}

Если добавить в Child свой метод void test() - то неоднозначность пропадёт, как понятно из предыдущей главы.

Указатели и ссылки #

Производный класс может быть "преобразован" в свой базовый класс, по сути - можно обратиться к объекту производного класса, как к базовому:

class Parent {
  public:
    void test() {}
};

class Child : public Parent {
  public:
    void foo() {
        // this - указатель на объект Child
        Parent* ptr = this;     // указатель на "вложенный" объект Parent
        ptr->test();
    }
};

int main() {
    Child child;
    child.test();   // вызов из Child

    Parent* ptr = &child;
    ptr->test();    // вызов из Parent
}

Это приближает нас к следующему уровню абстракции - полиморфизм.

Полиморфизм #

Полиморфизм - с греческого "многообразие", в программировании по сути означает возможность функции обрабатывать разные типы данных как один и тот же тип данных. Другими словами, одна и та же функция может принимать объекты разных классов и работать с ними, как с одним и тем же классом. Например - запрашивать какие-то данные, вызывать его методы и так далее. Или же например сделать массив объектов разных классов, как одного класса.

Шаблонный #

Простой и "прямолинейный" вариант полиморфизма - шаблонный. Допустим, у нас есть два класса, которые имеют метод getValue() - получить какое то значение:

class Foo {
  public:
    int getValue() {
        return 123;
    }
};

class Bar {
  public:
    int getValue() {
        return 456;
    }
};

Нам нужна функция (или метод класса, если речь об ООП), которая сможет работать с объектами этих классов. Да, можно создать несколько перегруженных функций под каждый класс - но это уже будет не полиморфизм:

void func(Foo& foo) {
    foo.getValue();
}
void func(Bar& bar) {
    bar.getValue();
}

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

template <typename T>
void func(T& obj) {
    int v = obj.getValue();
}

int main() {
    Foo foo;
    Bar bar;

    func(foo);
    func(bar);
}

Такое часто используется на практике, но есть и более мощный и гибкий способ.

Виртуальный #

Это ещё более мощный инструмент, позволяющий базовому классу вызывать метод, реализованный в производном классе. Метод, который может быть переопределён в производном классе, называется виртуальным (virtual):

  • Делаем в базовом классе метод с модификатором virtual
  • Наследуем класс
  • В производном классе делаем метод с такой же сигнатурой
// базовый
class Parent {
  public:
    void foo1() {}          // #1
    virtual void foo2() {}  // #2

    void test1() {
        foo1();     // вызов #1
    }

    void test2() {
        foo2();     // вызов #4
    }
};

// производный
class Child {
  public:
    void foo1() {}      // #3
    void foo2() {}      // #4
};

// тест
int main() {
    Child child;
    child.foo1();   // тут всё просто - вызван #3
    child.foo2();   // аналогично - вызван #4

    child.test1();  // тут внутри вызван #1
    child.test2();  // а тут - #4 !!!

    Parent& parent = child;
    parent.foo1();  // вызван #1
    parent.foo2();  // вызван #4 !!!
}

Таким образом класс Parent, вызывая свои же методы, будет вызывать их из производного класса Child. Также мы можем обратиться к объекту Child, как к его родительскому классу Parent, но переопределённый метод будет вызываться из производного класса Child!

pure virtual #

Существует понятие "чисто" виртуальной функции (pure virtual) - при объявлении такой метод приравнивается к нулю: virtual тип(параметры) = 0. Такой метод также называется абстрактным (abstract). Класс, который содержит хотя бы один абстрактный метод, тоже считается абстрактным классом:

  • Нельзя создать экземпляр абстрактного класса, его можно только наследовать
  • При наследовании абстрактного класса все его абстрактные методы должны быть переопределены
  • Класс, который содержит только абстрактные методы, называется интерфейсом. Т.е. такой класс не содержит самостоятельных методов и сам по себе ничего не умеет делать, он является "каркасом" для наследников

Пример #

Рассмотрим пример с теми же классами, что были в главе про шаблоны. Но на этот раз сделаем ещё один класс, который будет для них обоих родительским, и в нём объявим виртуальную функцию int getValue():

// общий базовый класс-интерфейс
class Interface {
  public:
    virtual int getValue() = 0;
};

class Foo : public Interface {
  public:
    int getValue() {
        return 123;
    }
};

class Bar : public Interface {
  public:
    int getValue() {
        return 456;
    }
};

Теперь экземпляры классов Foo и Bar являются также экземплярами класса Interface и их можно передавать туда, где ожидается Interface:

int func(Interface& obj) {
    return obj.getValue();
}

int main() {
    Foo foo;
    Bar bar;

    func(foo);  // 123
    func(bar);  // 456
}

Вот оно! Теперь у нас есть одна сущность Interface, которая может вести себя по разному в зависимости от реализации где то в другом месте программы. Также это даёт возможность делать например массивы объектов этого общего типа (в данном примере - массив указателей). При вызове виртуальной функции будет вызываться переопределённая функция в дочернем классе:

int main() {
    Foo foo1, foo2;
    Bar bar;

    Interface* arr[] = {&foo1, &foo2, &bar};

    arr[0]->getValue();     // 123
    arr[1]->getValue();     // 123
    arr[2]->getValue();     // 456
}

Пример из Arduino #

Отличным примером использования полиморфизма является класс Print из библиотеки Ардуино - ссылка на исходник. Это печатный интерфейс, который умеет переводить данные любых стандартных типов в символы, по сути - виртуально печатать. Возьмём из него кусочек с печатью строк:

class Print {
  public:
    // вызывается с символом
    virtual void write(char c) = 0;

    // напечатать строку
    void print(const char* str) {
        while (*str) {
            write(*str);    // вызываем посимвольно
            str++;
        }
        write('\n');    // перенос строки
    }

    // можно сделать такие методы для int, float...
};

Здесь есть виртуальный метод write(char c), который вызывается с каждым новым символом, отправленным на печать. Если мы например пишем библиотеку для дисплея, которая должна уметь печатать любые данные, то достаточно просто наследовать класс Print, переопределить метод write и запрограммировать вывод символа на дисплей. Класс Print в данном случае просто конвертирует данные в символы и отправляет нам их по одному. Давайте сделаем на его основе класс, который будет отправлять строки в консоль (запускаемый пример):

#include <iostream>

// интерфейс
class Print {
  public:
    // вызывается с символом
    virtual void write(char c) = 0;

    // напечатать строку
    void print(const char* str) {
        while (*str) {
            write(*str);    // вызываем посимвольно
            str++;
        }
        write('\n');
    }
};

// наш класс
class Printer : public Print {
    void write(char c) {
        // вывод в консоль
        std::cout << c;
    }
};

// тест
int main() {
    Printer p;
    p.print("Hello!");
    p.print("kek");
}

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

Или например класс графического движка: в родительском классе делается виртуальный метод, который рисует один пиксель. На основе этого метода можно сделать например линию и прямоугольник:

class GFX {
  public:
    // установить пиксель
    virtual void setPixel(int x, int y) = 0;

    // вертикальная линия
    void lineV(int x, int y0, int y1) {
        for (; y0 <= y1; y0++) setPixel(x, y0);
    }

    // горизонтальная линия
    void lineH(int y, int x0, int x1) {
        for (; x0 <= x1; x0++) setPixel(x0, y);
    }

    // прямоугольник, задаются координаты углов по диагонали
    void rect(int x0, int y0, int x1, int y1) {
        lineH(y0, x0, x1);
        lineH(y1, x0, x1);
        lineV(x0, y0, y1);
        lineV(x1, y0, y1);
    }
};

Допустим мы пишем класс для дисплея. Вместо того, чтобы писать графику самостоятельно в классе - используем "движок", переопределив метод рисования пикселя под конкретную реализацию для дисплея. Теперь наш дисплей сможет выводить остальную графику на основе рисования одного пикселя!

class Display : public GFX {
  public:
    void setPixel(int x, int y) {
        // код для дисплея, который выводит пиксель по x, y
    }
};

Display disp;
disp.lineV(10, 4, 5);       // линия
disp.rect(1, 1, 40, 40);    // прямоугольник

В общем и целом такой подход:

  • Позволяет избегать дублирования кода - программа будет весить меньше
  • Сокращает время разработки - один раз написал некий "инструмент" и можно использовать его в разных проектах
  • Позволяет разрабатывать, дорабатывать и тестировать подобные модули независимо от основной программы
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх