Лабораторна робота 4

Використання поліморфізму та шаблонів

1 Завдання на лабораторну роботу

1.1 Ієрархія класів

Реалізувати класи "Людина", "Громадянин", "Студент", "Співробітник". В кожному класі визначити віртуальну функцію виведення даних про об'єкт на екран. Створити масив указівників на різні об'єкти ієрархії. В циклі для кожного об'єкта вивести на екран рядок даних про нього.

1.2 Використання поліморфізму для зворотного виклику

Створити клас для розв'язання завдання 1.2 першої лабораторної роботи. Клас повинен містити принаймні два методи – функцію, яка повертає значення відповідно до індивідуального завдання, а також суто віртуальну функцію, яка викликається з попередньої та визначає ліву частину рівняння або функцію для дослідження (відповідно до завдання).

Клас розташувати в окремому заголовному файлі. У відповідному файлі реалізації здійснити визначення однієї з двох функцій класу.

В іншій одиниці трансляції створити похідний клас із визначенням конкретної функції, яка підлягає дослідженню. У функції main() створити об'єкт похідного класу та здійснити виконання індивідуального завдання.

Примітка: Для обчислення першої (другої) похідної слід додати окремі функції-члени базового класу.

1.3 Використання шаблонів для зворотного виклику

В окремому заголовному файлі створити шаблонну функцію для розв'язання завдання 1.2 першої лабораторної роботи. Першим параметром функції повинен бути об'єкт шаблонного типу, до якого можна застосувати операцію "круглі дужки".

Роботу шаблону перевірити на двох функціях, які підлягають дослідженню. Одна з функцій повинна бути реалізована як функціональний об'єкт.

Примітка: Для обчислення першої (другої) похідної слід додати окремі шаблонні функції.

1.4 Узагальнений клас для представлення двовимірного масиву

Переробити клас, створений у завданні 1.4 попередньої лабораторної роботи, на шаблон класу. Реалізувати зовнішню узагальнену функцію знаходження мінімального елемента масиву. В функції main() створити масиви цілих, дійсних і простих дробів (раніше створений клас). Для цих трьох масивів здійснити перевірку роботи функції знаходження мінімального значення серед елементів масиву, здійснити тестування всіх можливостей класу з перехопленням можливих винятків, а також розв'язати індивідуальну задачу.

Примітка: для того, щоб можна було знаходити мінімальне значення у масиві дробів, у класі "Простий дріб" необхідно перевантажити операції порівняння.

1.5 Бібліотека шаблонних функцій для роботи з масивом (додаткове завдання)

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

  • обмін місцями елементів зі вказаними індексами;
  • пошук елемента з певним значенням;
  • обмін місцями усіх пар сусідніх елементів (з парним і непарним індексом).

Здійснити демонстрацію роботи усіх функцій з використанням даних як мінімум трьох різних типів.

2 Методичні вказівки

2.1 Використання UML для подання класів

У лабораторній роботі № 1 попереднього семестру було розглянуто діаграми діяльності Уніфікованої мови моделювання (Unified Modeling Language, UML). Метою створення UML від початку було саме графічне подання класів. Діаграма класів (Class diagram) відображає статичну структуру системи у термінах класів та відношень між цими класами.

Графічним зображенням класу є прямокутник, розділений на три частини. Перша частина – це ім'я класу. Друга частина – список так званих атрибутів, третя частина – список операцій.

Атрибути визначають дані, які описують екземпляр класу та визначають його стан. У об'єктно-орієнтованому програмуванні атрибути відображаються у поля, або елементи даних. Повний формат атрибута має такий вигляд:

видимість ім'я : тип = усталене_значення

Видимість може бути такою:

- закритий (приватний)
+ відкритий (публічний)
# захищений

Можна використовувати скорочений формат – без видимості, без типу, без початкового значення, або взагалі тільки ім'я. Атрибут – масив визначається квадратними дужками [ ] біля імені.

Операції визначають дії, які можна застосувати до класу або його об'єкту. Операція описується рівнем доступу (видимості), списком параметрів та типом результату:

видимість ім'я(список_параметрів) : тип

Параметри у списку відокремлюють один від одного комами. Кожний параметр має такий формат:

ім'я : тип = усталене_значення

Можна опустити усі частини специфікації операції, за винятком імені та круглих дужок.

UML 2 дозволяє у випадку необхідності додавати четверту частину до зображення класу. У цій частині розташовують піктограми вкладених класів.

Асоціації – це найбільш загальний тип зв'язків між класами. За допомогою асоціацій зв'язують класи, об'єкти яких створюються та існують незалежно. Асоціація може бути спрямованою (один покажчик або посилання) або неспрямованою (екземпляри обох класів обізнані про екземпляри протилежного класу). Відношення типу "множинність" (multiplicity) показує кількість можливих зв'язків між екземплярами класів. Явне зазначення множинності можна опускати.

У C++ асоціація виникає, коли в одному з класів описано вказівник (посилання) на об'єкт іншого класу, який було створено десь незалежно. Наявність вказівника дозволяє викликати функції-елементи, або звертатися до елементів даних (якщо класи дружні):

class ClassB
{
    . . .
};

class ClassA
{
    . . .
    ClassB* pB;
    . . .
};

Неспрямована асоціація передбачає взаємну обізнаність. Для того, щоб реалізувати взаємне розташування вказівників, необхідно один з класів попередньо оголосити (без визначення):

class ClassA;

class ClassB
{
    . . .
    ClassA* pA;
    . . .
};

class ClassA
{
    . . .
    ClassB* pB;
    . . .
};

Агрегація – це спеціальна форма асоціації, яка показує, що сутність (екземпляр) міститься в іншій сутності або не може бути створена та існувати без обхопної сутності.

У C++ агрегація реалізується через визначення вказівника на вкладену сутність. Об'єкт, на який вказує цей вказівник, створюється за необхідності. Належна практика – перевірити, чи був створений об'єкт і видалити його з пам'яті у деструкторі:

class ClassPart
{
    . . .
};

class ClassWhole
{
private:
    ClassPart* part = nullptr;
    . . .
public:
    void someFunc()
    {
        part = new ClassPart();
        . . .
    }
    . . .
    ~ClassWhole()
    {
        if (part != nullptr)
        {
            delete part;
        }
    }
};

