Лабораторна робота 3

Створення та використання класів C++

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

1.1 Клас для представлення точки в тривимірному просторі

Створити клас для опису точки в тривимірному просторі. Клас повинен відповідати таким вимогам:

  • мати конструктор без параметрів;
  • мати конструктор с трьома параметрами;
  • містити елементи даних типу double для представлення координат точки;
  • реалізовувати публічні функції доступу до даних (сетери й гетери).

Для обчислення відстані створити операторну функцію operator-() ("мінус") з двома параметрами типу класу для представлення точок і результатом типу double. Функцію оголосити як друга класу.

У функції main() слід створити два об'єкти типу точки в тривимірному просторі, застосувавши різні конструктори. Для встановлення і читання значень застосувати функції доступу. Обчислити відстань між двома точками через використання операції "мінус", потім змінити координати однієї з точок і обчислити відстань між двома точками через явний виклик функції operator-().

1.2 Клас для представлення простого дробу

Створити клас для представлення простого дробу. Реалізувати конструктори, функцію скорочення дробу, а також перевантажити операції +, -, *, /, <, <=, >, >=, введення та виведення. В операторній функції виведення реалізувати максимально коректне виведення: отримувати та виводити цілу частину для неправильних дробів, не виводити знаменник, якщо він дорівнює 1 або чисельник дорівнює 0 тощо.

Здійснити читання двох дробів та демонстрацію скорочення дробів і всіх перевантажених операцій в функції main().

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

1.3 Класи для представлення студента й групи

Створити класи для представлення даних про студента і групи студентів. Клас "Студент" повинен містити такі приватні елементи даних:

  • номер студентського посвідчення (unsigned int);
  • прізвище (вказівник на символ); відповідний рядок створюватиметься у динамічній пам'яті за необхідністю;
  • оцінки за останню сесію у вигляді масиву цілих від 0 до 100 (оцінки за предметами); масив оцінок створюється в динамічній пам'яті;
  • вказівник на об'єкт класу "Група".

Визначити в класі "Студент" такі елементи:

  • конструктор без параметрів і конструктор з параметрами;
  • конструктор копіювання;
  • деструктор;
  • функції доступу до даних (сетери й гетери);
  • перевантажену операцію присвоєння.

Операторну функцію виведення даних про студента в потік слід реалізувати як зовнішню функцію – друга класу. Для забезпечення сортування перевантажити необхідні функції порівняння (відношення).

Слід визначити константу для максимально можливої кількості студентів (наприклад, 50)

У класі "Група" слід визначити такі елементи даних:

  • індекс групи;
  • масив указівників на студентів максимально можливої довжини;
  • реальна кількість указівників у масиві.

Визначити в класі "Група" такі елементи:

  • конструктор без параметрів і конструктор з параметрами;
  • функції доступу до даних про індекс (сетер і гетер);
  • перевантажену операцію присвоєння;
  • функцію сортування за визначеним критерієм;
  • знаходження студентів за певною ознакою.

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

У функції main() створити об'єкт "Група", додати масив студентів і продемонструвати всі реалізовані функції, зокрема, сортування і пошук за ознаками, визначеними в завданні 1.3 попередньої лабораторної роботи.

1.4 Клас для представлення двовимірного масиву

Розробити клас для представлення двовимірного масиву (матриці) цілих чисел довільних розмірів. Створити конструктори та деструктор, перевантажити операції додавання, віднімання і множення (згідно з правилами роботи з матрицями), звертання за індексом, введення з потоку та виведення в потік. Створити власні класи винятків та генерувати відповідні об'єкти-винятки, якщо неможливо виконати ту чи іншу операцію.

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

Номер варіанту
(номер студента у списку)
Правило перетворення елементів масиву
1
15
Усі елементи з непарними значеннями повинні бути збільшені у два рази
2
16
Усі елементи з парними значеннями повинні бути замінені їх квадратами
3
17
Усі елементи з нульовим значенням слід замінити одиницями
4
18
Усі елементи з парними значеннями повинні бути збільшені у два рази
5
19
Усі елементи повинні бути замінені їх абсолютними величинами
6
20
Усі елементи з парними значеннями повинні бути збільшені в три рази
7
21
Усі додатні елементи повинні бути замінені з цілими частинами їх десяткових логарифмів
8
22
Усі від'ємні елементи повинні бути замінені їх квадратами
9
23
Усі додатні елементи повинні бути замінені з цілими частинами їх натуральних логарифмів
10
24
Усі додатні елементи повинні бути замінені з цілими частинами їх квадратних коренів
11
25
Всі додатні елементи з парними значеннями повинні бути збільшені у два рази
12
26
Усі від'ємні елементи з непарними значеннями повинні бути збільшені в три рази
13
27
Усі від'ємні елементи з непарними значеннями повинні бути збільшені у два рази
14
28
Усі додатні елементи з парними значеннями повинні бути збільшені в три рази

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

1.5 Підрахунок суми введених значень

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

У функції main() створити декілька об'єктів і вивести отриману суму.

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

2.1 Передумови виникнення об'єктно-орієнтованого підходу

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

Великі програми, які виконували складні обчислення, необхідно було ділити на відносно незалежні частини – окремі процедури, які виконують фіксовану роботу м межах задачі. Всередині таких процедур можна було створювати інформаційно незалежні блоки. Ця ідея була втілена в мовах процедурного програмування, таких як, наприклад, ALGOL-60 та PASCAL.

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

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

Спроби застосувати традиційний підхід до нових задач здебільшого були приречені на невдачу.

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

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

  • процедурна декомпозиція (традиційна) передбачає поділення складної системи за її функціями, які можна подати у вигляді "чорних скриньок" з фіксованим набором вхідних даних і фіксованими результатами;
  • об'єктно-орієнтована декомпозиція передбачає поділення системи на відносно незалежні об'єкти, для яких можна визначити стан і набір можливих операцій над цими об'єктами.

Головна перевага об'єктно-орієнтованої декомпозиції у тому, що вона максимально наближена до структури реальних систем, в яких цілком природно можна виділити складові частини у вигляді окремих об'єктів. Замість побудови програми з послідовного виклику процедур передбачено створення і реєстрацію програмних об'єктів, які отримують повідомлення (події) з зовнішнього світу, обробляють ці події, а також надсилають повідомлення іншим об'єктам.

Мова C++ – одна з перших і одночасно найбільш потужних мов об'єктно-орієнтованого програмування.

2.2 Визначення класів

Класи у C++ є основним засобом визначення користувацьких типів. Класи аналогічні структурам. Клас (class) – це структурований тип даних, набір елементів даних різних типів і функцій для роботи з цими даними.

Дані об'єкта (елементи даних, data members) – це змінні, які характеризують стан об'єкта.

