image Привет, Хаброжители!

Книга Герберта Шилдта начиная с таких базовых понятий языка, как типы данных, массивы, строки, указатели и функции, книга охватывает также важнейшие элементы объектно-ориентированного программирования — классы и объекты, наследование, виртуальные функции, потоки ввода-вывода, исключения и шаблоны. Каждый раздел сопровождается простыми и наглядными примерами, позволяющими получить практические навыки программирования. Книга предназначена для приступающих к изучению языка С++ — одного из самых универсальных и распространенных на сегодня языков программирования.

Виртуальные функции и полиморфизм


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

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


Виртуальная функция — это функция, которая объявляется в базовом классе с использованием ключевого слова virtual и переопределяется в одном или нескольких производных классах. Таким образом, каждый производный класс может иметь свою версию виртуальной функции. Интересно рассмотреть ситуацию, когда виртуальная функция вызывается через указатель (или ссылку) на базовый класс. В этом случае C++ определяет, какую именно версию виртуальной функции нужно вызвать, по типу объекта, адресуемого этим указателем. Важно, что это решение принимается во время выполнения программы. Следовательно, при указании на разные объекты будут вызываться различные версии виртуальной функции. Иными словами, именно по типу адресуемого объекта (а не по типу самого указателя) определяется, какая версия виртуальной функции будет выполнена. Таким образом, если базовый класс содержит виртуальную функцию и если из него выведено два (или более) других класса, то при адресации различных типов объектов через указатель на базовый класс будут выполняться разные версии этой виртуальной функции. Аналогичный механизм работает и при использовании ссылки на базовый класс.

Функция объявляется виртуальной в базовом классе с помощью ключевого слова virtual. Когда виртуальная функция переопределяется в производном классе, это слово повторять не нужно (хотя это не будет ошибкой).

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

Рассмотрим короткую программу. В ней демонстрируется использование виртуальной функции:

// Пример использования виртуальной функции
image
Эта программа генерирует следующие результаты:

Base
First derivation
Second derivation

Рассмотрим код этой программы подробно, чтобы понять, как она работает.

В классе B функция who() объявлена виртуальной. Это означает, что ее можно переопределить в производном классе. И она действительно переопределяется в обоих производных классах, D1 и D2. В функции main() объявляются четыре переменные: base_obj (объект типа B), p (указатель на объект класса B), а также два объекта D1_obj и D2_obj двух производных классов D1 и D2 соответственно. После этого указателю p присваивается адрес объекта base_obj и вызывается who(). Поскольку функция who() объявлена виртуальной, C++ во время выполнения определяет, к какой именно ее версии здесь нужно обратиться. Решение принимается путем анализа типа объекта, адресуемого указателем p. В данном случае p указывает на объект типа В, поэтому сначала выполняется та версия функции who(), которая объявлена в классе B. Далее указателю p присваивается адрес объекта D1_obj. Вспомните, что с помощью указателя на базовый класс можно обращаться к объекту любого его производного класса. Поэтому когда who() вызывается во второй раз, C++ снова выясняет тип объекта, адресуемого указателем p. Затем, исходя из этого типа, он определяет, какую версию функции who() нужно вызвать. Поскольку p здесь указывает на объект типа D1, то выполняется версия who(), определенная в классе D1. Аналогично после присвоения p адреса объекта D2_obj вызывается версия who(), объявленная в классе D2.

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

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

D1_obj.who();

Однако при таком вызове виртуальной функции игнорируются ее полиморфные атрибуты. И только при обращении к ней через указатель на базовый класс достигается динамический полиморфизм.

На первый взгляд может показаться, что переопределение виртуальной функции в производном классе представляет собой специальную форму перегрузки функций, но на самом деле это два принципиально разных процесса. Прежде всего, тип и/или количество параметров у версий перегруженной функции должны различаться, в то время как у версий переопределенной виртуальной функции они должны в точности совпадать. Фактически прототипы виртуальной функции и ее переопределений должны быть абсолютно одинаковыми. Если они будут различаться, то такая функция будет попросту считаться перегруженной, и ее «виртуальная сущность» утратится. Помимо этого, виртуальная функция должна быть членом класса, для которого она определяется, а не его «другом». Но в то же время она может быть дружественна иному классу. И еще: функциям деструкторов разрешается быть виртуальными, а функциям конструкторов — нет.

