5 - Динамическое связывание

Лабораторная работа 5 для студентов курса “Программирование на основе классов и шаблонов” 2 семестра кафедры ИУ5 МГТУ им Н.Э. Баумана.

Содержание

Цель работы

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

Начало работы

Зайдите в свою локальную директорию с репозиторием для выполнения лабораторных работ. Заберите ветку с соответствующей лабораторной работой из общего репозитория:

git pull upstream

или

git pull upstream lab_5

Переключитесь на ветку с текущей лабораторной работой:

git checkout lab_5

Свяжите ветку локального репозитория с вашим удаленным репозиторием:

git push --set-upstream origin lab_5

Задание

Реализовать классы, предусмотренные вариантом (вариант равен остатку от деления номера по списку на 15).

№ п/п Перечень классов
1 студент, преподаватель, персона, завкафедрой
2 служащий, персона, рабочий, инженер
3 рабочий, кадры, инженер, администрация
4 деталь, механизм, изделие, узел
5 организация, страховая компания, судостроительная компания, завод
6 журнал, книга, печатное издание, учебник
7 тест, экзамен, выпускной экзамен, испытание
8 место, область, город, мегаполис
9 игрушка, продукт, товар, молочный продукт
10 квитанция, накладная, документ, чек
11 автомобиль, поезд, транспортное средство, экспресс
12 двигатель, двигатель внутреннего сгорания, дизель, турбореактивный двигатель
13 республика, монархия, королевство, государство
14 млекопитающие, парнокопытные, птицы, животное
15 корабль, пароход, парусник, корвет

Указания к выполнению лабораторной работы

  1. Иерархия классов

Для определения иерархии классов связать отношением наследования классы из списка по варианту. Из перечисленных классов необходимо выбрать наиболее общее понятие, которое будет являться базовым (абстрактным) классом.

Определить в классах все необходимые конструкторы и деструкторы. Не забыть в абстрактном классе определить виртуальный деструктор.

В конструкторах и деструкторах для абстрактного класса и производных классов предусмотреть вывод отладочных сообщений об их вызове в консоль.

Например:

std::cout <<  "A()"  << std::endl;  //при вызове конструктора
std::cout <<  "~A()"  << std::endl;  //при вызове деструктора

В абстрактном классе создать статическую переменную ­– динамический массив указателей на базовый класс для демонстрации работы полиморфизма.

В работе запрещено использование контейнеров STL (vector, list и др.).
В данной лабораторной контейнер реализуется вручную для изучения динамических массивов.

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

Компонентные данные класса специфицировать как protected.

В абстрактном классе объявить чисто виртуальную функцию show() (= 0), которая отвечает за отображение данных класса.

virtual void show() = 0;

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

Если функция просто virtual:

Если функция = 0:

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

  1. Контейнер классов

Реализовать статические функции:

При отладке программы по отладочным сообщениям из деструкторов убедитесь в корректной работе виртуальных деструкторов. (в каждом деструкторе добавить строку std::cout << "**** destructor\n"; , где **** имя класса)

Для демонстрации проделанной работы необходимо продемонстрировать:

Теоретические сведения для выполнения лабораторной работы

Наследование

Язык C++ позволяет классу наследовать переменные-члены и методы одного или нескольких других классов. Новый класс называют производным классом. Класс, элементы которого наследуются производным классом, называется базовым классом. В свою очередь производный класс может служить базовым для другого класса.

Общий вид наследования:

class  Base {
    // ...
};
class  Derived : <ключ  доступа> Base {
    // ...
};

<Ключ доступа> может быть private, protected, public. Если ключ не указан, то по умолчанию он принимается private.

Наследование даст возможность заключить некоторое общее или схожее поведение различных объектов в одном базовом классе.

Наследование позволяет также изменить поведение существующего класса. Производный класс может переопределить некоторые методы базового, наследуя, тем не менее, основной объем свойств и атрибутов базового класса. С помощью наследования может быть создана иерархия классов, которые совместно используют код и интерфейсы.

Наследуемые компоненты базового класса становятся частью объекта производного класса.

В иерархии производный объект наследует разрешенные для наследования компоненты всех базовых объектов (public, protected).

Допускается множественное наследование - возможность для некоторого класса наследовать компоненты нескольких никак не связанных между собой базовых классов. В иерархии классов соглашение относительно доступности компонентов класса следующее:

private - член класса может использоваться только функциями-членами данного класса и функциями-friend своего класса. В производном классе он недоступен.

protected - то же, что и private, но дополнительно член класса с данным атрибутом доступа может использоваться функциями-членами и функциями-friend классов, производных от данного (при наследовании public и protected).