Функції об'єкта (функції-елементи, member functions) – це функції, які мають безпосередній доступ до даних об'єкта. Іноді кажуть, що методи визначають поведінку об'єкта. На відміну від звичайних (глобальних) функцій, необхідно спочатку створити об'єкт і викликати метод у контексті цього об'єкта.

Одним з важливих принципів об'єктно-орієнтованого підходу є інкапсуляція (приховування даних). Зміст інкапсуляції полягає у приховуванні від зовнішнього користувача деталей реалізації об'єкта. Для реалізації інкапсуляції мова C++ надає рівні доступу до елементів класу: public, protected та private. Відповідні ключові слова з двокрапкою використовують для групування елементів класу.

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

Розглянемо, наприклад, клас Country:

class Country
{
private:
    char name[40];
    double area;
    int population;
public:
    char*  getName();
    double getArea();
    int    getPopulation();
    void   setName(const char* value);
    void   setArea(double value);
    void   setPopulation(int value);
    double density();
};

Функції-елементи getName(), getArea(), getPopulation(), setName(), setArea() та setPopulation() надають доступ до закритих елементів класу. Такі функції мають назву функцій доступу. Їх ще називають гетерами й сетерми (getters / setters).

Функція-елемент density() повертає щільність населення країни.

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

double Country::density()
{
    return population / area;
}

Як видно з прикладу, функція-елемент має вільний доступ до елементів даних.

Функції-елементи можна повністю визначити всередині класу. Тоді вони є неявними inline-функціями. Можна так змінити визначення класу:

class Country
{
private:
    char   name[40];
    double area;
    int    population;
public:
    char*  getName()                  { return name; }
    double getArea()                  { return area; }
    int    getPopulation()            { return population; }
    void   setName(const char* value) { strcpy(name, value); };
    void   setArea(double value)      { area = value; }
    void   setPopulation(int value)   { population = value; }
    double density();
};

Тепер можна використовувати ім'я класу для опису об'єкта та виклику функцій-елементів:

void main()
{
    Country someCountry;
    someCountry.setName("France");
    someCountry.setArea(551500);
    someCountry.setPopulation(67970000);
    cout << someCountry.density();
}

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

Функції-елементи мають доступ до інших елементів класу через так званий вказівник this. Функції-елементи отримують цей вказівник як неявний аргумент. Цей вказівник вказує на об'єкт, для якого викликана функція-елемент. Цей вказівник неявно передається кожній функції-елементу під час виклику. Наприклад, реалізація функції density() може бути такою:

double Country::density()
{
    return this->population / this->area;
}

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

void   setName(const char* name)     { strcpy(this->name, name); };
void   setArea(double area)          { this->area = area; }
void   setPopulation(int population) { this->population = population; }

Іноді необхідно повернути посилання або вказівник на об'єкт:

Country& Country::getCountry() 
{
    . . . 
    return *this;
}

Є спеціальний різновид функцій-елементів – так звані константні функції-елементи (constant member functions). Такі функції не можуть змінити даних об'єкту, для якого вони викликані. Для того, щоб описати таку функцію, після списку аргументів слід розмістити модифікатор const:

class Country
{
...
public:
    double getArea() const { return area; }
...
};

Константні функції-елементи можна викликати для константних об'єктів:

     const Country France;
     cout << France.getArea();
     France.setArea(1000000); // Помилка!

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

У нашому прикладі гетери та функцію density() можна визначити з модифікатором const. Але в такому випадку функція getName() повинна повертати вказівник на константний рядок (const char*). Загалом використання вказівників на константний об'єкт – це належна практика. Можна також описати відповідний параметр сетера. Клас матиме такий вигляд:

class Country
{
private:
    char   name[40];
    double area;
    int    population;
public:
    const char* getName() const { return name; }
    double      getArea() const { return area; }
    int         getPopulation() const { return population; }
    void        setName(const char* name) { strcpy(this->name, name); };
    void        setArea(double area) { this->area = area; }
    void        setPopulation(int population) { this->population = population; }
    double      density() const;
};

double Country::density() const
{
    return population / area;
}

З наведеного вище приклада видно, що для функцій-елементів, які реалізують поза тілом класу, модифікатор const слід вказувати й в оголошенні, і в реалізації.

Специфікатор mutable перед типом елементу даних застосовують для того, щоб цей елемент можна було змінювати з константних функцій-елементів.

Якщо функція-елемент має модифікатор const, вона одержує вказівник this, який вказує на константний об'єкт.

Оголошення класу – це ім'я класу з крапкою з комою:

class Country;

Тепер можна створювати вказівник на оголошений клас, вказувати їх як друзів, але не створювати об'єкт.

2.3 Ініціалізація об'єктів. Конструктори та деструктори

2.3.1 Поняття конструктора

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

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

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

Ім'я конструктора повинно збігатись з ім'ям класу. Можна описати будь-яку кількість конструкторів класу. Вони повинні відрізнятися кількістю або типами параметрів. У раніше створеному класі можна визначити декілька конструкторів:

class Country
{
private:
    char   name[40];
    double area;
    int    population;
public:
    Country(double area) { this->area = area; }
    Country(const char* name);
    Country(const char* name, double area);
    Country(const char* name, double area, int population);
    . . .
};

Можна додати також інші варіанти.

Конструктор з одним параметром типу double реалізовано в тілі класу. Починаючи з версії C++11, компілятор згенерує попередження про відсутність початкових значень частини елементів даних. Ми виправимо цю ситуацію нижче.

Конструктори можна реалізовувати поза тілом класу:

Country::Country(const char * name)
{
    strcpy(this->name, name);
    area = 1; // істотно, щоб територія не мала значення 0
              // і не виникало помилки у функції density()
    population = 0;
}

Country::Country(const char * name, double area)
{
    strcpy(this->name, name);
    this->area = area;
    population = 0;
}

Country::Country(const char * name, double area, int population)
{
    strcpy(this->name, name);
    this->area = area;
    this->population = population;
}

2.3.2 Використання списку ініціалізації

Елементи даних також можна ініціалізувати в так званому списку ініціалізації (initializer list). Цей список розміщується перед тілом конструктору. Він починається з двокрапки й далі через кому вказують елементи даних з початковими значеннями в дужках. Наприклад, останній конструктор можна реалізувати в такий спосіб:

Country::Country(const char * name, double area, int population)
     : area(area), population(population)
{
    strcpy(this->name, name);
}

Починаючи з версії C++11, можна викликати один конструктор з іншого в списку ініціалізації. Це спростить реалізацію конструкторів, які тепер можна розташувати в тілі класу, а також переробити конструктор з параметром area:

class Country
{
    ...
public:
    Country(double area) : Country("", area, 0) { } // Тепер усі поля ініціалізовані
    Country(const char* name) : Country(name, 1, 0) { }
    Country(const char* name, double area) : Country(name, area, 0) { }
    ...
};

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

Country::Country(const char* name, double area = 1, int population = 0)
    : area(area), population(population)
{
    strcpy(this->name, name);
}

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

