Лабораторна робота 1
Створення та використання класів C++
1 Завдання на лабораторну роботу
1.1 Клас для представлення простого дробу
Створити клас для представлення простого дробу. Реалізувати конструктори, функцію скорочення дробу, а також перевантажити операції +, -, *, /, введення та виведення. Здійснити демонстрацію можливостей класу в функції main()
.
1.2 Клас для представлення двовимірного масиву
Розробити клас для представлення двовимірного масиву (матриці) цілих чисел довільних розмірів. Створити конструктори та деструктор, перевантажити операції додавання, віднімання і множення (згідно з правилами роботи з матрицями), звертання за індексом, введення з потоку та виведення в потік. Створити власні класи винятків та генерувати відповідні об'єкти-винятки, якщо неможливо виконати ту чи іншу операцію.
Створити окрему функцію, яка отримує посилання на матрицю і виконує над масивом дії, вказані в таблиці. Функція не повинна бути методом класу або дружньою функцією.
Номер варіанту (номер студента у списку) |
Правило перетворення елементів масиву | Кількість рядків m |
Кількість стовпців n |
|
---|---|---|---|---|
1 |
15 |
Усі елементи з непарними значеннями повинні бути збільшені в два рази | 4 |
3 |
2 |
16 |
Усі елементи з парними значеннями повинні бути замінені їх квадратами | 3 |
5 |
3 |
17 |
Усі елементи з нульовим значенням слід замінити одиницями | 3 |
4 |
4 |
18 |
Усі елементи з парними значеннями повинні бути збільшені в два рази | 4 |
5 |
5 |
19 |
Усі елементи повинні бути замінені їх абсолютними величинами | 5 |
4 |
6 |
20 |
Усі елементи з парними значеннями повинні бути збільшені в три рази | 3 |
3 |
7 |
21 |
Усі додатні елементи повинні бути замінені з цілими частинами їх десяткових логарифмів | 4 |
5 |
8 |
22 |
Усі від'ємні елементи повинні бути замінені їх квадратами | 4 |
4 |
9 |
23 |
Усі додатні елементи повинні бути замінені з цілими частинами їх натуральних логарифмів | 5 |
4 |
10 |
24 |
Усі додатні елементи повинні бути замінені з цілими частинами їх квадратних коренів | 3 |
5 |
11 |
25 |
Всі додатні елементи з парними значеннями повинні бути збільшені в два рази | 5 |
4 |
12 |
26 |
Усі від'ємні елементи з непарними значеннями повинні бути збільшені в три рази | 3 |
4 |
13 |
27 |
Усі від'ємні елементи з непарними значеннями повинні бути збільшені в два рази | 4 |
3 |
14 |
28 |
Усі додатні елементи з парними значеннями повинні бути збільшені в три рази | 3 |
5 |
У функції main()
здійснити тестування всіх можливостей класу з перехопленням можливих винятків, а також розв'язати індивідуальну задачу.
1.3 Підрахунок суми введених значень
Створити клас з одним закритим елементом даних цілого типу, геттером і конструктором з одним параметром. В цьому ж класі створити закрите статичне поле, яке зберігає суму цілих елементів даних всіх раніше створених об'єктів. Під час кожного виклику конструктора до статичного поля повинно додаватися нове значення. Статична публічна функція цього ж класу повинна повертати цю суму.
У функції main()
створити декілька об'єктів і вивести отриману суму.
2 Методичні вказівки
2.1 Користувацькі типи. Переліки, структури й об'єднання
Мова C++ дозволяє створювати власні типи, використання яких може бути не менш зручним і більш виразним, ніж використання існуючих стандартних типів.
У більшості випадків для створення користувацьких типів застосовують класи. Але іноді також можна використовувати інші мовні конструкції – переліки, структури й об'єднання.
2.1.1 Переліки
Тип переліку визначає набір цілих констант. Перелік може бути визначений за допомогою ключового слова enum
, після якого у фігурних дужках розміщують список елементів, розділених комами. Перша константа приймає усталене значення 0, наступна -
1 тощо.
Можна створити безіменні та іменовані переліки. Безіменний перелік фактично є списком констант. Наприклад,
enum { red, green, blue };
Такий перелік еквівалентний визначенню констант:
const int red = 0; const int green = 1; const int blue = 2;
Необхідні значення можна визначити явно. Значення наступних елементів обчислюють шляхом додавання одиниці:
enum { one = 1, three = 3, four, nine = 9, ten }; // four == 4, ten == 10
Іменований перелік визначає новий тип даних. Цей новий тип може бути використаний для визначення змінних:
enum Colors { red, green, blue }; Colors c = blue;
На відміну від опису typedef
, типи даних, які визначає користувач, зокрема перелік, не є синонімами існуючих типів. Наприклад, в нашому випадку не можна присвоїти цілі значення змінним типу Colors
, тільки значення red
, green
та blue
. Але навпаки, присвоєння цілим змінним значень типу Colors
є цілком коректним. Змінні таких типів можна використовувати у виразах.
Colors c; c = 1; // Помилка! c = green; // OK int i = c; // OK i = c + 1; // OK
Для переліків можна перевантажувати деякі операції, наприклад ++
та --
. Відповідний приклад буде розглянуто нижче.
2.1.2 Структури й об'єднання
Структури дозволяють об'єднати кілька елементів даних різних типів. З такою групою можна працювати як з одним цілим. Після визначення типу структури можна описати відповідну змінну.
struct City { char name [20]; long population; }; // Крапка з комою обов'язкова City Kharkiv; // Kharkiv - змінна типу City
Структури найчастіше визначаються у глобальній області видимості. Синтаксис мови C++ дозволяє створювати змінні безпосередньо після визначення типу.
struct City { char name [20]; long population; } Kharkiv; // Kharkiv - змінна типу City
Однак таке визначення не слід вважати доцільним.
На відміну від масивів, операція присвоєння здійснює поелементне копіювання структури.
City someCity; someCity = Kharkiv; // Поелементне копіювання
Для доступу до елементів структури використовують операцію ".
". Спеціальний оператор ->
використовують для того, щоб отримати доступ до елементів через указівник на структуру:
cout << someCity.name; City *pc = new City; pc->population = 10000000;
Якщо структура містить набір булевих елементів, або цілих, які можуть приймати дуже невеликі значення, такі елементи можна описати як так звані бітові поля. Опис бітових полів складається з типу, імені, двокрапки та розміру поля у бітах. Наприклад:
struct Flags { unsigned int logical : 1; // Один біт unsigned int tinyInt : 3; // Дуже мале ціле };
Використання бітових полів дозволяє більш раціонально використовувати оперативну пам'ять. Але це має сенс тільки якщо такі поля розташовані послідовно. Бітові поля також можна використовувати в описі класів.
На відміну від мови програмування C, структури у C++ можуть мати функції-елементи. Взагалі структури підтримують увесь синтаксис класів. Єдина відмінність полягає в тому, що усталено елементи структури є відкритими, але це можна змінити за допомогою відповідних директив.
Об'єднання (union
) – це структура, у якій всі елементи розташовані за однією адресою. В кожен момент у об'єднанні може зберігатися значення тільки одного з елементів. У наведеному нижче прикладі об'єкт об'єднання може зберігати дійсне або ціле значення, але не одночасно обидва. Спроба присвоїти значення другому елементу призводить до псування значення першого елемента, оскільки насправді пам'ять виділена тільки для одного зі значень.
union FloatAndInt { float f; int i; }; FloatAndInt fi; fi.i = 2; fi.f = 100; cout << fi.i << endl; // 1120403456 fi.i = 3; cout << fi.f << endl; // 4.2039e-45
Об'єднання може бути використане для створення масивів, елементи яких мають різні типи. Крім того, можна використати об'єднання для кодування інформації, а також для аналізу внутрішнього представлення даних. У наведеному вище прикладі число 1120403456, якщо його перевести у двійкову систему, показує внутрішнє представлення числа 100.0 з плаваючою крапкою.
Можна створювати анонімні об'єднання:
union { int a; char* p; }; a = 1; p = "name"; // a і p немає сенсу використовувати одночасно
Об'єднання може містити функції-елементи.
Можливості застосування об'єднань істотно обмежені. Використання поліморфізму надає більш коректний спосіб для зберігання в масивах даних різних типів, тому зараз об'єднання використовують не дуже часто.
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(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(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(57981000); cout << someCountry.density(); }
Функції-елементи мають доступ до інших елементів класу через так званий вказівник this
. Функції-елементи отримують цей вказівник як неявний аргумент. Цей вказівник вказує на об'єкт, для якого викликана функція-елемент. Цей вказівник неявно передається кожній функції-елемнту під час виклику. Наприклад, реалізація функції density()
може бути такою:
double Country::density() { return this->population / this->area; }
В цьому випадку використання this
не має практичного сенсу, але демонструє механізм роботи функції-елемента з елементами даних. Найчастіше вказівник this
застосовують для того, щоб уникнути конфліктів імен. Наприклад, за угодою імена параметрів сеттерів збігаються з іменами елементів даних. У цьому випадку ім'я без this
– це ім'я параметру (локальної змінної), а з this
– ім'я елемента даних:
void setName(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; }
Є спеціальний різновид функцій-елементів -
так звані константні функції-елементи. Такі функції не можуть змінити даних об'єкту, для якого вони викликані. Для того, щоб описати таку функцію, після списку аргументів слід розмістити модифікатор 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
, який вказує на константний об'єкт.
2.3 Конструктори та деструктори
На відміну від процедурного підходу в якому певні дії, зокрема, виклик функцій, здійснюються в порядку, визначеному автором програми, об'єктно-орієнтований підхід надає клас для створення об'єктів і передбачає можливість виклику функцій-елементів у довільному порядку. Це означає, що створений об'єкт повинен бути готовий до роботи одразу і всі елементи даних повинні бути ініціалізовані.
У класів є спеціальні функції-елементи, які називаються конструкторами. Конструктори призначені для ініціалізації даних об'єктів. Конструктори автоматично викликаються під час створення об'єкту. Для різних об'єктів є різні варіанти виклику конструкторів:
- для глобальних змінних – до виконання функції
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
реалізовано в тілі класу. Не зважаючи на те, в тілі конструктора здійснюється ініціалізація лише одного елемента даних, усі інші дані ініціалізуються усталеними значеннями для відповідних типів – нулями для цілих і вказівників, нульовими елементами масивів тощо. Конструктори можна реалізовувати поза тілом класу:
Country::Country(const char * name) { strcpy(this->name, name); area = 1; // істотно, щоб територія не мала значення 0 // і не виникало помилки у функції density() } Country::Country(const char * name, double area) { strcpy(this->name, name); this->area = area; } Country::Country(const char * name, double area, int population) { strcpy(this->name, name); this->area = area; this->population = population; }
Найпростіший конструктор -
це усталений (default) конструктор. Він не має параметрів. Якщо конструктор без параметрів не визначений явно, компілятор створює такий конструктор автоматично. Такий конструктор здійснює ініціалізацію елементів даних усталеними значеннями (нулями). Якщо у класі визначений хоча б один явний конструктор, компілятор не створює усталеного конструктора автоматично. В наведеному вище прикладі конструктор без параметрів автоматично не створюється, тому, якщо такий конструктор потрібен, його слід додати вручну. Можна вказати ті значення, усталені значення яких повинні візрізнятися від 0, наприклад:
Country() { area = 1; }
Під час створення об'єктів виклик конструктора може бути неявним або явним:
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, 81338000); // з трьома параметрами, // розташування об'єкта в динамічній пам'яті
Якщо, наприклад, якась зовнішня функція передбачає отримання параметра типу об'єкта класу, її можна, наприклад, викликати, створивши безіменний об'єкт (з явним викликом конструктора):
void addToDatabase(Country c); // прототип функції // ... addToDatabase(Country("Sweden", 450000, 8745000));
Можна здійснювати ініціалізацію об'єкта іншим об'єктом того ж типу. В цьому випадку викликається спеціальний конструктор -
так званий конструктор копіювання. Такий конструктор, якщо його не перекрили, створюється автоматично. Він здійснює поелементне копіювання даних раніше створеного об'єкта в новий. Наприклад:
Country Ukraine = secondCountry;
Конструктор копіювання можна створити явно. Це необхідно, наприклад, коли серед елементів даних класу є вказівник на певні дані в динамічній пам'яті. Попередня версія класу Country
не потребує створення явного конструктора копіювання. Але, наприклад, назву країни можна розташувати в динамічній пам'яті. Оскільни довжина назв країн дуже різна, такий підхід заощадить пам'ять. Нова версія класу Country
буде такою:
class Country { private: char* name; double area; int population; public: Country() { area = 1; } Country(double area) { this->area = area; } Country(const char* name); Country(const char* name, double area); 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) { this->name = new char[strlen(name) + 1]; strcpy(this->name, name); } Country::Country(const char * name, double area) { this->name = new char[strlen(name) + 1]; strcpy(this->name, name); this->area = 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 != 0) { delete[] this->name; } this->name = new char[strlen(name) + 1]; strcpy(this->name, name); }
Тепер у всіх конструкторах, які отримують назву країни, ім'я буде розташоване в динамічній пам'яті. Крім того, реалізація функції setName()
передбачає перевірку, чи не було ім'я вже розташоване в динамічній пам'яті зі звільненням тієї пам'яті, якщо треба.
Якщо тепер створювати новий об'єкт шляхом копіювання старого (наприклад, 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; }
Тепер назва кожної країни зберігатиметься окремо.
Деструктор -
це спеціальна функція-елемент, яка має ім'я ~Ім'яКласу
та автоматично викликається перед тим, як об'єкт має бути знищений. Деструктор не може мати параметрів. Деструктори глобальних і локальних змінних викликаються у порядку, зворотному виклику конструкторів:
- для глобальних змінних – після виконання функції
main()
; - для локальних змінних – під час виходу з функції (блоку);
Для об'єктів, які розташовані в динамічній пам'яті деструктори викликаються перед звільненням динамічної пам'яті операцією delete
.
Якщо ми не створили явного дестурктору, авторматично створюеться деструктор з порожнім тілом. У нашому випадку слід явно стоврити дестурктор для звільнення динамічної пам'яті:
class Country { . . . public: . . . ~Country(); . . . }; . . . Country::~Country() { if (name != 0) { 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 Область видимості класу
Клас визначає свою область видимості. Усі елементи класу входять до його області видимості. Іноді до елементів класової області видимості можна звертатись за допомогою оператору ::
, який застосовується до імені класу. Наприклад, можна використовувати цей оператор замість указівника this
:
class X { int i; public: void f() { int i; i = X::i; } };
Можна також описати в тілі класу синонім типу за допомогою typedef
:
class Z { public: typedef double real; void f() { real r = 1; cout << r; } }; Z::real x = 0;
Клас може бути описаний у глобальній області видимості, а також у класовій та локальній областях. Класи, які визначені всередині інших класів, мають назву внутрішніх. Об'єкт внутрішнього класу не створюється автоматично у зовнішньому класі. Такий об'єкт необхідно створювати окремо:
class X { public: class Y { public: int t; }; private: class Z { public: int w; }; Z z; // Об'єкт внутрішнього класу }; X::Y y; // OK X::Z z; // Помилка! Немає доступу до Z
2.5 Статичні елементи класу
Іноді нам необхідно створити змінні, які логічно мають відношення до певного класу, але їх не доцільно робити елементами даних об'єктів, бо вони повинні бути спільними для усіх об'єктів. Статичні елементи даних пропонують спосіб розміщення глобальних змінних в області видимості класу. Звертатись до таких елементів можна як через імена об'єктів, так і імена класів:
class X { . . . public: static int i; }; . . . X::i = 10; // Через ім'я класу X x; x.i = 11; // Через ім'я об'єкту
Як видно з наведеного прикладу, статичні елементи не потребують створення об'єкту.
Статичні елементи даних не можуть бути описані з модифікатором mutable
.
Існують також статичні функції-елементи. Вони не отримують указівника this
і тому не мають доступу до нестатичних елементів об'єкту, для якого вони викликані.
Статичні елементи даних повинні бути визначені у глобальній області видимості.
class X { static int x; static const int size = 5; class Inner // Внутрішній клас { static float f; void func(void); }; public: char array[size]; }; int X::x = 1; // Ініціалізація статичного елементу float X::Inner::f = 3.14; // Ініціалізація статичного елементу void X::Inner::func(void) { // . . . }
Можна визначити статичні константи:
class X { public: static int count; }; int X::count = 100;
Локальні класи не можуть мати статичних елементів.
2.6 Друзі класу
Функції або класи, оголошені як друзі класу, мають доступ до його закритих та захищених елементів. Друзі не є частиною області видимості класу та не отримують указівника this
.
class SomeClass { friend void f(SomeClass &sc); private: int i; }; void f(SomeClass &sc) { cout << sc.i; // Доступ до закритого елементу даних } void main() { SomeClass s1; f(s1); }
Дружні функції можуть бути реалізовані у тілі класу. Такі функції мають неявний модифікатор inline
. Друзі можуть бути оголошені у будь-якій частині класу (public
, private
або protected
). Це не впливає на механізм доступу.
Можна здійснити оголошення класу без визначення. Завдяки цьому можна декларувати взаємну дружбу:
class Y; // оголошення класу class X { friend Y; . . . }; class Y; { // визначення класу friend X; . . . };
Функції-елементи класу X
можуть бути оголошені як друзі класу Y
:
class X { . . . void member_funcX(); }; class Y { friend void X::member_funcX(); . . . };
Якщо X
-
друг класу Y
, Y
-
друг класу Z
, X
не є автоматично другом Z
. Дружба не успадковується.
2.7 Обробка винятків
Дуже часто функція програми, у якій виникає певна помилка, не має можливості виправити цю помилку, бо невідомим є контекст виклику цієї функції. Помилку необхідно передати до тієї частини програми, у якій її можна обробити.
Механізм генерації та обробки винятків надає шлях вирішення цієї проблеми.
Програма може згенерувати виняток за допомогою оператора throw
. Наприклад,
double someFunc(double value) { if (value == 0) // Помилка throw "Division by Zero"; // Генерується виняток типу char* return 1 / value; }
Твердження throw
можна порівняти з оператором return
. Вираз throw
складається з відповідного ключового слова та виразу. Тип виразу визначає тип винятку. Цей тип ніяк не пов'язаний з типом, який повинна повертати функція.
Опис функції, яка генерує виняток, може містити список типів цих винятків. Наприклад:
double someFunc(double value) throw (char*) { . . . }
або в оголошенні:
void f() : throw (int, char*);
Список винятків функції є частиною її заголовку. Його треба наводити в усіх оголошеннях функції. Якщо функція не може взагалі генерувати винятків, це визначається порожнім списком:
bool g(int, int) throw(); // порожній список винятків
На жаль, компілятори не перевіряють відповідності списку винятків.
Після генерації здійснюється так зване зворотне розкручування стеку, яке можна порівняти з виконанням послідовності тверджень return
, кожне з яких повертає той самий об'єкт. Цей об'єкт передається за посиланням до свого обробника.
Блок 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) { //. . . } }
Для більш зручної обробки помилок можна описати більш складні класи винятків:
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.8 Перевантаження операцій
Під час проектування класу можна визначити набір операцій, які можна виконувати над об'єктами. Визначення операції для об'єкта класу, структури чи перелічення здійснюється за допомогою так званої операторної функції. Ім'я операторної функції складається зі службового слова operator
, за яким міститься одна з визначених операцій С++.
Операторна функція не обов'язково повинна бути функцією-елементом. Якщо вона реалізована як звичайна функція, то принаймні один з її параметрів повинний бути типу, визначеного користувачем. Отже, призначення оператора не можна змінити для вбудованих типів.
Автор класу може визначити (перевантажити) всі операції, визначені в С++, крім чотирьох: "::
", ".*
", ".
" та "?:
". Не можна задати нову операцію, наприклад **
чи <>
. Визначені пріоритети операторів змінити не можна. Повинне зберігатися визначене число аргументів операції. Не можна задавати усталені значення параметрів для операторних функцій. Чотири операції ("+
","-
","*
" і "&
") можуть використовуватися і як бінарні, і як унарні.
Якщо операторна функція реалізується як функція-елемент класу, то вважається, що лівий операнд функції -
об'єкт, на який указує this
. Якщо потрібно, щоб лівий операнд був іншого типу, то операторна функція не може бути функцією-елементом.
Якщо операторна функція є елементом класу, кількість її параметрів повинна бути на 1 менше кількості операндів операції, що перевантажується, тому що в цьому випадку першим операндом є сам об'єкт. Деякі операції -
присвоювання "=
", індексація "[]
", виклик функції "()
" і вибір елемента "->
" повинні бути перевантажені тільки як функції-елементи.
Операторні функції можуть бути перевантажені, якщо їх можна розрізнити за списком параметрів.
Операторі функції, що реалізують операції введення-виведення (>>
і <<
), не повинні бути функціями-елементами. Першим параметр повинно бути посилання на об'єкт-потік (ostream
для операції виведення й istream
для введення). Другим параметром повинне бути посилання на об'єкт класу, для якого перевантажується операція. Для операції виведення таке посилання може бути посиланням на константний об'єкт. Операторні функції, що реалізують введення-виведення, повинні повертати посилання на потік, отриманий як перший операнд.
Для перевантаження префіксних операцій ++
та --
використовуються функції-елементи виду
X& X::operator++(); X& X::operator--();
Для перевантаження постфіксних операцій ++
та --
використовуються функції-елементи виду
X X::operator++(int); X X::operator--(int);
Операція присвоювання перевантажується в тих випадках, коли поелементне копіювання об'єктів приводить до виникнення помилки. Якщо виникла необхідність у визначенні конструктора копіювання або деструктора, слід також перевантажити операцію присвоювання.
Під час перевантаження операції звертання за індексом"[]
" потрібно враховувати, що немає обмежень ні на тип вхідного параметра, ні на значення, що повертається функцією.
Перевантаження операції виклику функції реалізується через функцію-елемент. Допускається довільна кількість параметрів довільних типів.
Операторні функції можна викликати явно, наприклад:
X x, x1; x = x.operator+(x1);
Існує особливий вид операторних функцій -
операції перетворення типів, що дозволяють перетворювати об'єкт у заданий тип. Операція перетворення повинна бути реалізована у вигляді функції-елемента й у загальному випадку вона має такий вигляд:
X::operator T(); // T - ім'я типу
Для операції перетворення не можна задавати тип значення, що повертається, чи вказувати список формальних параметрів. Наприклад, завдяки операції перетворення припустимі такі дії:
class X { int i; public: operator int() { return i; } . . . }; . . . X x; int k = x; // Використовується елемент даних i int n = x + k
Перевантаження операцій можна також здійснювати для переліків. Найчастіше перевантажують операції ++
і --
. Припустимо, є тип переліку:
enum DayOfWeek { Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday };
Для перевантаження префіксної операції ++
в тому ж просторі імен, де визначено тип DayOfWeek
, повинна бути реалізована така функція:
DayOfWeek& operator++(DayOfWeek& d) { switch (d) { case Sunday: d = Monday; break; case Monday: d = Tuesday; break; case Tuesday: d = Wednesday; break; case Wednesday: d = Thursday; break; case Thursday: d = Friday; break; case Friday: d = Saturday; break; case Saturday: d = Sunday; } return d; }
Для перевантаження постфіксної операції ++
необхідно реалізувати функцію з двома параметрами:
DayOfWeek& operator++(DayOfWeek& d, int) { DayOfWeek dOld = d; operator++(d); // викликаємо попередню функцію return dOld; }
У списку формальних параметрів int
– тип параметра який не використовується, але його наявність дозволяє компілятору відрізнити постфіксну операцію від префіксної.
2.9 Композиція класів
Об'єкти класів можна робити елементами даних інших класів. Цей процес має назву композиція класів.
Конструктори класів, об'єкти яких розміщені у зовнішньому класі, викликаються до виконання конструктору зовнішнього класу. Виклик конструкторів без параметрів здійснюється автоматично. Конструктори завжди викликаються у порядку, у якому об'єкти оголошені у класі. Деструктори викликаються у зворотному порядку.
Іноді для внутрішніх об'єктів необхідно викликати конструктори з параметрами. Це можна зробити з використанням так званого списку ініціалізації. Цей список розміщується перед тілом конструктору. Наприклад:
class X { public: X(int j) { ... } . . . }; class Y { X x; public: Y(int k) : x(k) { ... } . . . };
За допомогою списку ініціалізації можна також встановлювати початкові значення для елементів вбудованих типів:
class X { int k; double d; public: X(int j, double h) : k(j), d(h) { ... } . . . };
3 Приклади програм
3.1 Клас для представлення математичного вектору
Припустимо, нам необхідно створити клас для представлення математичного вектору в двовимірному просторі. Описати вектор можна двома координатами – 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; } void setX(double x) { this->x = x; } double getY() { return y; } 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.2 Клас для представлення одновимірного масиву
В наведеному нижче прикладі створюється клас для представлення одновимірного масиву з перевантаженням необхідних операцій:
#include <iostream> using std::cout; using std::endl; using std::istream; using std::ostream; class IntArray { friend ostream& operator<<(ostream& out, const IntArray& a) { for (int i = 0; i < a.size; i++) out << a.pa[i] << ' '; return out; } friend istream& operator >> (istream& in, IntArray& a) { for (int i = 0; i < a.size; i++) in >> a.pa[i]; return in; } private: int *pa; int size; public: class OutOfBounds { int index; public: OutOfBounds(int i) : index(i) { } int getIndex() const { return index; } }; IntArray() { pa = 0; size = 0; } IntArray(int n); IntArray(IntArray& arr); ~IntArray() { if (pa) delete[] pa; } 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; } }; IntArray::IntArray(int n) { pa = new int[size = n]; } IntArray::IntArray(IntArray& arr) { size = arr.size; pa = new int[size]; for (int i = 0; i < size; i++) pa[i] = arr.pa[i]; } void IntArray::addElem(int elem) { int *temp = new int[size + 1]; if (pa) { for (int i = 0; i < size; i++) temp[i] = pa[i]; delete[] pa; } pa = temp; pa[size] = elem; size++; } 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) 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() { IntArray a; a.addElem(11); a.addElem(12); cout << a << endl; try { a[1] = 2; a[10] = 35; } catch (IntArray::OutOfBounds e) { cout << "Bad index: " << e.getIndex() << endl; } cout << getMin(a) << endl; }
Як видно з прикладу, робота з об'єктом поза класом дуже схожа на роботу зі звичайним масивом.
3.3 Підрахунок створених об'єктів
Припустимо, нам необхідно здійснювати підрахунок об'єктів певного класу. Можна створити лічильник – статичний елемент даних, в якому зберігатиметься кількість об'єктів, присутніх у пам'яті. Збільшення значення лічильника здійснюємо в конструкторі, а зменшення – у деструкторі. Програма може мати такий вигляд:
#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 Вправи для контролю
- Створити клас "Пара чисел" з необхідними конструкторами та функціями доступу.
- Створити клас "Точка в тривимірному просторі" з необхідними конструкторами та функціями доступу. Перевантажити операції введення-виведення.
- Створити клас "Комплексне число " з необхідними конструкторами та функціями доступу. Перевантажити операції + , -, *, / і введення-виведення.
- Створити клас "Вектор у N-вимірному просторі" з необхідними конструкторами та функціями доступу. Перевантажити операції + , -, *, / і введення-виведення.
- Створити клас "Квадратне рівняння" з елементами даних – коефіцієнтами рівняння, необхідними конструкторами та функціями доступу. Реалізувати функцію знаходження коренів.
5 Контрольні запитання
- Які синтаксичні конструкції надає C++ для створення користувацьких типів?
- Для чого використовують безіменні переліки?
- Як змінній типу переліку присвоїти ціле значення?
- Що спільного і чим відрізняються структури й масиви?
- Як надіслати структури до функцій?
- Для чого використовують операцію
->
? - У чому полягають особливості об'єднань і для чого їх використовують?
- Що таке клас і з чого він складається?
- Що таке інкапсуляція?
- Які рівні доступу до елементів класу підтримує C++?
- Що таке функції доступу?
- Чи можна в C++ поза класом реалізовувати методи, оголошені всередині класу?
- Що таке конструктор і як він викликається?
- Скільки конструкторів без параметрів може бути створено в одному класі?
- Як створити клас, у якому немає жодного конструктора?
- Що таке конструктор копіювання і коли його слід визначати?
- Що таке деструктор і як він викликається?
- Скільки деструкторів може бути визначено в класі?
- Як створити константну функцію-елемент?
- Що таке вказівник
this
і для чого його використовують? - Які елементи входять в область видимості класу?
- Чи можна створювати класи всередині інших класів?
- Що таке статичний елемент класу?
- Які обмеження накладаються на реалізацію статичних функцій?
- Чому статичні елементи даних слід визначати поза межами класу?
- Що таке друзі класу? Які синтаксичні конструкції можуть бути друзями класу?
- Чи входять друзі класу в область видимості класу?
- Для чого призначений механізм винятків?
- Як створити об'єкт-виняток?
- Яких типів можуть бути об'єкти-винятки?
- Чи можна використовувати основний результат функції, якщо відбулася генерація винятку?
- Як перехопити й обробити виняток?
- Як створити блок обробки всіх винятків?
- Для чого використовують перевантаження операцій?
- Які операції не можна перевантажувати?
- Коли операцію слід перевантажувати тільки визначивши функцію-елемент?
- Коли операцію слід перевантажувати тільки визначивши глобальну функцію?
- Як перевантажити операцію перетворення типів?