Наследование виртуальных функций


Если функция объявляется как виртуальная, она остается такой вне зависимости от того, через сколько уровней производных классов она может пройти. К примеру, если бы класс D2 был выведен из класса D1, а не из класса B, как показано в следующем примере, то функция who()оставалась бы виртуальной и механизм выбора соответствующей версии по-прежнему работал бы корректно:

Этот класс выведен из класса D1, а не из В
class D2 : public D1 {
public:
     void who() { // Переопределение функции who() для класса D2
         cout << "Second derivation\n";
     }
};

Если производный класс не переопределяет виртуальную функцию, то используется функция, определенная в базовом классе. Давайте проверим, как поведет себя версия предыдущей программы, если в классе D2 не будет переопределена функция who():

#include <iostream>
using namespace std;

class B {
public:
     virtual void who() {
         cout << "Base\n";
     }
};

class D1 : public B {
public:
     void who() {
         cout << "First derivation\n";
     }
};


class D2 : public B {
// Функция who() не определена  ← Класс D2 не переопределяет функцию who()
};


int main()
{
    B base_obj;
    B *p;
    D1 D1_obj;
    D2 D2_obj;

    p = &base_obj;
    p->who(); // Обращаемся к функции who() класса B

    p = &D1_obj;
    p->who(); // Обращаемся к функции who() класса D1

    p = &D2_obj;
    p->who(); /* Обращаемся к функции who()  ← Вызываем функцию who() класса B
                        класса B, поскольку в классе D2 она не переопределена */
return 0;
}

При выполнении эта программа генерирует следующие результаты:

Base
First derivation
Base

Поскольку функция who() не переопределена классом D2, при ее вызове выполняется та версия функции who(), которая определена в классе B.

Важно помнить, что наследуемые свойства модификатора virtual иерархичны. Если предыдущий пример изменить так, чтобы класс D2 был выведен из класса D1, а не из класса B, то при обращении к функции who() через объект типа D2 будет вызвана та ее версия, которая объявлена в классе D1, поскольку он является «ближайшим» (по иерархическим «меркам») к классу D2 (а не версия из класса B).

Зачем нужны виртуальные функции


Как отмечалось ранее, виртуальные функции в сочетании с производными типами позволяют C++ поддерживать динамический полиморфизм. Он важен для ООП, поскольку обеспечивает возможность некоторому шаблонному классу определять функции, которые будут использовать все производные от него классы, причем производный класс может определить собственную реализацию некоторых или всех этих функций. Иногда эта идея выражается так: базовый класс диктует общий интерфейс для любого выведенного из него объекта, но при этом позволяет производному классу определить метод, используемый для реализации этого интерфейса. Вот почему для описания полиморфизма часто используется фраза «один интерфейс — множество методов».

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

Вы можете задаться вопросом: почему же так важен общий интерфейс со множеством реализаций? Ответ снова возвращает нас к главной причине возникновения ООП: такой интерфейс позволяет нам справляться с возрастающей сложностью программ. К примеру, при корректной разработке программы мы будем знать, что ко всем объектам, выведенным из базового класса, можно получить доступ единым (общим для всех) способом, даже если конкретные действия у производных классов могут различаться. Это означает, что нам придется иметь дело только с одним интерфейсом, а не с несколькими. При этом производный класс волен использовать любые (или все) функции, предоставленные базовым. То есть разработчику производного класса не нужно заново изобретать элементы, уже имеющиеся в базовом классе.

Отделение интерфейса от реализации позволяет создавать библиотеки классов (и это могут делать сторонние организации). Грамотно реализованные библиотеки должны предоставлять общий интерфейс, который программист может использовать для выведения классов в соответствии со своими потребностями. Например, как библиотека классов Microsoft Foundation Classes (MFC), так и более новая .NET Framework Windows Forms поддерживают разработку приложений для Microsoft Windows. Применение этих классов позволяет писать программы, которые могут унаследовать множество функций, нужных любой программе на Windows. Следует лишь добавить в нее средства, уникальные для вашего приложения. Это особенно полезно при программировании сложных систем.