Іноді використовується більш жорстка форма – композиція, яка передбачає що сутність повністю міститься в іншій сутності. Вони створюються і видаляються разом.

У C++ композиція реалізується через визначення елементу даних типу вкладеної сутності. Композиція була розглянута у попередній лабораторній роботі.

Агрегація та композиція дозволяють вказувати множинність.

Відношення типу "залежність" на діаграмі класів вказує, що залежний клас користується функціональністю іншого класу.

На рівні мови програмування це може бути реалізовано через виклик статичних функцій іншого класу або створення тимчасового об'єкта. У C++ це може мати такий вигляд:

class Supplier
{
    . . .
public:
    static void f() { }
    void g() { }
    . . .
};

class Client
{
    . . .
public:
    void h() 
    { 
        Supplier::f();
        Supplier supplier;
        supplier.g();
        . . .
    }
    . . .
};

Для написів на діаграмах класів можна використовувати будь-яку мову, але в тих випадках, коли діаграма класів використовується для генерації вихідного коду, слід вживати латинські літери та переважно англійську мнемоніку.

2.2 Успадкування

Успадкування (inheritance) застосовують для створення класу, похідного від певного базового класу. Похідні класи успадковують елементи базових класів, а також можуть додавати нові елементи.

Синтаксично успадкування визначається списком базових класів після імені класу і двокрапки. Перед іменем базового класу вказується тип успадкування (public або інше):

class PublicTransport // Базовий клас
{
    . . .
};

class Tram : public PublicTransport // Похідний клас
{
    . . .
};

Мова С++ підтримує одиничне та множинне успадкування. Найбільш розповсюдженим є одиничне успадкування, коли клас походить від одного базового класу. У випадку використання множинного успадкування клас може походити від кількох базових класів. Наприклад,

class PublicTransport // Базовий клас
{
    . . .
};

class RailTransport // Базовий клас
{
    . . .
};

class Tram : public PublicTransport, public RailTransport // Похідний клас
{
    . . .
};

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

У С++ прийняті такі типи успадкування:

  • відкритий базовий клас (public): відкриті елементи базового класу залишаються відкритими у похідному класі, захищені елементи базового класу залишаються захищеними у похідному класі, закриті елементи базового класу є закритими у похідному класі;
  • захищений базовий клас (protected): відкриті та захищені елементи базового класу є захищеними у похідному класі, закриті елементи базового класу є закритими у похідному класі;
  • закритий базовий клас (private): відкриті та захищені елементи базового класу є закритими у похідному класі, закриті елементи базового класу є закритими у похідному класі; цей спосіб є прийнятим усталено.

Похідні класи успадковують усі елементи базового класу, окрім конструкторів, деструкторів та перевантаженої операції присвоєння.

Конструктори базових класів виконуються до виконання конструкторів об'єктів які є елементами даних похідного класу, а також конструктора похідного класу. Деструктори виконуються у зворотному порядку. Якщо конструктори базових класів потребують передачі аргументів, це здійснюється за допомогою списку ініціалізації:

class File
{
public:
    File(const char name) { ... }
    . . .
};

class TextFile : public File
{
public:
    TextFile(const char name) : File(name) { ... }
    . . .
};

У версії C++11 додано специфікатор final, який в контексті опису класів вказує, що клас не може бути використаний як базовий:

class CompletedEntity final 
{
};

class DerivedFromFinal : public CompletedEntity // Синтаксична помилка!
{
};

Компілятор згенерує повідомлення про помилку "Error C3246 'DerivedFromFinal': cannot inherit from 'CompletedEntity' as it has been declared as 'final'".

Для моделювання спадкування в UML використовують узагальнення (generalization). Більш загальний клас є базовим. Стрілка починається з похідного класу.

Значення вказівників на похідні класи можна присвоювати змінним типу вказівників на базовий клас. Аналогічне правило є для посилань:

class Base
{
public:
    someCommonFunc() { }
    . . .
};

class FirstDerived : public Base
{
    . . .
};

class SecondDerived: public Base
{
    . . .
};

Base *pBase = new FirstDerived();
SecondDerived secondDerived;
Base &refBase = secondDerived;

Для інших перетворень типів указівників та посилань на об'єкти однієї ієрархії вживають спеціальний оператор dynamic_cast<тип>(вираз).

Тепер можна створити масив указівників на базовий клас та здійснити ініціалізацію елементів указівниками на похідні класи. Для всіх створених об'єктів можна викликати якусь спільну функцію:

Base *arr[3];
arr[0] = new Base();
arr[1] = new FirstDerived();
arr[2] = new SecondDerived();
for (int i = 0; i < 3; i++)
{
    arr[i]->someCommonFunc();
}

У визначенні класів можна використовувати using-оголошення. Оголошення using дозволяють створювати набір перевантажених функцій з базових і похідних класів; using-оголошення у визначенні класу має відношення до членів базового класу. using-оголошення не може бути використано для отримання доступу до закритої інформації базового класу.

Оголошення функції-елемента в похідному класі не перевантажує, а приховує однойменні функції-елементи в базовому, навіть якщо їх списки параметрів різні. Для того, щоб зробити однойменні функції перевантаженими, може використовуватися using-оголошення:

class Base
{
public:
    void setValue(int);
};

class Descendant : private Base
{
public:
    using Base::setValue;
    void setValue(string);
};

int main()
{
    Descendant d;
    d.setValue("a");
    d.setValue(2); // Без using-оголошення була б помилка 
    return 0;
}

2.3 Множинне успадкування

Множинне успадкування (multiple inheritance) застосовується в тому випадку, коли об'єкт являє собою поняття, що поєднує в собі кілька загальних понять, кожне з яких може бути представлено базовим класом.

Можливість мати більше одного базового класу тягне за собою можливість неодноразового входження класу як базового. Така ситуація виникає, коли базові класи мають спільний базовий тип. Розглянемо приклад. Клас "Трамвай" (Tram) є одночасно похідним від "Громадський транспорт" (PublicTransport) і "Рейковий транспорт" (RailTransport), які своєю чергою є похідними від класу "Транспорт" (Transport). Виникає так зване ромбовидне успадкування (diamond inheritance):