2.3.3 Усталена ініціалізація об'єкта. Виклик конструкторів

Найпростіший конструктор – це усталений (default) конструктор. Він не має параметрів. Якщо конструктор без параметрів не визначений явно, компілятор створює такий конструктор автоматично. Якщо у класі визначений хоча б один явний конструктор, компілятор не створює усталеного конструктора автоматично. В наведеному вище прикладі конструктор без параметрів автоматично не створюється, тому, якщо такий конструктор потрібен, його слід додати вручну. Можна вказати початкові значення, наприклад:

class Country
{
    ...
public:
    Country() : Country("", 1, 0) { }
    ...
};

Починаючи з версії C++11 з'явилася можливість вказувати початкове значення (default member initializer) безпосередньо під час створення елементу даних:

class Country
{
private:
    char name[40] = {};
    double area = 1;
    int population = 0;
    . . .
};

Тоді можна створити конструктор без параметрів з порожнім тілом:

Country() { }

Під час створення об'єктів виклик конструктора може бути неявним або явним:

Country firstCountry;                    // виклик конструктора без параметрів
Country secondCountry(603700);           // виклик конструктора Country(double area)
Country thirdCountry = "France";         // виклик конструктора Country(const char* name)
Country fourthCountry("Poland", 312696); // з двома параметрами
Country *pCountry = new Country("Germany", 357000, 83130000); // з трьома параметрами,
                                         // розташування об'єкта в динамічній пам'яті

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

Якщо, наприклад, якась зовнішня функція передбачає отримання параметра типу об'єкта класу, її можна, наприклад, викликати, створивши безіменний об'єкт (з явним викликом конструктора):

void addToDatabase(Country c); // прототип функції
// ...
addToDatabase(Country("Sweden", 450000, 10490000));

2.3.4 Конструктор копіювання

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

Country Ukraine = secondCountry;

Конструктор копіювання можна створити явно. Це необхідно, наприклад, коли серед елементів даних класу є вказівник на певні дані в динамічній пам'яті. Попередня версія класу Country не потребує створення явного конструктора копіювання. Але, наприклад, назву країни можна розташувати в динамічній пам'яті. Оскільки довжина назв країн дуже різна, такий підхід заощадить пам'ять. Нова версія класу Country буде такою:

class Country
{
private:
    char*  name = nullptr;
    double area = 1;
    int    population = 0;
public:
    Country() { }
    Country(double area) { this->area = area; }
    Country(const char* name) : Country(name, 1, 0) { }
    Country(const char* name, double area) : Country(name, area, 0) { }
    Country(const char* name, double area, int population);
    const char* getName() const { return name; }
    double      getArea() const { return area; }
    int         getPopulation() const { return population; }
    void        setName(const char* name);
    void        setArea(double area) { this->area = area; }
    void        setPopulation(int population) { this->population = population; }
    double      density() const { return population / area; };
};

Country::Country(const char * name, double area, int population)
{
    this->name = new char[strlen(name) + 1];
    strcpy(this->name, name);
    this->area = area;
    this->population = population;
}

void Country::setName(const char * name)
{
    if (this->name != nullptr)
    {
        delete[] this->name;
    }
    this->name = new char[strlen(name) + 1];
    strcpy(this->name, name);
}

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

Примітка: у коді використано константу nullptr для позначення вказівника, який не вказує ні на що (нуль, або NULL у попередніх версіях); ця константа з'явилася у С++11 і є більш безпечною з точки зору типів вказівників.

Якщо тепер створювати новий об'єкт шляхом копіювання старого (наприклад, Country Ukraine = secondCountry), усталено здійснюватиметься поелементне копіювання всіх елементів даних. Це означає, що у двох об'єктах два вказівники name вказуватимуть на один рядок в динамічній пам'яті. Це призведе до того, що зміна імені в одному об'єкті спричинятиме зміну імені в іншому, а це не відповідає нашому задумові. Слід реалізувати якийсь більш коректний шлях копіювання. Тобто, необхідно власноруч створити конструктор копіювання.

Для явного створення конструктору копіювання класу Country слід додати такий опис:

class Country
{
    . . .
public:
    . . .
    Country(const Country& c);
    . . .
};

Реалізація конструктора копіювання буде такою:

Country::Country(const Country &c)
{
    name = new char[strlen(c.name) + 1];
    strcpy(this->name, c.name);
    area = c.area;
    population = c.population;
}

Тепер назва кожної країни зберігатиметься окремо.

Версія мови C++11 пропонує розширення можливостей конструкторів. Ці можливості будуть розглянуті пізніше.

2.3.5 Деструктори

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

  • для глобальних змінних – після виконання функції main();
  • для локальних змінних – під час виходу з функції (блоку);

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

Якщо ми не створили явного деструктора, автоматично створюється деструктор з порожнім тілом. У нашому випадку слід явно створити деструктор для звільнення динамічної пам'яті:

class Country
{
    . . .
public:
    . . .
    ~Country();
    . . .
};

. . .

Country::~Country()
{
    if (name != nullptr)
    {
        delete[] name;
    }
}

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

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

MyClass f(MyClass t) // Виклик конструктора копіювання
{
    return t;
} // Виклик деструктора

Для того, щоб уникнути копіювання об'єкта та виклику відповідного конструктора, аргумент доцільно описати як посилання:

Country& readCountry(Country& c) // Конструктор не викликається
{
    double area;
    int population;
    cin >> area >> population;
    c.setArea(area);
    c.setPopulation(population);
    return c;
} // Деструктор не викликається

Якщо ми бажаємо захистити об'єкт від модифікації, посилання можна описати з модифікатором const.

2.4 Область видимості класу. Вкладені класи

2.4.1 Область видимості класу

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

Наприклад, можна використовувати цей оператор замість указівника this:

class Country
{
private:
    char*  name = nullptr;
    double area = 1;
    int    population = 0;
public:
    Country(double area) { Country::area = area; }
    // ...
    void setArea(double area) { Country::area = area; }
    void setPopulation(int population) { Country::population = population; }
};

Використання імені класу замість указівника this робить текст менш наочним. Зазвичай таку практику застосовують для роботи зі статичними елементами (будуть розглянуті нижче).

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

Country::setArea(1000); // синтаксична помилка, якщо це зроблено поза класом 

Можна також описати в тілі класу синонім типу за допомогою typedef.

class Country
{
public:
    typedef double areaType;
private:
    areaType area = 1;
    // ... 
};

Можна, наприклад, створити деякий допоміжний клас, який містить лише визначення синонімів типів:

class Typedefs
{
public:
    typedef long int Integer;
    typedef Integer Long;    // синонім
    typedef long long int VeryLong;
    typedef unsigned long int Cardinal;
};

Таке визначення класу аналогічне визначенню простору імен. Всередині класу можна вільно користуватися створеними синонімами, поза класом – через операцію ::.