public - член класса может использоваться любой функцией, которая является членом данного или производного класса, а также к public - членам возможен доступ извне через имя объекта (при наследовании public и protected).

Следует иметь в виду, что объявление friend не является атрибутом доступа и не наследуется.

Наследование позволяет рассматривать целые иерархии классов и работать со всеми элементами одинаково, приводя их к базовому. Правила приведения следующие:

Ошибки приведения базового класса к наследуемому отслеживаются программистом.

Синтаксис определения производного класса:

class  имя_класса : <ключ доступа> список_базовых_классов {
    // список_компонентов_класса
};

В приведенном ниже примере показано определение наследования классов и доступа к членам базового класса через объект производного класса:

#include <iostream>

class  Base {
public:
    int n;
    int  count(){
        return ++n;
    }
};

class  Derived : public  Base {
public:
    int m;
    int  discount(){
        return --m;
    }
};

int  main(int, char**) {
    Derived d;
    d.n = 10;
    std::cout << d.count() << '\n';
    d.m = 20;
    std::cout << d.discount() << '\n';
    return 0;
}

Конструкторы и деструкторы производных классов

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

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

Уничтожаются объекты в обратном порядке: сначала производный, а потом базовый объект.

Таким образом, порядок уничтожения объекта противоположен по отношению к порядку его конструирования.


class  Base {
    int a, b;
public:
    Base(int x, int y) {
        a = x;
        b = y;
    }
};

class  Derived : public  Base {
    int sum;
public:
    Derived(int x, int y, int s) : Base(x, y) {
        sum = s;
    }
};

Наследование и оператор присваивания

Оператор присваивания не наследуется, так как при отсутствии этого оператора в производном классе компилятор генерирует для этого класса оператор присваивания по умолчанию. Поэтому нетривиальные операторы присваивания нужно переопределять в производных классах.

Пример создания иерархии классов, связанных наследованием

Иерархия классов

В рассматриваемом примере используется следующая структура наследования:

        Human
        /   \
   Student  Teacher

Все объекты могут рассматриваться как объекты типа Human.

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

Создание базового класса

Для решения этой задачи создадим базовый класс Human, который будет описывать модель человека. В нем будут храниться в переменной name имя, фамилия и отчество.

// Human.h

#include <cstring>
#include <iostream>

class  Human {
    char* name;
public:
    Human() {
        name = nullptr;
    }
    Human(const  char* n) {
        name = new  char\[strlen(n) + 1];
        std::strcpy(name, n);
    }
    Human(const  Human& copy) {
        name = new char[strlen(copy.name) + 1];
        std::strcpy(this->name, copy.name);
    }
    Human& operator=(const Human& copy) {
        if (this != &copy) {
            delete[] name;
            name = new char[strlen(copy.name) + 1];
            std::strcpy(this->name, copy.name);
        }
        return this;
    }

    ~Human() {
        delete[] name;
    }

    void print() {
        std::cout << name << '\n';
    }

    char* get_name() { // Получение  Ф.И.О. человека
        return  name;
    }
};

Наследование от базового класса

Теперь создайте новый класс Student, который будет наследником класса Human. В новом классе добавлены новое свойство scores - целочисленный массив оценок студента и метод getAverageScore() для вычисления среднего балла студента.

// Student.h
#include <iostream>
#include "Human.h"

namespace {
    const int kSizeScore = 5;
};
class Student : public Human {
    int scores[kSizeScore];
public:
    Student(const char* name, int* scores) : Human(name) {
    for (int i = 0; i < kSizeScore; ++i){
​           ​this->scores[i] = scores[i];
        }
    }

    double getAverageScore() {  // Получение среднего балла студента
        unsigned int sumScores = 0;
        double averageScore;
        for (int i = 0; i < kSizeScore; ++i) {
            sumScores += this->scores[i];
        }
        averageScore = (double) sumScores / kSizeScore;
        return averageScore;
    }
    void print() {
        Human::print();     // Вывод имени студента (используется унаследованный метод класса Human)
        std::cout << "Оценки: ";    // Вывод оценок студента
        for (int i = 0; i < kSizeScore; ++i){
            ​std::cout << scores[i] << ' ';
        }
    ​    std::cout << '\n';
        std::cout << "Средний балл:" << getAverageScore()<< '\n';// Вывод среднего балла студента
    }
};

Все публичные свойства и методы класса Human будут доступны в классе Student.

Конструктор базового класса