class Transport
{
protected:
    char name[30];
    double maxSpeed;
};
class PublicTransport : public Transport
{
protected:
    int passengers;
    int maxLoad;
};

class RailTransport : public Transport
{
protected:
    double trackGauge;
    double maxLoad;
};

class Tram : public PublicTransport, public RailTransport
{
};

Під час використання такої ієрархії класів виникають дві проблеми:

  • елементи даних базового класу (Transport) окремо потрапляють у класи PublicTransport і RailTransport, а потім двічі у клас Tram;
  • класи PublicTransport і RailTransport описують елемент даних з однаковим ім'ям, але різних типів і з різним сенсом; обидва елементи даних потрапляють у клас Tram і виникає конфлікт імен.

Для розв'язання першої проблеми використовують так зване віртуальне успадкування. До специфікації базового класу можна додати ключове слово virtual. Один об'єкт віртуального базового класу розділяється між всіма класами, які вказали його як віртуальний, визначаючи свої базові класи:

class Transport
{
protected:
    char name[30];
    double maxSpeed;
};
class PublicTransport : virtual public Transport
{
protected:
    int passengers;
    int maxLoad;
};

class RailTransport : virtual public Transport
{
protected:
    double trackGauge;
    double maxLoad;
};

class Tram : public PublicTransport, public RailTransport
{
};

Конструктори віртуальних базових класів викликаються до будь-яких конструкторів невіртуальних базових класів.

Примітка: не слід плутати віртуальні базові класи з віртуальними функціями, які розглядатимуться пізніше; це поняття, не пов'язані між собою.

Віртуальне успадкування загалом не розв'язує другої проблеми: не допомагає уникнути конфліктів імен. Для розв'язання проблем з іменами членів базових класів використовують using-оголошення.

Множинне успадкування класів незалежно від способів його реалізації є небезпечним з точки зору можливого конфлікту імен, його використання може призвести до виникнення суперечливих і дубльованих даних. Використання множинного успадкування слід уникати в більшості випадків. Єдиний сенс його застосування – коли один з базових класів є абстрактним і зовсім не містить даних. Абстрактні класи будуть розглянуті нижче.

2.4 Ієрархії класів-винятків

На відміну від багатьох інших мов програмування, С++ не вимагає, щоб усі класи винятків походили з одного базового класу. Проте іноді доцільно створювати власні ієрархії винятків:

class Error
{
    // ...
};

class MathError : public Error
{
    // ...
};

Оброблювач базового типу обробляє також винятки похідних типів:

...
catch (Error)
{
    // обробляє виняток типу Error та MathError
}

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

...
catch (MathError)
{
    // обробляє виняток типу MathError
}
catch (Error)
{
    // обробляє виняток типу Error
}
catch (...)
{
    // обробляє всі інші винятки
}

Скористатися цими правилами можна, створивши ієрархію винятків і більш детально обробляти конкретні винятки залежно від контексту.

Припустимо, є така ієрархія класів винятків, пов’язаних з певним застосунком:

class AppException { } // Клас, базовий для всіх винятку застосунку

class FileException : public AppException { } 

class FileNotFound : public FileException { }

class WrongFormat : public FileException { }

class MathException : public AppException { }

class DivisionByZero : public MathException { }

class WrongArgument : public MathException { }

Наведену ієрархію можна представити діаграмою UML:

Припустимо, є деяка функція, яка може згенерувати всі типи винятків:

void badFunc()
    // можуть виникнути різні винятки
}

Залежно від логіки програми різні типи винятків можна обробляти більш детально. Іноді важливо детально розглянути файлові помилки:

try 
{
    badFunc();
}
catch (FileNotFound)
{
    // файл не знайдено
}
catch (WrongFormat)
{
    // хибний формат
}
catch (FileException)
{
    // інші помилки, пов'язані з файлами
}
catch (MathException)
{
    // усі математичні помилки обробляємо разом
}
catch (AppException)
{
    // підбираємо всі інші винятки ієрархії
}
catch (...)
{
    // про всяк випадок
}

Іноді більш важливими є математичні помилки:

try 
{
    badFunc();
}
catch (FileException)
{
    // усі помилки, пов'язані з файлами обробляємо разом
}
catch (DivisionByZero)
{
    // ділення на нуль
}
catch (WrongArgument)
{
    // хибний аргумент
}
catch (MathException)
{
    // інші математичні помилки 
}
catch (AppException)
{
    // підбираємо всі інші винятки ієрархії
}
catch (...)
{
    // про всяк випадок
}

2.5 Поліморфізм

2.5.1 Концепція поліморфізму

Поліморфізм (polymorphism) – це механізм визначення серед функцій з однаковими іменами функції для виклику, заснований на типі параметрів (поліморфізм часу компіляції) або об'єкта, для якого викликається метод (поліморфізм часу виконання). Фактично поліморфізм часу виконання пропонує альтернативний механізм зворотного виклику.

Іноді є необхідним розміщення в одному масиві вказівників на різні об'єкти, при чому виконання певної дії для різних елементів повинно обумовлювати різну реакцію. Один з підходів базується на використанні вказівників на функції, а також створення масивів указівників типу void*. Це не зовсім безпечний шлях.

Мова C++ пропонує альтернативний спосіб розв'язання цієї проблеми. Поліморфізм часу виконання у C++ використовує ієрархії успадкування. Цей спосіб базується на описаній вище можливості зберігання у вказівниках на базовий тип адрес об'єктів похідних типів. Точні типи об'єктів у масиві вказівників, а також адреси функцій-елементів цих об'єктів не можуть бути визначені компілятором. Поліморфізм дозволяє процесору отримати адреси функцій через указівники на об'єкти під час виконання програми.

2.5.2 Віртуальні функції

Поліморфізм часу виконання у C++ базується на використанні так званих віртуальних функцій. Віртуальна функція (virtual function) – це функція-елемент, адреса якої може визначатись динамічно під час виконання програми. Віртуальні функції – це методи з фіксованим інтерфейсом виклику та різними реалізаціями у різних похідних класах. У визначенні класу для ідентифікації віртуальних функцій вживається слово virtual:

class SomeClass
{
    . . .
public:
    virtual void firstFunc();
    virtual int secondFunc(int x);
};