Typedefs::Integer i;
Typedefs::Long l;
Typedefs::VeryLong v;
Typedefs::Cardinal c;

На відміну від просторів імен, використання префіксів обов'язкове. Для класів немає аналогів using-директив або using-оголошень.

2.4.2 Внутрішні типи

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

class Country
{
public:
    class Capital
    {
    private:
        char name[20] = {};
        int population = 0;
    public:
        Capital() { }
        Capital(const char* name, int population) : population(population) 
            { strcpy(this->name, name); };
        const char* getName() const { return name; }
        void  setName(const char* name) { strcpy(this->name, name); };
        int getPopulation() const { return population; }
        void setPopulation(int population) { this->population = population; }
    };
private:
    // ...
    Capital capital;
public:
    // ...
    Capital getCapital() const { return capital; }
    void setCapital(Capital capital) { this->capital = capital; }
};

Об'єкт класу Capital можна також створити поза класом:

Country someCountry("France", 551500, 67970000);
Country::Capital capitalOfFrance("Paris", 2175600);
someCountry.setCapital(capitalOfFrance);

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

class Country
{
private:
    class Capital
    {
    public:
        char name[20] = {};
        int population = 0;
    };
    // ...
    Capital capital;
public:
    // ...
    const char* getCapitalName() const { return capital.name; }
    void  setCapitalName(const char* name) { strcpy(capital.name, name); };
    int   getCapitalPopulation() const { return capital.population; }
    void setCapitalPopulation(int population) { capital.population = population; }
};

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

2.4.3 Локальні класи

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

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

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

double distance(double x1, double y1, double x2, double y2)
{
    auto sq = [](double a) { return a * a; };
    return std::sqrt(sq(x2 - x1) + sq(y2 - y1));
}

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

#include <iostream>
#include <cmath>

double distance(double x1, double y1, double x2, double y2)
{
    class SecondPower
    {
    public:
        double sq(double a) { return a * a; };
    };
    SecondPower sp;
    return std::sqrt(sp.sq(x2 - x1) + sp.sq(y2 - y1));
}

int main()
{
    std::cout << distance(1, 1, 4, 5) << std::endl;
    return 0;
}

Наявність лямбда-виразів знижує сенс використання таких рішень.

2.5 Статичні елементи класу

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

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

class Country
{
public:
    static char className[20];
    // ...
};

Звертатись до статичних елементів можна як через імена об'єктів, так і імена класів:

. . .

strcpy(Country::className, "Country"); // Через ім'я класу
Country c;
strcpy(c.className, "Contrée"); // Через ім'я об'єкту (французькою мовою)
std::cout << c.className;

Як видно з наведеного прикладу, статичні елементи не потребують створення об'єкта. Якщо ми взаємодіємо зі статичним елементом через об'єкт, всі зміни значень стосуються всього класу, а не окремого об'єкта.

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

char Country::className[20] = {};

Статичні елементи даних не можуть бути описані з модифікатором mutable.

Існують також статичні функції-елементи. Вони не отримують указівника this і тому не мають доступу до нестатичних елементів об'єкта, для якого вони викликані. Наприклад, елемент даних className краще зробити приватним і забезпечити його функціями доступу:

class Country
{
private:
    static char className[20];
public:
    static const char* getClassName() { return className; }
    static void setClassName(const char* className)
        { strcpy(Country::className, className); };
    // ..
};

Робота зі статичним полем тепер здійснюється через методи:

Country::setClassName("Country");
Country c;
c.setClassName("Contrée");
std::cout << c.getClassName();

Елемент даних className, хоч він і є приватним, все одно вимагає визначення в глобальній області видимості:

char Country::className[20] = {};

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

class Math
{
public:
    static double sqr(double x) { return x * x; }
    static double cube(double x) { return x * x * x; }
    static double reciprocal(double x) { return 1 / x; }
    static int factorial(int n);
};

int Math::factorial(int n)
{
    int result = 1;
    for (int k = 2; k <= n; k++)
    {
        result *= k;
    }
    return result;
}

З наведеного коду видно, що статичні функції-елементи теж можуть бути визначені поза класом. При цьому ключове слово static не застосовують перед реалізацією функції.

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

std::cout << Math::sqr(2) << std::endl;        // 4
std::cout << Math::cube(3) << std::endl;       // 27
std::cout << Math::reciprocal(4) << std::endl; // 0.25
std::cout << Math::factorial(5) << std::endl;  // 120

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

class Math
{
private:
    Math() { }
public:
    // ..
};

Тепер спроба створити об'єкт класу Math призведе до синтаксичної помилки.

Math m; // синтаксична помилка

Починаючи з версії мови C++17, статичні дані можуть бути оголошені з модифікатором inline. Такі елементи даних не оголошуються, а визначаються в тілі класу і можуть бути ініціалізовані початковими значеннями. Статичні елементи даних з модифікатором inline не потребують визначення поза тілом класу.

class InlineStatic 
{ 
public:
    static inline int n = 10; 
};

Статичні елементи даних з модифікатором const фактично є константами, значення яких можна вказати безпосередньо в тілі класу, або поза тілом:

class StaticConstants
{
public:
    const static int m = 1;
    const static int k;
};

const int StaticConstants::k = 3;

2.6 Друзі класу

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

Для зручного визначення друзів класи дуже часто доцільно оголошувати заздалегідь. В нашому випадку, якщо ми не хочемо визначати клас Capital як вкладений клас класу Country, ми можемо створити клас Capital окремо і вказати, що у нього є друг – клас Country. Завдяки цьому в методах класу Capital можна використовувати приватні дані класу Capital, але об'єкт (елемент даних) треба створити окремо:

class Capital
{
    friend class Country; // клас оголошено
private:
    char name[20] = {};
    int population = 0;
};

class Country
{
private:
    // ...
    Capital capital;
public:
    // ...
    const char* getCapitalName() const { return capital.name; }
    void  setCapitalName(const char* name) { strcpy(capital.name, name); };
    int   getCapitalPopulation() const { return capital.population; }
    void setCapitalPopulation(int population) { capital.population = population; }
};

Якщо клас Country вказано як дружній у визначенні класу Capital, методи класу Country матимуть доступ до приватних елементів, але не навпаки. Іноді треба реалізувати взаємну дружбу. Тоді один з класів необхідно спочатку оголосити, а потім вказувати його як друга.

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

class Capital;

class Country
{
private:
    char    name[40] = {};
    double  area = 1;
    int     population = 0;
    Capital *pCapital;
public:
    Country();
    ~Country();
    // ...
    const char* getCapitalName() const;
    void  setCapitalName(const char* name);
    int   getCapitalPopulation() const;
    void setCapitalPopulation(int population);
};

class Capital
{
    friend const char* Country::getCapitalName() const;
    friend void  Country::setCapitalName(const char* name);
    friend int   Country::getCapitalPopulation() const;
    friend void Country::setCapitalPopulation(int population);

private:
    char name[20] = {};
    int population = 0;
};

