Лабораторная работа 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 | корабль, пароход, парусник, корвет |
Для определения иерархии классов связать отношением наследования классы из списка по варианту. Из перечисленных классов необходимо выбрать наиболее общее понятие, которое будет являться базовым (абстрактным) классом.
Определить в классах все необходимые конструкторы и деструкторы. Не забыть в абстрактном классе определить виртуальный деструктор.
В конструкторах и деструкторах для абстрактного класса и производных классов предусмотреть вывод отладочных сообщений об их вызове в консоль.
Например:
std::cout << "A()" << std::endl; //при вызове конструктора
std::cout << "~A()" << std::endl; //при вызове деструктора
В абстрактном классе создать статическую переменную – динамический массив указателей на базовый класс для демонстрации работы полиморфизма.
В работе запрещено использование контейнеров STL (vector, list и др.).
В данной лабораторной контейнер реализуется вручную для изучения динамических массивов.
Контейнер реализуется в базовом классе только в учебных целях для демонстрации полиморфизма.
Компонентные данные класса специфицировать как protected.
В абстрактном классе объявить чисто виртуальную функцию show() (= 0), которая отвечает за отображение данных класса.
virtual void show() = 0;
В каждом производном классе необходимо реализовать данный метод с учётом специфики класса.
Если функция просто virtual:
- класс НЕ становится абстрактным;
- можно создавать его объекты.
Если функция = 0:
- класс становится абстрактным;
- объекты создавать нельзя;
- все наследники обязаны реализовать метод.
Определение классов, их реализацию, демонстрационную программу поместить в отдельные файлы. Данные файлы должны быть упакованы в отдельную статическую библиотеку.
Реализовать статические функции:
print() для вывода всех связанных с контейнером экземпляров производных классов с указанием индекса в контейнере.remove() для удаления конкретного указателя по заданному индексу из контейнера и для удаления динамического объекта по этому указателю.clear() для очистки контейнера и удаления всех динамически созданных объектов по указателям из контейнера.При отладке программы по отладочным сообщениям из деструкторов убедитесь в корректной работе виртуальных деструкторов. (в каждом деструкторе добавить строку
std::cout << "**** destructor\n";, где****имя класса)
Для демонстрации проделанной работы необходимо продемонстрировать:
print().remove().clear().Язык 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 — базовый класс, содержащий общие данные о человеке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 != ©) {
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.
// 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. Как вы уже поняли, мы не будем описывать все методы этого класса с нуля, а просто унаследуем его от класса 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() {}. Это необходимо для корректного удаления объектов производных классов через указатель на базовый класс.