Слово virtual не вживається, якщо функція визначається за межами класу:

void SomeClass::firstFunc()
{
    . . . // Реалізація
}

Похідні класи можуть перекривати всі або частину віртуальних функцій базового класу. Функція, яка перекриває віртуальну функцію, повинна точно повторювати її заголовок. Не можна змінювати типи й кількість параметрів. Слово virtual не є обов'язковим. Починаючи з версії C++11, до заголовків перевизначених функцій доцільно додавати модифікатор override:

class Descendant : public SomeClass
{
    . . .
public:
    virtual void firstFunc() override { };
    virtual int secondFunc(int x) override;
};

Об'єкт класу, у якому є віртуальні функції, має назву поліморфний об'єкт, а відповідний клас – поліморфний клас (polymorphic class). Кожний поліморфний об'єкт містить спеціальне поле, у яке конструктор записує адресу так званої таблиці віртуальних методів (Virtual Method Table, VMT). Така таблиця містить адреси віртуальних функцій і створюється для кожного поліморфного класу окремо. Вибір таблиці залежить від реального типу об'єкта, на який вказує вказівник або посилання.

Є три варіанти виклику функцій-елементів: для об'єктів, для вказівників та для посилань. Виклик віртуальної функції для об'єкта визначається під час компіляції. В інших випадках адреса конкретної функції визначається під час виконання програми через VMT.

Практичне застосування віртуальних функцій можна продемонструвати на класичному прикладі. Під час створення графічної системи необхідно створити ієрархію класів для представлення геометричних фігур. Базовий клас Shape (фігура) реалізує елементи даних і функції-елементи, що можуть бути використані різними похідними класами. До таких полів можна, наприклад, віднести поточну позицію (лівий верхній кут прямокутника, в який вписана фігура) – елементи даних left і top, а також розміри – елементи даних width і height. Можна визначити методи для переміщення фігури та зміни її розмірів. Віртуальна функція draw() буде перекрита в похідних класах. Також слід додати конструктори, методи доступу та інші функції:

class Shape {
private:
    int left = 0, top = 0, width = 0, height = 0;
public:
    void moveTo(int newLeft, int newTop) {
        left = newLeft;
        top = newTop;
        draw();
    }

    void resize(int newWidth, int newHeight) {
        width = newWidth;
        height = newHeight;
        draw();
        
    }

    virtual void draw()
    {
    }

    ... // інші функції
};

Конкретні класи, створені від Shape, такі як Rectangle або Ellipse, визначають реалізацію методу draw():

class Rectangle : public Shape {
    void draw() override
    {
        // малювання прямокутника
    }
};

class Ellipse : public Shape {
    void draw() override
    {
        // малювання еліпсу
    }
};

Тепер, наприклад, можна створити масив фігур і здійснити перемалювання усієї сцени:

const int count = 4;
Shape* shapes[count];
shapes[0] = new Ellipse();
shapes[1] = new Rectangle();
shapes[2] = new Ellipse();
shapes[3] = new Shape();
for (int i = 0; i < count; i++)
{
    shapes[i]->draw();
}

Крім того, фігури можна розташувати за певним правилом:

for (int i = 0; i < count; i++)
{
    shapes[i]->moveTo(i * 100, i * 200);
}

Оскільки з функцій moveTo() і resize() віртуальна функція draw() викликається через вказівник this, отже через таблицю віртуальних методів, під час пересування та зміни розміру буде малюватися необхідна фігура.

Деструктори також можуть бути віртуальними. Після оголошення деструктора віртуальним деструктори усіх похідних класів є віртуальними автоматично. Віртуальні деструктори дозволяють точно визначити обсяг пам'яті, яку необхідно звільнити, коли знищується об'єкт. Це особливо важливо для об'єктів, які створені у динамічній пам'яті та знищуються за допомогою операції delete.

Віртуальні функції не можуть бути описані з модифікатором static. Віртуальні функції можуть бути inline, але цей специфікатор має ефект лише для виклику функції для об'єкта (не для вказівника або посилання).

Іноді у певному похідному класі необхідно перервати ланцюг перевизначення віртуальних функцій. Для того, щоб унеможливити подальше перевизначення, після заголовку функції використовують модифікатор final.

2.6 Абстрактні класи

Іноді у базовому класі не доцільно здійснювати реалізацію певної віртуальної функції, оскільки клас створено для представлення базової абстракції. В такому випадку функцію можна описати як суто віртуальну (pure virtual function). Для цього використовують спеціальний синтаксис:

virtual void f() = 0;

Суто віртуальну функцію також називають абстрактною. Абстрактний клас (abstract class)– це клас, який містить оголошення хоча б однієї суто віртуальної функції:, який містить оголошення хоча б однієї суто віртуальної функції:

class AbstractBaseClass
{
    . . .
public:
    virtual void f() = 0;
    virtual int secondFunc(int x);
};

Похідні класи повинні перевантажити суто віртуальні функції. В іншому випадку такі класи залишаються абстрактними.

Наприклад, клас Shape може бути абстрактним оскільки усталена реалізація функції draw() не має сенсу:

class Shape {
    ...
    virtual void draw() = 0;
    ...
};

Не можна створити об'єкт абстрактного класу, але можна описати вказівник або посилання на абстрактний клас та проініціалізувати їх конкретними похідними класами. Тому в попередньому прикладі створення об'єкта типу Shape призведе до синтаксичної помилки:

shapes[3] = new Shape(); // Синтаксична помилка

Помилка також виникне, якщо в якомусь похідному класі ми не визначили функцію draw(), або помилилися з її ім'ям і списком параметрів.

Суто абстрактний клас (без даних і жодної неабстрактної функції) є практично безпечним з точки зору множинного успадкування. Такий клас використовують як один з базових, імітуючи таким чином відсутній в C++ механізм реалізації інтерфейсів. Набір абстрактних функцій визначає певну специфічну поведінку, яка може бути притаманна об'єктам різної природи – відповідні класи можуть належати до різних ієрархій.

Розглянемо такий приклад. Є базовий клас "Тварина" і похідні класи "Комаха", "Ссавець" і "Птах":

class Animal
{
    //...
};

class Insect : public Animal
{
    //...
};

class Mammal : public Animal
{
    //...
};