Country::Country()
{
    pCapital = new Capital();
}

Country::~Country()
{
    delete pCapital;
}

const char* Country::getCapitalName() const 
{ 
    return pCapital->name;
}

void Country::setCapitalName(const char* name) 
{ 
    strcpy(pCapital->name, name);
};

int Country::getCapitalPopulation() const
{ 
    return pCapital->population;
}

void Country::setCapitalPopulation(int population)
{
    pCapital->population = population;
}

Можна також визначити другом звичайну функцію, яка не є функцією-елементом іншого класу. Наприклад, можна створити функцію printCapital(), аргументом якої є посилання на об'єкт класу Capital:

class Capital
{
    friend void printCapital(const Capital& capital);
    friend class Country;
private:
    char name[20] = {};
    int population = 0;
};

void printCapital(const Capital& capital)
{
    std::cout << capital.name << " " << capital.population << std::endl;
}

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

class Capital
{
    friend void printCapital(const Capital& capital)
    {
        std::cout << capital.name << " " << capital.population << std::endl;
    }
    friend class Country;
private:
    char name[20] = {};
    int population = 0;
};

Також в тілі класу може бути реалізовано дружній клас цілком.

Якщо X – друг класу Y, Y – друг класу Z, X не є автоматично другом Z. Дружба не успадковується.

2.7 Перевантаження операцій

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

  • Не можна визначити нові операції, такі як ** чи <>.
  • Призначення оператора не можна змінити для вбудованих типів.
  • Пріоритети операторів змінити не можна.
  • Кількість операндів змінити не можна.
  • Якщо оператор може бути використаний як унарний або бінарний оператор (&, *, + та -), кожне використання перевантажується окремо.

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

Операції

  • :: (дозвіл області видимості),
  • . (вибір елемента),
  • .* (вибір функції-елемента через вказівник на функцію),
  • ? : (умовна операція)

не можна перевантажити.

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

Операторна функція повинна бути або нестатичною функцією-елементом класу, або глобальною функцією. Глобальна функція повинна приймати принаймні один аргумент типу, визначеного користувачем, або посилання на такий тип.

Припустимо, створено клас Integer:

class Integer
{
public:
    int n;
    Integer(int n) { this->n = n; }
};

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

Integer operator+(Integer i1, Integer i2)
{
    return Integer(i1.n + i2.n);
}

Тепер до об'єктів типу Integer можна застосовувати операцію +:

void main()
{
    Integer k = 10;
    Integer m = 20;
    Integer sum = k + m;
    cout << sum.n;
}

У попередньому прикладі порушено принцип інкапсуляції. Якщо ми змінимо опис класу і розташуємо n у розділі private, операторну функцію можна оголосити як друга класу:

class Integer
{
    friend Integer operator+(Integer i1, Integer i2);
private:
    int n;
public:
    Integer(int n) { this->n = n; }
    int getN() const  { return n; }
};

Integer operator+(Integer i1, Integer i2)
{
    return Integer(i1.n + i2.n);
}

void main()
{
    Integer k = 10;
    Integer m = 20;
    Integer sum = k + m;
    cout << sum.getN();
}

Функцію також можна повністю реалізувати в тілі класу:

class Integer
{
    friend Integer operator+(Integer i1, Integer i2)
    {
        return Integer(i1.n + i2.n);
    }
private:
   ...
};

Слід пам'ятати, що функція залишається глобальною і не входить в область видимості класу Integer.

Операторні функції можуть бути перевантажені, якщо їх можна розрізнити за списком параметрів. Різні комбінації типів операндів повинні бути реалізовані окремо, наприклад:

class Integer
{
    friend Integer operator+(Integer i1, Integer i2);
    friend Integer operator+(int i1, Integer i2);
    friend Integer operator+(Integer i1, int i2);
    ...
}

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

Якщо операторна функція є елементом класу, кількість її параметрів повинна бути на 1 менше кількості операндів операції, що перевантажується, тому що в цьому випадку першим операндом є сам об'єкт, на який указує this.

Деякі операції

  • = (присвоювання),
  • [] (індексація),
  • () (виклик функції),
  • -> (вибір елемента за вказівником)

повинні бути перевантажені тільки як функції-елементи.

Операторні функції можна викликати явно, наприклад:

Integer x = 1, x1 = 2;
x = operator+(x, x1);

Операторні функції, що реалізують операції введення-виведення (>> і <<), не можуть бути функціями-елементами. Першим параметр повинно бути посилання на об'єкт-потік. Другим параметром повинне бути посилання на об'єкт класу, для якого перевантажується операція. Операторні функції, що реалізують введення-виведення, повинні повертати посилання на потік, отриманий як перший операнд.

ostream& operator<<(ostream& out, const Integer& x);
istream& operator>>(istream& in, Integer& x);

Для перевантаження префіксних операцій ++ та -- використовуються функції-елементи виду

Integer& operator++();
Integer& operator--();

Для перевантаження постфіксних операцій ++ та -- використовуються функції-елементи виду

Integer operator++(int);
Integer operator--(int);

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

Під час перевантаження операції звертання за індексом"[]" потрібно враховувати, що немає обмежень ні на тип параметра, ні на значення, що повертається функцією. Але параметр може бути лише один.

Перевантаження операції виклику функції реалізується через функцію-елемент. Допускається довільна кількість параметрів довільних типів. Наприклад:

class Integer
{
    ...
public:
    Integer(int n) { this->n = n; }

    int operator()() { return n; }
    int operator()(int a, int b, int c) { return a + b + c; }
};

void main()
{
    Integer k = 10;
    Integer m = 20;
    Integer sum = k + m;
    cout << sum() << endl; // 30
    cout << sum(1, 2, 3);  // 6
}

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

X::operator T(); // T - ім'я типу

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

class Integer
{
    int i;
public:
    operator int() { return i; }
    . . .
};

. . .

Integer m = 1;
int k = m; // Використовується елемент даних i
int n = m + k;

2.8 Обробка винятків

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

Можна визначити вимоги до механізму сповіщення про можливі помилки:

  • виключити можливість ігнорування помилки;
  • надати програмісту можливість гнучкого реагування на помилку.

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

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

Механізм генерації та обробки винятків надає шлях розв'язання цієї проблеми.

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

Програма може згенерувати виняток за допомогою оператора throw. Наприклад,

double someFunc(double value)
{
    if (value == 0)               // Помилка
        throw "Division by Zero"; // Генерується виняток типу char*
    return 1 / value;
}

Твердження throw можна порівняти з оператором return. Вираз throw складається з відповідного ключового слова та виразу. Тип виразу визначає тип винятку. Цей тип ніяк не пов'язаний з типом, який повинна повертати функція.