Для того чтобы выполнить конструктор родительского класса для объекта Student (в нашем случае — это заполнение поля name), используется следующий синтаксис:

// Конструктор класса Student

Student(<аргументы конструктора текущего класса>): Human(<инициализация конструктора родительского класса>) {

// инициализация конструктора текущего класса

}

В конструктор класса Human мы передаем фамилию, имя, отчество человека, которые сохраняются в экземпляре класса. Для класса Student нам необходимо задать еще и список оценок студента. Поэтому конструктор Student принимает все аргументы конструктора базового класса, а также дополнительные аргументы для расширения функционала:


Student(char* name, int* scores) : Human(name) {
 for (int i = 0; i < kSizeScore; ++i) {
    this->scores[i] = scores[i];
    }
}

Список оценок студента хранится в массиве.

Создание объекта класса Student

Реализуем пользовательский интерфейс для работы с классом Student.

// main.cpp
#include <iostream>
#include "Student.h"

int main() {
    int scores[kSizeScore] = {5, 4, 4, 5, 3};
    Student* stud = new Student("Петров Иван Алексеевич", scores);
    stud->print();
    delete stud;
    return 0;
}

В этом примере мы написали программу, которая создает объект класса Student, сохраняя в нем его имя, фамилию, отчество и список оценок. После инициализации объекта происходит вывод всех данных о студенте. Для этого для объекта вызывается метод print(), в котором:

вызывается метод print(), унаследованный от базового класса Human – выводится имя объекта;

выводятся оценки студента – вывод содержимого переменной-члена scores;

затем в методе вычисляется средний балл студента (вызов метода getAverageScore) и выводится на экран.

Результат выполнения main():

Петров Иван Алексеевич

Оценки: 5  4  4  5  3

Средний балл: 4.2

Создание класса-наследника Teacher

Нужно создать еще один класс, в котором будут храниться данные преподавателей. Дадим ему название — Teacher. Как вы уже поняли, мы не будем описывать все методы этого класса с нуля, а просто унаследуем его от класса Human. Тогда не нужно будет реализовывать хранение имени, фамилии и отчества преподавателя. Это уже есть в базовом классе Human.

// Teacher.h
#include <iostream>

#include "Human.h"

class Teacher : public Human {
    unsigned int workTime;
public:
    Teacher(const char* name, unsigned int workTime) : Human(name) {
        this->workTime = workTime;
    }

    unsigned int getWorkTime() {
        return this->workTime;
    }
    void print() {
                        // Вывод фамилии, имени, отчества преподавателя
                        //(используется унаследованный метод класса Human)
        Human::print();
                        // Вывод количества учебных часов преподавателя
        std::cout << "Количество часов: ";
        std::cout << workTime << "\n\n";
    }
};

У класса Teacher появилось новое свойство — количество учебных часов, отведенное преподавателю. Весь остальной функционал наследуется от базового класса Human. Если бы мы писали все с нуля, то одинакового кода получилось бы в разы больше, и его поддержка усложнилась бы на порядок.

Создание объекта класса Teacher

Изменим содержимое файла main.cpp, чтобы проверить работу класса Teacher.

// main.cpp
#include <iostream>
#include "Teacher.h"

int main() {

    unsigned int teacherWorkTime = 40;  // Количество учебных часов преподавателя
    Teacher* tch = new Teacher("Bacильков Петр Сергеевич", ​​​​teacherWorkTime);
    tch->print();
    delete tch;
    return 0;
}

Результат выполнения main():

Bacильков Петр Сергеевич
Количество часов: 40

Можно таким же образом создать класс, в котором будут храниться данные обслуживающего персонала или руководящего состава. Наследование используют, когда у каждой группы объектов есть общие параметры, но для каждой из этих групп нужно хранить более кастомные данные.

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

В класс Human можно добавить еще больше свойств, которые будут описывать данные, имеющиеся у любого человека. Например, номер паспорта, дату рождения, прописку и место проживания.

Подобный подход позволяет в разы уменьшить дублирование кода в реальных проектах и упросить его поддержку.

Виртуальные функции

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

Для этого функция базового класса должна быть объявлена с ключевым словом virtual.

Функция производного класса, имеющая такое же имя и сигнатуру, переопределяет (замещает) виртуальную функцию базового класса.

Такие функции называются виртуальными функциями.

Общий пример:

Пример вызова виртуальной функции производного класса через указатель на базовый класс:

#include <iostream>

class Base {
public:
    virtual int what() {
        return 10;
    }
};

class Derived : public Base {
public:
    int what() override {
        return 20;
    }
};