class Bird : public Animal
{
    //...
};

Деякі з тварин можуть літати. Створюємо абстрактний клас Flyable ("здібний до польоту") з абстрактною функцією fly() і відповідно класи "Джміль", "Кажан" і "Ґава", в яких функція fly() реалізована в різний спосіб:

class Flyable
{
public:
    virtual void fly() = 0;
    virtual ~Flyable() { }
};

class Bumblebee : public Insect, public Flyable
{
public:
    virtual void fly() { std::cout << "Bumblebee flies\n"; }
};

class Bat : public Mammal, public Flyable
{
public:
    virtual void fly() { std::cout << "Bat flies\n"; }
};

class Crow : public Bird, public Flyable
{
public:
    virtual void fly() { std::cout << "Crow flies\n"; }
};

Примітка: деструктор класу Flyable не є абстрактним (він не може бути абстрактним), але його наявність забезпечує віртуальність деструкторів у похідних класах, що дуже важливо для коректного звільнення пам'яті.

Завдяки можливостям успадкування вказівники на об'єкти цих типів можна записати в масив і виконати загальні дії (виклик функції fly()):

int main()
{
    Flyable* arr[] = { new Bumblebee(), new Bat(), new Crow() };
    for (int i = 0; i < 3; i++)
    {
        arr[i]->fly();
    }
    for (int i = 0; i < 3; i++)
    {
        delete arr[i];
    }
    return 0;
}

Одночасно вказівник на об'єкт типу Bumblebee може входити в масив комах, вказівник на об'єкт типу Bat може входити в масив ссавців, вказівник на об'єкт типу Crow може входити в масив птахів і всі вони можуть бути записані в масив вказівників на тварин.

2.7 Використання шаблонів

2.7.1 Опис та використання шаблонних функцій

Узагальнене програмування (generic programming) це парадигма програмування, яка полягає у відокремленні даних, структур даних та алгоритмів обробки даних. Шаблонні функції та шаблони типів є механізмом реалізації узагальненого програмування у C++.

Коли компілятор зустрічає в коді шаблон, який починається словом template, він не здійснює компіляцію шаблона, а лише знаходить місце, де шаблон закінчується. Для того, щоб скористатися шаблоном, необхідно уточнити його параметри. Якщо здійснено звернення до шаблону зі вказаними формальними параметрами, здійснюється компіляція коду шаблону.

Шаблонні функції (template functions) призначені для запису узагальнених функцій, що можуть працювати з даними різних типів.

Визначення й оголошення шаблону функції починається службовим словом template. За ним йде у кутових дужках ("<" і ">") список формальних параметрів шаблона, розділених комами. Список формальних параметрів шаблона не може бути порожнім. Кожен формальний параметр, що визначає тип, складається зі службового слова class, (або typename, що більше відповідає сучасному стандарту) за яким йде ідентифікатор. Ім'я формального параметра в списку повинне бути унікальним.

Формальні параметри шаблонів можуть використовуватися для визначення типу результату і формальних параметрів шаблонної функції. У тілі шаблонної функції також можуть використовуватися формальні параметри шаблона.

Як приклад шаблона приведемо функцію підсумовування елементів масиву довільного типу. Головне, щоб для елементів масиву були визначені операції присвоювання, у тому числі присвоювання константи "нуль", і "+=".

template <typename SomeType>
SomeType sumOfArray(SomeType *a, const int size)
{
    SomeType sum = 0;
    for (int i = 0; i < size; i++)
    {
        sum += a[i];
    }
    return sum;
}

Крім того, можна визначити шаблон функції виведення в стандартний потік елементів масиву:

template <typename SomeType>
void printArray(SomeType *a, const int size)
{
    for (int i = 0; i < size; i++)
    {
        cout << a[i] << ' ';
    }
    cout << endl;
}

Конкретні визначення функцій, що відповідають шаблону, компілятор генерує під час виклику шаблонної функції з параметрами конкретного типу. Для описаного вище приклада можна запропонувати таке використання шаблонних функцій:

int main()
{
    int a[] = {1, 2, 3};
    printArray(a, 3);
    cout << sumOfArray(a, 3) << endl;
    double b[] = {1.1, 2.2, 3.3, 4.5};
    printArray(b, 4);
    cout << sumOfArray(b, 4) << endl;
    cin.get();
    return 0;
}

Шаблонна функція може перевантажуватися за умови, що список формальних параметрів кожного варіанта відрізняється від інших або типами параметрів, або їхнім числом.

2.7.2 Порядок звернення до шаблонних функцій

Бувають випадки, коли для якихось конкретних типів потрібно дати особливе визначення шаблонної функції. У цьому випадку програміст повинен задати свій спеціальний варіант функції. Наприклад, шаблон функції min() працює для типів, для яких визначена операція "<":

template <typename Type> 
Type min(Type a, Type b)
{
    return a < b ? a : b;
} 

Цей шаблон не підходить для варіанта порівняння рядків. Для них визначається спеціальний варіант функції:

char* min(char* s1, char* s2)
{
    return strcmp(s1, s2) < 0 ? s1 : s2;
}

Порядок виклику функцій буде таким.

  1. Досліджуються всі нешаблонні варіанти функції.
  2. Досліджуються всі шаблонні варіанти функції.
  3. Повторно досліджуються всі нешаблонні варіанти функції, з застосуванням перетворення типів.

Для того, щоб можна було конкретизувати шаблон, компілятор повинен бачити не тільки оголошення, але і визначення функції. Тому визначення шаблонних функцій можна і треба поміщати в заголовні файли.

Під час виклику функції фактичний параметр шаблона можна вказати явно, наприклад:

int i = min<int>(2, 3);

2.7.3 Шаблони класів

Шаблон класу (class template) можна використовувати для створення сімейства класів, які відрізняються типами або константними значеннями всередині опису.

Попереднє оголошення і визначення шаблону класу починається зі службового слова template. За ним іде список формальних параметрів шаблона типу. Цей список не може бути порожнім. Наприклад:

template <typename T> class X 
{ 
    T t; 
public:
    void set(T t1) { t = t1; }
};

Шаблон класу не є класом. Інстанціювання шаблону (template instantiation) – це створення певних типів з шаблону. Такі класи мають назву екземплярів шаблону (template instances):