Попередні версії C++ підтримували можливість додавання до заголовка функції списку потенційних винятків. Такий список складався з ключового слова throw і переліку можливих типів винятків у дужках. Наприклад:

double f(double x) throw (int, char*) // працювало в попередніх версіях
{
    . . .
}

Якщо функція не могла взагалі генерувати винятків, це визначалося порожнім списком:

bool g(int x) throw() // працювало в попередніх версіях
{
    . . .
}

Новітні версії C++ не підтримують списків винятків, а починаючи з версії C++20 цю конструкцію вилучено з синтаксису мови. Для позначення того, що функція не може генерувати винятки, застосовують ключове слово noexcept (починаючи з версії С++11):

void g(int x) noexcept
{
}

або

void g(int x) noexcept(true)
{
}

Після генерації здійснюється так зване зворотне розкручування стека, яке можна порівняти з виконанням послідовності тверджень return, кожне з яких повертає той самий об'єкт. Цей об'єкт передається за посиланням до свого обробника. Це виглядає як виконання послідовності операторів return, кожен з яких повертає той самий об’єкт. Цей процес називається розкручуванням стека (stack unwinding).

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

try
{
    double x = 0;
    cout << someFunc(x); // Може бути згенерований виняток
}

Після блоку try повинні знаходитись один або кілька блоків, які обробляють винятки у залежності від типів. Такі оброблювачі складаються з ключового слова catch, за яким у дужках стоїть тип винятку, та програмного блоку. Спеціальна конструкція catch (...) застосовується для створення оброблювача усіх типів винятків. Наприклад:

try 
{
    double x = 0;
    cout << someFunc(x); // може згенерувати виняток
}   
catch (char*) 
{
    // обробляє виняток типу char* 
}
catch (int)
{
    // обробляє виняток типу int
}
catch (...)
{
    // обробляє інші винятки
}

Оброблювач винятку може також вживати змінну-виняток:

try 
{
    double x = 0;
    cout << someFunc(x); // може згенерувати виняток
}   
catch (char* c) 
{
    cout << c; // виведення "Division by Zero" 
}

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

catch (char* c) 
{
    // локальна обробка винятку
    throw;  // повторна генерація винятку 
}

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

class Not_Found {};
class Bad_Data {}; 

Дуже часто такі типи описують в області видимості класів, до яких вони відносяться:

class MyClass 
{
public: 
    class Not_Found {};
    class Bad_Data {};  
    void f(int i)
    {
        if (i < 0) // Створення тимчасового об'єкта
            throw Bad_Data();   	
    }
};

void main() 
{
    try 
    {
        MyClass m;
        m.f(-2);
    }
    catch (MyClass::Bad_Data&) 
    {
        //...
    }
    catch (MyClass::Not_Found&) 
    {
        //...
    }
}

Для підвищення ефективності винятки об'єктного типу описують у блоках catch як посилання.

Для більш зручної обробки помилок можна описати складніші класи винятків, наприклад:

class MyClass
{
public: 
    class Bad_Data 
    {
        int bad_value;
    public:
        Bad_Data(int value) : bad_value(value) {}
        int getBadValue() const { return bad_value; }
    };  
    void f(int i) 
    {
        if (i < 0) 
            throw Bad_Data(i);   	
    }
};

void main() 
{
    try 
    {
        MyClass m;
        m.f(-2);
    }
    catch (MyClass::Bad_Data& b) 
    {
        cout << "Bad value: " << b.getBadValue();
    }
}

2.9 Композиція класів

Об'єкти класів можна робити елементами даних інших класів. Цей процес має назву композиція класів.

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

Іноді для внутрішніх об'єктів необхідно викликати конструктори з параметрами. Це можна зробити з використанням списку ініціалізації. Наприклад:

class X
{
public:
    X(int j) { ... }
    . . .
};

class Y
{
    X x;
public:
    Y(int k) : x(k) { ... }
    . . .
};

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

3.1 Клас для представлення точки на екрані

Наша мета – створити клас для опису точки на екрані. Клас повинен відповідати таким вимогам:

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

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

Сирцевий код буде таким:

#include <iostream>
#include <cmath>

// Клас для представлення точки на екрані
class Point2D
{
    friend double distance(Point2D, Point2D);
private:
    int x, y; // координати точки
public:
    // Конструктор без параметрів викликає інший конструктор:
    Point2D() : Point2D(0, 0) { }
    // Конструктор ініціалізує елементи даних:
    Point2D(int x, int y) : x(x), y(y) { }
    int getX() { return x; }
    void setX(int x) { this->x = x; }
    int getY() { return y; }
    void setY(int y) { this->y = y; }
    // Допоміжна статична функція обчислення другого степеня:
    static double sqr(double x) {  return x * x; }
};

// Обчислення відстані між двома точками
// Дружня функція має доступ до елементів даних класу
double distance(Point2D p1, Point2D p2)
{
    return std::sqrt(Point2D::sqr(p1.x - p2.x) + Point2D::sqr(p1.y - p2.y));
}

int main()
{
    // Першу точку створюємо за допомогою конструктора з параметрами:
    Point2D p1(1, 2);
    std::cout << p1.getX() << " " << p1.getY() << "\n";
    // Для другої точки координати вказуємо сетерами:
    Point2D p2;
    p2.setX(4);
    p2.setY(6);
    std::cout << p2.getX() << " " << p2.getY() << "\n";
    // Обчислюємо відстань:
    std::cout << distance(p1, p2);
}

3.2 Клас для представлення математичного вектору

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

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

#include <iostream>
using std::cin;
using std::cout;
using std::endl;
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);

    // Гетери:
    double getX()       { return x; }
    double getY()       { return y; }

    // Сетери:
    void setX(double x) { this->x = x; }
    void setY(double y) { this->y = y; }
};

// Конструктор з параметрами
Vector::Vector(double x, double y) {
    this->x = x;
    this->y = y;
}

void main()
{
    // Створюємо два об'єкти й читаємо з клавіатури їх координати:
    Vector v1, v2;
    cout << "Input first vector: ";
    cin >> v1; // Наприклад, 1 2
    cout << "Input second vector: ";
    cin >> v2; // Наприклад, 3 4

    // Демонструємо роботу перевантажених операцій:
    cout << v1 + v2 << endl; // x=4 y=6
    cout << v1 * v2 << endl; // 11
    cout << v1 * 2 << endl;  // x=2 y=4
    cout << 3 * v2 << endl;  // x=9 y=12
}

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

3.3 Класи для представлення міста і країни

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

Примітка: це припущення буде знято на подальших етапах розвитку цього рішення.

Клас City матиме такі елементи даних:

  • назва міста;
  • вказівник на країну, в якій знаходиться місто;
  • назва регіону;
  • населення міста.