int main() {
    Derived d;
    Base* b = &d;
    std::cout << b->what() << '\n';  // печатает 20
    Base& c = d;
    std::cout << c.what() << '\n';  // печатает 20
    return 0;
}

Тип указателя Base* определяет доступные методы, но фактический тип объекта Derived определяет, какая именно реализация функции будет вызвана.

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

20
20

Несмотря на то, что переменная b имеет тип Base*, фактически она указывает на объект класса Derived. Поэтому вызывается функция Derived::what().

Этот механизм называется динамическим связыванием.

Спецификатор override

В современном C++ рекомендуется использовать спецификатор override при переопределении виртуальных функций.

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

Пример:

class Derived : public Base {
public:
    int what() override {
        return 20;
    }
};

Если сигнатура функции будет указана неверно, компилятор выдаст ошибку. Это позволяет избежать распространённых ошибок при переопределении функций.

Явный вызов функции базового класса

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

Это можно сделать с помощью указания имени класса:

#include <iostream>

class Base {
public:
    virtual int what() {
        return 10;
    }
};

class Derived : public Base {
public:
    int what() override {
        return 20;
    }
};

int main() {
    Base* b = new Derived;

    std::cout << b->Base::what() << '\n';  // вызов функции базового класса

    delete b;
    return 0;
}

Результат:

10

Виртуальные деструкторы

Если объект производного класса удаляется через указатель на базовый класс, деструктор базового класса должен быть виртуальным.

Пример:

#include <iostream>

class Base {
public:
    virtual ~Base() {
        std::cout << "Base destructor\n";
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived destructor\n";
    }
};

int main() {
    Base* obj = new Derived;
    delete obj;

    return 0;
}

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

Derived destructor
Base destructor

Сначала вызывается деструктор производного класса, затем — базового класса.

Ошибка при отсутствии виртуального деструктора

Если деструктор базового класса не объявлен виртуальным, то при удалении объекта через указатель на базовый класс будет вызван только деструктор базового класса.

Пример:

class Base {
public:
    ~Base() {
        std::cout << "Base destructor\n";
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "Derived destructor\n";
    }
};

int main() {
    Base* obj = new Derived;
    delete obj;
}

Вывод программы:

Base destructor

Деструктор Derived вызван не будет, что может привести к утечкам памяти и некорректному освобождению ресурсов.

Поэтому в базовых классах, предназначенных для наследования, деструктор рекомендуется объявлять виртуальным.

Абстрактные классы

В языке C++ иногда требуется описать общий интерфейс для группы классов, но при этом не создавать объекты самого базового класса.

Для этого используются абстрактные классы.

Абстрактный класс — это класс, который содержит хотя бы одну чисто виртуальную функцию.

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

Чисто виртуальная функция объявляется следующим образом:

virtual <тип имя_функции(параметры)>  = 0;

Например:

class Abstract {
public:
    virtual int what() = 0;
};

Запись = 0 означает, что функция является чисто виртуальной.

Особенности абстрактных классов

Абстрактные классы имеют следующие свойства:

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

Пример использования абстрактного класса

Рассмотрим пример.

#include <iostream>

class Abstract {
public:
    virtual ~Abstract() {}

    virtual int what() = 0;
};

class Concrete : public Abstract {
public:
    int what() override {
        return 10;
    }
};

Класс Abstract является абстрактным, так как содержит чисто виртуальную функцию what().

Создавать объекты этого класса нельзя:

Abstract a;   // ошибка компиляции

Однако можно создавать указатели или ссылки на абстрактный класс:

Abstract* ptr;

Работа с объектами производных классов

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

Пример:

#include <iostream>

class Abstract {
public:
    virtual ~Abstract() {}
    virtual int what() = 0;
};

class Concrete : public Abstract {
public:
    int what() override {
        return 10;
    }
};

int foo(Abstract& a) {
    return a.what();
}

int main() {
    Concrete c;

    std::cout << foo(c) << '\n';

    return 0;
}

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

10

Функция foo() принимает ссылку на абстрактный класс, но в неё можно передать объект любого производного класса.

Благодаря механизму динамического связывания будет вызвана соответствующая реализация функции what().

Использование абстрактных классов

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

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

Human
 ├ Student
 └ Teacher

Базовый класс может содержать общие свойства и методы, а также объявлять виртуальные функции, которые должны реализовываться в производных классах.

Такой подход позволяет:

Важное замечание

В абстрактных классах рекомендуется объявлять виртуальный деструктор:
virtual ~Abstract() {}. Это необходимо для корректного удаления объектов производных классов через указатель на базовый класс.