X<int> xi;
X<double> xd;

Примітка: шаблони класів іноді умовно називають узагальненими класами (generic classes).

Параметрами шаблонів можуть бути параметри-типи, параметри звичайних типів і параметри-шаблони. У шаблона може бути кілька параметрів. Цілі аргументи використовуються найчастіше для завдання розмірів і границь масивів. Цілий аргумент шаблона повинен бути константою. Можливе завдання усталених параметрів шаблона:

template <typename T, int size = 64> class Y 
{ 
    . . . 
};

Фактичне значення цілого параметру повинно бути константним виразом:

const int N = 128;
int i = 256;

Y<int, 2*N> b1;// OK
Y<float, i> b2;// Помилка: i не константа

Шаблони класів допускають використання механізму спадкування. Можливе створення похідного шаблону класу як від шаблона, так і від класу, що не є шаблоном.

Функція-елемент шаблонного класу вважається неявною шаблонною функцією, а параметри шаблона типу для її класу – параметрами її шаблона. Для деяких типів стандартні функції-елементи не підходять. У таких випадках можна явно задавати реалізацію функції, розрахованої на конкретний тип. Перед реалізацією таких функцій потрібно спеціальне оголошення template<> без параметрів.

#include <iostream>

template <typename T> class MinFinder {
private:
    T a, b;
public:
    MinFinder(T a, T b)
    {
        this->a = a;
        this->b = b;
    }
    T findMin();
};

template <typename T> T MinFilder<T>::findMin()
{
    return a < b ? a : b;
}

template <> const char* MinFilder<const char*>::findMin()
{
    return std::strcmp(a, b) < 0 ? a : b;
}

int main()
{
    MinFilder<const char*> finder("a", "b");
    std::cout << finder.findMin();
}

Крім того, можна дати особливе визначення шаблонного класу, розраховане на конкретний тип. Функція-друг для шаблона типу не є неявною шаблонною функцією.

Шаблон класу може мати статичні елементи. Кожен клас, згенерований за шаблоном, має свою копію статичних елементів.

2.7.4 Шаблони класів і відповідність типів

Створені за одним шаблоном типи будуть різними та між ними неможливе відношення спадкування крім єдиного випадку, коли в цих типів ідентичні параметри шаблона. Наприклад:

template <typename T> class X
{
    /* ... */
}

X<int>   x1;
X<short> x2;
X<int>   x3;

Тут x1 і x3 одного типу, а x2 – зовсім іншого. Автоматичне приведення типів не здійснюється:

x2 = x3; // помилка

3 Приклади програм

3.1 Ієрархія об'єктів реального світу

Припустимо, необхідно розробити ієрархію класів "Регіон" – "Населений район" – "Країна". Окремі класи цієї ієрархії можуть стати базовими для інших класів (наприклад "Незаселений острів", "Національний парк", "Адміністративний район", "Автономна республіка" і т.д.). Ієрархію класів можна доповнити класами "Місто" і "Острів". Доцільно в кожен клас додати конструктор, який ініціалізує усі поля. Можна також створити масив указівників на різні об'єкти ієрархії та для кожного об'єкта вивести на екран рядок даних про нього.

Можна запропонувати таку ієрархію класів, в яких для більшої наочності всі функції реалізовані всередині класів:

#pragma warning(disable:4996)


#include <cstring>
#include <iostream>

using std::strcpy;
using std::cout;
using std::endl;

class Region
{
private:
    char name[30];
    double area;
public:
    Region(const char *name, double area)
    {
        strcpy(this->name, name); 
        this->area = area;
    }
    char* getName()
    {
        return name;
    }
    double getArea() const
    {
        return area;
    }
    virtual void show()
    {
        cout << endl << "Назва: " << name << ".\tПлоща: " << area << " кв.км.";
    }
    virtual ~Region() { }
};

class PopulatedRegion : public Region
{
private:
    int population;
public:
    PopulatedRegion(const char* name, double area, int population)
        : Region(name, area) { this->population = population; }
    int getPopulation() const
    {
        return population;
    }
    double density() const
    {
        return population / getArea();
    }
    void show() override
    {
        Region::show(); 
        cout << "\tНаселення:" << population << " чол.\tЩільність: " << density() << " чол/кв.км.";
    }
};

class Country : public PopulatedRegion
{
private:
    char capital[20];
public: 
    Country(const char* name, double area, int population, const char* capital) 
        : PopulatedRegion(name, area, population) { strcpy(this->capital, capital); }
public:
    char* getCapital()
    {
        return capital;
    }
    void show() override
    {
        PopulatedRegion::show();
        cout << "\tСтолиця " << capital;
    }
};

class City : public PopulatedRegion
{
private:
    int boroughs;
public:
    City(const char* name, double area, int population, int boroughs)
        : PopulatedRegion(name, area, population) { this->boroughs = boroughs; }
public:
    int getBoroughs() const
    {
        return boroughs;
    }
    void show() override
    {
        PopulatedRegion::show();
        cout << "\tРайонів: " << boroughs;
    }
};

class Island : public PopulatedRegion
{
private:
    char sea[30];
public:
    Island(const char* name, double area, int population, const char* sea)
        : PopulatedRegion(name, area, population) { strcpy(this->sea, sea); }
    char* getSea()
    {
        return sea;
    }
    virtual void show() override
    { 
        PopulatedRegion::show();
        cout << "\tМоре: " << sea;
    }
};

void main()
{
    setlocale(LC_ALL, "UKRAINIAN");
    const int N = 4;
    Region *regions[N] = { 
        new City("Київ", 839, 2679000, 10),
        new Country("Україна", 603700, 46294000, "Київ"),
        new City("Харкiв", 310, 1461000, 9),
        new Island("Змiїний", 0.2, 30, "Чорне")
    };
    for (int i = 0; i < N; i++)
    {
        regions[i]->show();
    }
    for (int i = 0; i < N; i++)
    {
        delete regions[i];
    }
}

Примітка: підключення нестандартної директиви #pragma warning(disable:4996) у Visual Studio дозволяє уникнути повідомлень про помилку, пов'язаних з використанням "небезпечної" функції strcpy().