Применение виртуальных функций


Чтобы лучше понять преимущества виртуальных функций, применим их к классу TwoDShape. В предыдущих примерах каждый класс, выведенный из TwoDShape, определяет функцию с именем area(). Это указывает на то, что лучше сделать функцию вычисления площади фигуры area() виртуальной в классе TwoDShape. Благодаря этому мы сможем переопределить ее в производных классах так, чтобы она вычисляла площадь в соответствии с типом конкретной геометрической фигуры, которую инкапсулирует данный класс. Реализация этой идеи представлена в следующей программе. Для удобства в класс TwoDShape вводится поле name, которое упрощает демонстрацию этих классов:

// Использование виртуальных функций и механизма полиморфизма
#include <iostream>
#include <cstring>
using namespace std;

// Класс определения двумерных объектов
class TwoDShape {
    // Приватные члены класса
    double width;
    double height;

    // Добавляем поле name
    char name[20];
public:

    // Конструктор по умолчанию
    TwoDShape() {
          width = height = 0.0;
          strcpy(name, "unknown");
    }

    // Конструктор класса TwoDShape
    TwoDShape(double w, double h, char *n) {
          width = w;
          height = h;
          strcpy(name, n);
    }

    // Конструктор объекта, у которого ширина равна высоте
    TwoDShape(double x, char *n) {
         width = height = x;
         strcpy(name, n);
    }

    void showDim() {
        cout << "Width and height are " <<
                      width << " and " << height << "\n";
}

    // функции доступа
    double getWidth() { return width; }
    double getHeight() { return height; }
    void setWidth(double w) { width = w; }
    void setHeight(double h) { height = h; }
    char *getName() { return name; }

    // Добавляем в класс TwoDShape функцию area() и делаем ее виртуальной
    virtual double area() {   ← Теперь area() — виртуальная функция
        cout << "Error: area() must be overridden.\n";

        return 0.0;
    }
};

// Класс Triangle выводится из класса TwoDShape
class Triangle : public TwoDShape {
    char style[20]; // Теперь это приватный член
public:

    /* Конструктор по умолчанию. Он автоматически
        вызывает конструктор по умолчанию класса TwoDShape */
    Triangle() {
        strcpy(style, "unknown");
    }

    // Конструктор с тремя параметрами
    Triangle(char *str, double w,
                double h) : TwoDShape(w, h, "triangle") {
        strcpy(style, str);
    }

    // Конструктор равнобедренного треугольника
    Triangle(double x) : TwoDShape(x, "triangle") {
    strcpy(style, "isosceles");
    }

    // Переопределение функции area(), объявленной в классе TwoDShape, в Triangle
double area() {    ← Переопределение функции area() в классе Triangle

     return getWidth() * getHeight() / 2;
    }

    void showStyle() {
        cout << "Triangle is " << style << "\n";
    }
};

// Класс Rectangle - производный от класса TwoDShape
class Rectangle : public TwoDShape {
public:

    // Конструктор прямоугольника
    Rectangle(double w, double h) :
         TwoDShape(w, h, "rectangle") { }

    // Конструктор квадрата
    Rectangle(double x) :
         TwoDShape(x, "rectangle") { }

    bool isSquare() {
         if(getWidth() == getHeight()) return true;
         return false;
    }

    // Еще одно переопределение функции area()
    double area() {   ←Переопределение функции area() в классе Rectangle
    return getWidth() * getHeight();
    }
};

int main() {
    // Объявляем массив указателей на объекты типа TwoDShape
    TwoDShape *shapes[5];

    shapes[0] = &Triangle("right", 8.0, 12.0);
    shapes[1] = &Rectangle(10);
    shapes[2] = &Rectangle(10, 4);
    shapes[3] = &Triangle(7.0);
    shapes[4] = &TwoDShape(10, 20, "generic");

    for(int i=0; i < 5; i++) {
        cout << "object is " <<
                     shapes[i]->getName() << "\n";  ←Для каждого объекта теперь вызывается нужная 
                                                                         версия функции area()
      
        cout << "\n";
    } 

    return 0;
}