Назви міста і регіону доцільно розташовувати в динамічній пам'яті. Клас повинен надавати конструктор без параметрів і конструктор з чотирма параметрами. Пам'ять для даних буде звільнятися з динамічної пам'яті у деструкторі. Окрім сетерів і гетерів слід реалізувати перевантажену операцію присвоєння, щоб уникнути помилок з динамічною пам'яттю. Операторну функцію виведення даних про місто в потік реалізуємо як зовнішню функцію – друга класу. Для того, щоб в реалізації виведення можна було скористатися функцією sprintf(), яка форматує рядок (аналогічно printf(), яка виводить рядок на консоль), слід додати визначення #define _CRT_SECURE_NO_WARNINGS.

Для забезпечення сортування міст за кількістю населення слід перевантажити операцію порівняння для двох міст. Таку функцію можна реалізувати як зовнішню. Для нашого алгоритму достатньо перевизначити функцію operator>(). Для інших алгоритмів можуть придатися інші операції порівняння.

У класі Country будуть створені такі елементи даних:

  • назва країни;
  • масив указівників на міста;
  • реальна кількість указівників у масиві.

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

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

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

#define _CRT_SECURE_NO_WARNINGS
#include <cstring>
#include <iostream>

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

const int MAX_COUNT = 100; // Максимальна кількість міст

// Треба заздалегідь оголосити клас, щоб можна було створювати вказівник:
class Country;

// Клас для представлення міста
class City
{
    // Перевантажений оператор для виведення в потік
    friend std::ostream& operator<<(std::ostream& out, const City& city);
private:
    char *name = nullptr;      // назва міста
    Country *country = nullptr;// вказівник на країну розташування
    char *region = nullptr;    // назва регіону
    int population = 0;        // населення
public:
    // Конструктори:
    City() { }
    City(const char* name, Country* country, const char* region, int population);
    City(const City& city);

    ~City(); // деструктор

    // Гетери:
    const char* getName() const { return name; }
    Country* getCountry() const { return country; }
    const char* getRegion() const { return region; }
    int getPopulation() const { return population; }

    // Сетери:
    void setName(const char* name);
    void setRegion(const char* region);
    void setCountry(Country* country) { this->country = country; }
    void setPopulation(int population) { this->population = population; }

    // Перевантажена операція присвоєння
    const City& operator=(const City& city);
};

// Конструктор з параметрами, реалізований через виклик сетерів
City::City(const char* name, Country* country, const char* region, int population)
{
    setName(name);
    setCountry(country);
    setRegion(region);
    setPopulation(population);
}

// Конструктор копіювання
City::City(const City& city)
{
    name = new char[strlen(city.name) + 1];
    strcpy(name, city.name);
    region = new char[strlen(city.region) + 1];
    strcpy(region, city.region);
    country = city.country;
    population = city.population;
}

// Видаляємо з пам'яті назви міста і регіону (якщо масиви створювалися)
City::~City() 
{
    if (name != nullptr)
    {
        delete[] name;
    }
    if (region != nullptr)
    {
        delete[] region;
    }
}

// Видаляємо попередню назву міста, створюємо новий масив і записуємо нову назву
void City::setName(const char* name)
{
    if (this->name != nullptr)
    {
        delete[] this->name;
    }
    this->name = new char[strlen(name) + 1];
    strcpy(this->name, name);
}

// Видаляємо попередню назву регіону, створюємо новий масив і записуємо нову назву
void City::setRegion(const char* region)
{
    if (this->region != nullptr)
    {
        delete[] this->region;
    }
    this->region = new char[strlen(region) + 1];
    strcpy(this->region, region);
}

// Реалізуємо перевантажену операцію присвоєння через виклик сетерів
const City& City::operator=(const City& city)
{
    if (&city != this)
    {
        setName(city.name);
        setCountry(city.country);
        setRegion(city.region);
        setPopulation(city.population);
    }
    return *this;
}

// Перевантажена операція порівняння двох міст
bool operator>(const City& c1, const City& c2)
{
    return c1.getPopulation() > c2.getPopulation();
}

// Клас для представлення країни
class Country
{
    // Перевантажений оператор для виведення в потік
    friend std::ostream& operator<<(std::ostream& out, const Country& country)
    {
        out << country.name << endl;
        for (int i = 0; i < country.count; i++)
        {
            out << *(country.cities[i]) << endl;
        }
        out << endl;
        return out;
    }
private:
    char name[40];                 // назва країни
    City *cities[MAX_COUNT] = { }; // масив указівників на міста
    int count = 0;                 // кількість вказівників у масиві
public:
    // Конструктори:
    Country() { }
    Country(const char* name) { setName(name); }

    const char* getName() const { return name; } // гетер

    // Перевантажений оператор для отримання елементів масиву
    City* operator[](int index) const { return cities[index]; }

    // Сетери:
    void setName(const char* name) { strcpy(this->name, name); }
    void setCities(City* cities[], int  count);

    void sortByPopulation(); // Сортування за населенням
};

// Отримаємо з параметру й заповнюємо масив міст 
void Country::setCities(City* cities[], int  count)
{
    this->count = count;
    for (int i = 0; i < count; i++)
    {
        this->cities[i] = cities[i];
        this->cities[i]->setCountry(this);
    }
}

// Сортування за населенням
void Country::sortByPopulation()
{
    bool mustSort = true; // повторюємо сортування 
                          // якщо mustSort дорівнює true
    do
    {
        mustSort = false;
        for (int i = 0; i < count - 1; i++)
        {
            // Здійснюємо розіменування, 
            // бо порівнювати можна об'єкти, а не вказівники:
            if (*(cities[i]) > *(cities[i + 1]))
                // Обмiнюємо елементи
            {
                City* temp = cities[i];
                cities[i] = cities[i + 1];
                cities[i + 1] = temp;
                mustSort = true;
            }
        }
    } while (mustSort);
}

// Перевантажений оператор для виведення в потік
std::ostream& operator<<(std::ostream& out, const City& city)
{
    char buffer[300];
    sprintf(buffer, "Мiсто: %s.\tКраїна: %s.\tРегіон: %s.\tНаселення: %d",
        city.name, city.country->getName(), city.region, city.population);
    out << buffer;
    return out;
}

// Допоміжна функція для заповнення масиву вказівників на міста
void createCities(City *cities[])
{
    cities[0] = new City("Харкiв", nullptr, "Харкiвська область", 1421125);
    cities[1] = new City("Полтава", nullptr, "Полтавська область", 284942);
    cities[2] = new City("Лозова", nullptr, "Харкiвська область", 54618);
    cities[3] = new City("Суми", nullptr, "Сумська область", 264753);
}