У функції main() ми використовуємо масив указівників замість масиву об'єктів, оскільки тільки в цьому випадку працює механізм поліморфізму. В протилежному випадку функція show() викликалася б тільки з базового класу.

3.2 Клас для розв'язання рівняння методом ділення відрізку навпіл

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

Створюємо новий проєкт, до якого додаємо новий клас, наприклад AbstractDichotomy. Це можна зробити за допомогою функції головного меню Project | Add Class.... Вводимо ім'я класу AbstractDichotomy і натискуємо OK. Для класу автоматично генерується пара файлів – заголовний файл і файл реалізації. Файл AbstractDichotomy.h містить такий код:

#pragma once
class AbstractDichotomy
{
};

Файл AbstractDichotomy.cpp містить лише підключення заголовного файлу:

#include "AbstractDichotomy.h"

Згенерований текст містить нестандартну директиву препроцесору #pragma once. Застосування цієї директиви замість стандартних стражів включення унеможливить компіляцію коду в інших середовищах, окрім Visual Studio. Замість цієї директиви до заголовного файлу слід додати стражів включення. Для кожного класу, який автоматично генерується, слід виправляти цей недолік. У класі оголошуємо функцію знаходження кореня root() і суто віртуальну функцію f():

#ifndef AbstractDichotomy_h
#define AbstractDichotomy_h


class AbstractDichotomy
{
public:
    double root(double a, double b, double eps = 0.001);
    virtual double f(double x) = 0;
};

#endif

Файл реалізації містить визначення функції root():

#include <cmath>
#include "AbstractDichotomy.h"

double AbstractDichotomy::root(double a, double b, double eps)
{
    double x;
    do
    {
        x = (a + b) / 2;
        if (f(a) * f(x) > 0)
        {
            a = x;
        }
        else
        {
            b = x;
        }
    } 
    while (std::fabs(b - a) > eps);
    return x;
}

Тепер у програмі, яка потребує розв'язання рівняння, можна підключити заголовний файл і створити новий похідний клас, в якому буде визначено певну функцію – ліву частину рівняння:

#include <iostream>
#include "AbstractDichotomy.h"

using std::cout;
using std::endl;

class MyDichotomy : public AbstractDichotomy
{
    virtual double f(double x) override
    {
        return x * x - 2;
    }
};

void main()
{
    MyDichotomy d;
    cout << d.root(0, 6) << endl;           // 1.41431
    cout << d.root(0, 6, 0.00001) << endl;  // 1.41421
}

Нові класи слід створювати для кожної конкретної функції.

3.3 Використання шаблонів для створення універсальної функції

Альтернативний варіант універсального рішення для створення універсальної функції знаходження коренів надають шаблони.

Створюємо заголовний файл:

#ifndef DICHOTOMY_H
#define DICHOTOMY_H


template <typename F> double root(F f, double a, double b, double eps = 0.001)
{
    double x;
    do
    {
        x = (a + b) / 2;
        if (f(a) * f(x) > 0)
        {
            a = x;
        }
        else
        {
            b = x;
        }
    } 
    while (b - a > eps);
    return x;
}

#endif

Окремий файл реалізації не потрібен.

У файлі з функцією main() описуємо клас для створення функціонального об'єкта. Крім того, можна створити функцію, для якої також здійснюватиметься пошук кореня. У функції main() шукаємо корені для обох функцій.

#include <iostream>
#include <cmath>
#include "Dichotomy.h"

// Клас для створення функціонального об'єкта - полінома N-го степеня.
// Параметр шаблона - ціла константа, яку можна застосувати для створення масиву.

template <int N> class Polynomial
{
private:
    double coefs[N + 1] = { 0 };
public:
    Polynomial(std::initializer_list<double> coefs)
    {
        int i = 0;
        for (const double& k : coefs)
        {
            if (i <= N)
            {
                this->coefs[i++] = k;
            }
        }
    }

    // Завдяки перевантаженню операції "круглі дужки" 
    // можна працювати з об'єктом, як із функцією
    double operator()(double x)
    {
        double sum = 0;
        double p = 1;
        for (int i = N; i >= 0; i--)
        {
            sum += p * coefs[i];
            p *= x;
        }
        return sum;
    }
};

double my_sin(double x)
{
    return std::sin(x);
}

int main()
{
    std::cout << root(my_sin, 1, 4, 0.000001) << std::endl;
    Polynomial<2> poly = { 1, -1, -2 };
    std::cout << root(poly, 0, 3, 0.000001) << std::endl;
    for (double x = 0; x < 3; x += 0.25)
    {
        std::cout << x << "\t" << poly(x) << std::endl;
    }
    return 0;
}

На жаль, на відміну від використання вказівників на функції, рішення, побудоване на шаблоні, не дозволяє застосовувати стандартні функції. Як у наведеному вище прикладі, їх треба "обгортати" власними функціями.

3.4 Узагальнений клас для представлення одновимірного масиву

Клас, створений у попередній лабораторній роботі, можна перетворити на шаблон. Тоді можна буде створювати масиви об'єктів різних типів. Клас можна розташувати в окремому заголовному файлі Array.h. Узагальнена функція getSum(), яка знаходиться в цьому ж файлі, дозволяє знайти суму елементів масиву. Текст файлу Array.h буде таким:

#ifndef Array_h
#define Array_h

#include <iostream>

using std::istream;
using std::ostream;

template <typename T> class Array
{
    friend ostream& operator<<(ostream& out, const Array& a)
    {
        for (int i = 0; i < a.size; i++)
        {
            out << a.pa[i] << ' ';
        }
        return out;
    }
    friend istream& operator >> (istream& in, Array& a)
    {
        for (int i = 0; i < a.size; i++)
        {
            in >> a.pa[i];
        }
        return in;
    }
private:
    T *pa = nullptr;
    int  size = 0;
public:
    class OutOfBounds
    {
        int index;
    public:
        OutOfBounds(int i) : index(i) { }
        int getIndex() const { return index; }
    };
    Array() { }
    Array(int n);
    Array(Array& arr);
    ~Array()
    {
        if (pa)
        {
            delete[] pa;
        }
    }
    void addElem(T elem);
    T& operator[](int index);
    const Array& operator=(const Array& a);
    bool operator==(const Array& a) const;
    int getSize() const { return size; }
};