Результаты выполнения этой программы таковы:

object is triangle
Area is 48

object is rectangle
Area is 100

object is rectangle
Area is 40

object is triangle
Area is 24.5

object is generic
Error: area() must be overridden.
Area is 0

Изучим программу подробнее. Во-первых, area() объявляется в классе TwoDShape с использованием ключевого слова virtual и переопределяется в классах Triangle и Rectangle. В классе TwoDShape area() представляет собой своего рода «заглушку», которая просто информирует пользователя о том, что в производном классе эту функцию нужно переопределить. Каждое ее переопределение реализует вариант вычисления площади, соответствующий типу объекта, который инкапсулирован производным классом. Получается, что если бы вы реализовали класс эллипсов, то функция area() в этом классе вычисляла бы площадь эллипса.

В предыдущей программе проиллюстрирован еще один важный момент: в функции main() переменная shapes объявляется как массив указателей на объекты типа TwoDShape, однако элементам этого массива присваиваются указатели на объекты классов Triangle, Rectangle и TwoDShape. Это вполне допустимо, ведь указатель на базовый класс может ссылаться на объект производного класса. Далее программа в цикле опрашивает массив shapes и выводит информацию о каждом объекте. Несмотря на простоту, этот цикл демонстрирует преимущества наследования и виртуальных функций. Конкретный тип объекта, адресуемый указателем на базовый класс, определяется во время выполнения программы. Это позволяет принять необходимые меры, то есть выполнить действия, соответствующие объекту данного типа. Если объект выведен из класса TwoDShape, его площадь можно узнать посредством вызова функции area(). Интерфейс для выполнения этой операции одинаков для всех производных (от TwoDShape) классов, вне зависимости от конкретного типа используемой фигуры.

Об авторе


Герберт Шилдт (Herbert Schildt) — признанный авторитет в области программирования на языках С, C++, Java и С#, профессиональный Windows-программист, член комитетов ANSI/ISO, принимавших стандарт для языков С и C++. Продано свыше трех миллионов экземпляров его книг, переведенных на все основные языки мира. Шилдт — автор многих бестселлеров по С, C++, C# и Java. Список основных изданий см. во введении в разделе «Дальнейшее изучение программирования». Герберт Шилдт — обладатель степени магистра в области computer science Иллинойсского университета.

» Оглавление
» Отрывок

Электронных прав на книгу нет.
Для Хаброжителей скидка 25% по купону — Питер

p/s идет Черная пятница в издательстве «Питер»

Комментарии (5)


  1. 0Bannon
    28.11.2023 00:08
    +5

    Можно уже что-нибудь для заканчивающих.


  1. voldemar_d
    28.11.2023 00:08

    Книга Герберта Шилдта начиная с таких базовых понятий языка, как типы данных, массивы, строки, указатели и функции, книга охватывает также важнейшие элементы

    Зачем "книга" два раза? Также, перед "начиная" запятой не хватает.


  1. NeoCode
    28.11.2023 00:08

    Для начинающих книг полно. А для тех кто уже давно в теме что - "курите стандарт"? :) Вот правда, хотелось бы хоть одну книгу по самым современным новшествам С++ типа constexpr/consteval/constinit, по зубо-мозго-дробительнейшим шаблонам, по наиболее сложным библиотекам Boost и всякому такому...


    1. Yuri0128
      28.11.2023 00:08

      хотелось бы хоть одну книгу по самым современным новшествам С++

      остается одно решение: написать самому. Напишите, - куча народу будет рада. И я в той куче :)...


    1. KanuTaH
      28.11.2023 00:08

      Нечто типа обзора по новшествам и их рекомендуемому применению есть в C++ Core Guidelines, он как раз и предназначен для тех, кто "в теме", но хочет быть в курсе новинок. Про Boost там правда нет.