int main()
{
    setlocale(LC_ALL, "UKRAINIAN");
    const int realCount = 4;     // працюємо з чотирма містами
    City *cities[realCount];     // створюємо масив вказівників на міста   
    createCities(cities);        // заповнюємо масив
    Country country = "Україна"; // створюємо об'єкт "Країна",
                                 // викликаємо конструктор з одним параметром
    country.setCities(cities, realCount); // копіюємо міста в об'єкт "Країна"
    cout << country << endl;     // виводимо всі дані
    cout << *country[0] << endl; // виводимо інформацію про місто за індексом
    country.sortByPopulation();  // здійснюємо сортування
    cout << country << endl;     // виводимо всі дані
    // Видалення міст, на які вказують вказівники в масиві cities
    for (int i = 0; i < realCount; i++)
    {
        delete cities[i];
    }
    return 0;
}

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

3.4 Клас для представлення одновимірного масиву

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

#include <iostream>

using std::cin;
using std::cout;
using std::endl;
using std::istream;
using std::ostream;

// Клас для представлення одновимірного масиву
class IntArray
{
    // Дружні функції перевантаження операцій виведення та введення:
    friend ostream& operator<<(ostream& out, const IntArray& a);
    friend istream& operator>>(istream& in, IntArray& a);
private:
    int* pa = nullptr; // вказівник на майбутній масив
    int  size = 0;     // поточний розмір масиву
public:
    // Вкладений клас для створення об'єкту-винятку
    class OutOfBounds
    {
        int index; // індекс за межами діапазону
    public:
        OutOfBounds(int i) : index(i) { }      // конструктор
        int getIndex() const { return index; } // гетер для індексу
    };
    
    // Конструктори:
    IntArray() { }
    IntArray(int n) { pa = new int[size = n]; }
    IntArray(IntArray& arr);

    ~IntArray(); // деструктор
    void addElem(int elem);     // функція додавання елемента
    int& operator[](int index); // доступ до елементів за читанням і записом

    // Перевантажені операції:
    const IntArray& operator=(const IntArray& a);
    bool operator==(const IntArray& a) const;

    int getSize() const { return size; } // повертає кількість елементів масиву
};

// Перевантажена операція виведення в потік
ostream& operator<<(ostream& out, const IntArray& a)
{
    for (int i = 0; i < a.size; i++)
    {
        out << a.pa[i] << ' ';
    }
    return out;
}

// Перевантажена операція читання з потоку
istream& operator>>(istream& in, IntArray& a)
{
    for (int i = 0; i < a.size; i++)
    {
        in >> a.pa[i];
    }
    return in;
}

// Конструктор копіювання
IntArray::IntArray(IntArray& arr)
{
    size = arr.size;
    pa = new int[size];
    for (int i = 0; i < size; i++)
    {
        pa[i] = arr.pa[i];
    }
}

// Деструктор
IntArray::~IntArray()
{
    if (pa != nullptr)
    {
        delete[] pa;
    }
}

// Додавання елемента. Розташування масиву на новому місці 
void IntArray::addElem(int elem)
{
    int* temp = new int[size + 1];
    if (pa != nullptr)
    {
        for (int i = 0; i < size; i++)
        {
            temp[i] = pa[i];
        }
        delete[] pa;
    }
    pa = temp;
    pa[size] = elem;
    size++;
}

// Забезпечує доступ до елементів за читанням і записом
// Генерує виняток OutOfBounds у випадку хибного індексу
int& IntArray::operator[](int index)
{
    if (index < 0 || index >= size)
    {
        throw OutOfBounds(index);
    }
    return pa[index];
}

// Перевантажена операція присвоєння масиву елементів іншого
const IntArray& IntArray::operator=(const IntArray& a)
{
    if (&a != this)
    {
        if (pa != nullptr)
        {
            delete[] pa;
        }
        size = a.size;
        pa = new int[size];
        for (int i = 0; i < size; i++)
        {
            pa[i] = a.pa[i];
        }
    }
    return *this;
}

// Перевантажена операція порівняння масивів
bool IntArray::operator==(const IntArray& 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;
}

// Глобальна функція знаходження мінімального елемента масиву
// Функція не має прямого доступу до даних и використовує
// перевантажені операції й функції-елементи
int getMin(IntArray a) // викликаємо конструктор копіювання
{
    int min = a[0];
    for (int i = 1; i < a.getSize(); i++)
    {
        if (min > a[i])
        {
            min = a[i];
        }
    }
    return min;
}

void main()
{
    setlocale(LC_ALL, "UKRAINIAN");
    IntArray a(2); // Масив з двох елементів
    cout << "Введiть два елементи масиву: ";
    cin >> a;
    cout << "Елементи масиву: " << a << endl;
    a.addElem(12);
    cout << "Додаємо елемент" << endl;
    cout << "Елементи масиву: " << a << endl;
    try
    {
        a[1] = 2;   // змінили
        a[10] = 35; // хибний індекс
    }
    catch (IntArray::OutOfBounds& e)
    {
        cout << "Хибний індекс: " << e.getIndex() << endl;
    }
    cout << "Новi елементи: " << a << endl;
    IntArray b; // створили новий масив
    b = a;      // скопіювали елементи
    if (a == b)
    {
        cout << "Масиви a i b однаковi" << endl;
    }
    else
    {
        cout << "Масиви a i b різнi";
    }
    cout << "Мiнiмальний елемент: " << getMin(a) << endl;

}

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

3.5 Підрахунок створених об'єктів

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

#include <iostream>
using std::cout;
using std::endl; 

class ObjectCount
{
private:
    static int count;
public:
    static int getCount()
    {
        return count;
    }
    ObjectCount()
    {
        count++;
    }
    ~ObjectCount()
    {
        count--;
    }
};

// Статичний елемент даних слід визначити й ініціалізувати поза межами класу:
int ObjectCount::count = 0;

void main()
{
    ObjectCount c1;
    cout << c1.getCount() << endl;  // 1
    ObjectCount *p1 = &c1;  // копіюємо адресу, конструктор не викликається
    cout << p1->getCount() << endl; // 1
    ObjectCount *p2 = new ObjectCount();
    cout << p2->getCount() << endl; // 2
    delete p2;
    cout << p2->getCount() << endl; // 1
}

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

void main()
{
    ObjectCount c1;
    cout << ObjectCount::getCount() << endl; // 1
    ObjectCount *p1 = &c1;
    cout << ObjectCount::getCount() << endl; // 1
    ObjectCount *p2 = new ObjectCount();
    cout << ObjectCount::getCount() << endl; // 2
    delete p2;
    cout << ObjectCount::getCount() << endl; // 1
}

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

  1. Створити клас "Пара рядків" з необхідними конструкторами та функціями доступу.
  2. Створити клас "Комплексне число " з необхідними конструкторами та функціями доступу. Перевантажити операції +, -, *, / і введення-виведення.
  3. Створити клас "Вектор у N-вимірному просторі" з необхідними конструкторами та функціями доступу. Перевантажити операції +, -, *, / і введення-виведення.
  4. Створити клас "Квадратне рівняння" з елементами даних – коефіцієнтами рівняння, необхідними конструкторами та функціями доступу. Реалізувати функцію знаходження коренів.

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

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

 

up