template <typename T> Array<T>::Array(int n)
{
    pa = new T[size = n];
}

template <typename T> Array<T>::Array(Array& arr)
{
    size = arr.size;
    pa = new T[size];
    for (int i = 0; i < size; i++)
    {
        pa[i] = arr.pa[i];
    }
}

template <typename T> void Array<T>::addElem(T elem)
{
    T *temp = new T[size + 1];
    if (pa)
    {
        for (int i = 0; i < size; i++)
        {
            temp[i] = pa[i];
        }
        delete[] pa;
    }
    pa = temp;
    pa[size] = elem;
    size++;
}

template <typename T> T& Array<T>::operator[](int index)
{
    if (index < 0 || index >= size)
    {
        throw OutOfBounds(index);
    }
    return pa[index];
}

template <typename T> const Array<T>& Array<T>::operator=(const Array<T>& a)
{
    if (&a != this)
    {
        if (pa)
        {
            delete[] pa;
        }
        size = a.size;
        pa = new T[size];
        for (int i = 0; i < size; i++)
        {
            pa[i] = a.pa[i];
        }
    }
    return *this;
}

template <typename T> bool Array<T>::operator==(const Array<T>& a) const
{
    if (&a == this)
    {
        return true;
    }
    if (size != a.size)
    {
        return false;
    }
    for (int i = 0; i < size; i++)
    {
        if (pa[i] != a.pa[i])
        {
            return false;
        }
    }
    return true;
}

template <typename T> T getSum(Array<T>& a)
{
    T sum = T();
    for (int i = 0; i < a.getSize(); i++)
    {
        sum = sum + a[i];
    }
    return sum;
}

#endif

Для того, щоб здійснити тестування класу для різних типів, можна створити заголовний файл Vector.h і перенести в нього вихідний код класу Vector з прикладу попередньої лабораторної роботи:

#ifndef Vector_h
#define Vector_h

#include <iostream>

using std::istream;
using std::ostream;

class Vector {
    friend istream& operator >> (istream& in, Vector& v) { return in >> v.x >> v.y; }
    friend ostream& operator<<(ostream& out, const Vector& v)
                                                { return out << "x=" << v.x << " y=" << v.y; }
    friend Vector operator+(Vector v1, Vector v2) { return Vector(v1.x + v2.x, v1.y + v2.y); }
    friend Vector operator*(double k, Vector v) { return Vector(v.x * k, v.y * k); }
    friend Vector operator*(Vector v, double k) { return operator*(k, v); }
    friend double operator*(Vector v1, Vector v2) { return v1.x * v2.x + v1.y * v2.y; }
private:
    double x, y;
public:
    Vector() { x = y = 0; }
    Vector(double x, double y) { this->x = x; this->y = y; }
    double getX() { return x; }
    void setX(double x) { this->x = x; }
    double getY() { return y; }
    void setY(double y) { this->y = y; }
};

#endif

Тепер можна скористатися створеним класом Array для зберігання як цілих чисел, так і векторів:

#include <iostream>
#include "Array.h"
#include "Vector.h"

using std::cout;
using std::endl;

void main()
{
    Array<int> intArray;
    intArray.addElem(11);
    intArray.addElem(12);
    cout << intArray << endl;
    try
    {
        intArray[1] = 4;
        intArray[10] = 35;
    }
    catch (Array<int>::OutOfBounds e)
    {
        cout << "Bad index: " << e.getIndex() << endl;
    }
    cout << getSum(intArray) << endl;
    Array<Vector> vectorArray;
    vectorArray.addElem(Vector(1, 2));
    vectorArray.addElem(Vector(3, 4));
    vectorArray.addElem(Vector(5, 6));
    cout << vectorArray << endl;
    cout << getSum(vectorArray) << endl; // x=9 y=12
}

Можна також зберігати дані інших типів, наприклад, вказівники на ціле:

int m = 1, n = 2;
// Створюємо масив вказівників на int
Array<int*> pointerArray;
pointerArray.addElem(&m);
pointerArray.addElem(&n);
// Виводимо розіменовані значення:
cout << *pointerArray[0] << " " << *pointerArray[1] << endl; // 1 2

Але спроба знайти суму елементів призведе до помилки компіляції, оскільки не можна знаходити суму вказівників:

// Не можна знаходити суму вказівників:
cout << getSum(pointerArray) << endl; // Помилка компіляції (cannot add two pointers)

4 Вправи для контролю

  1. Створити ієрархію класів "Будинок" – "Навчальний корпус". Створити масив указівників і вивести в циклі дані про об'єкти різних типів.
  2. Створити ієрархію класів "Цифровий пристрій" – "Мобільний телефон". Створити масив указівників і вивести в циклі дані про об'єкти різних типів.
  3. Створити шаблонну функцію для пошуку в масиві елементів, які знаходяться в певному діапазоні. Перевірити роботу для різних типів елементів.
  4. Створити шаблон класу для зберігання пари чисел різних типів.

5 Контрольні запитання

  1. Як засобами UML представити клас?
  2. Які зв'язки між класами пропонує UML і як вони реалізуються у C++?
  3. У чому полягає концепція успадкування?
  4. Чим відрізняється відкрите, захищене і закрите успадкування?
  5. Які переваги й недоліки множинного успадкування?
  6. Для чого визначають віртуальний базовий клас?
  7. Для чого створюють ієрархії винятків?
  8. У чому полягає концепція поліморфізму?
  9. Чим відрізняється поліморфізм часу компіляції від поліморфізму часу виконання?
  10. Що таке віртуальна функція?
  11. Які класи вважаються поліморфними?
  12. Чому в поліморфних класах доцільно визначати віртуальні деструктори?
  13. Що таке таблиця віртуальних методів?
  14. Що таке суто віртуальна функція?
  15. Що таке абстрактний клас?
  16. Для чого використовують шаблонні функції?
  17. Чи можна шаблонну функцію окремо реалізувати для певного типу?
  18. Навіщо параметр шаблону в шаблонній функції вказувати явно?
  19. Як створити шаблон класу?
  20. Якими можуть бути параметри шаблону?
  21. Для чого використовують цілі параметри шаблону?
  22. Що таке інстанціювання шаблону?
  23. Як здійснюється перетворення об'єктів інстанційованих типів?

 

up