Лабораторна робота 1

Успадкування та поліморфізм

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

1.1 Індивідуальне завдання

Створити ієрархію класів, які представляють сутності лабораторної роботи № 5 курсу "Основи програмування (частина 2)" попереднього семестру. Базовий абстрактний клас, який представляє другу сутність індивідуального завдання (умовно SecondAbstractEntity), не повинен містити даних, лише абстрактні методи доступу, перевизначення функцій toString() та equals(), а також реалізацію функцій, визначених попереднім завданням. Цей клас також повинен реалізовувати інтерфейс Comparable для природного порівняння об'єктів під час сортування за однією з ознак.

Базовий абстрактний клас, який представляє першу з сутностей індивідуального завдання (умовно FirstAbstractEntity), повинен містити:

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

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

Від створених абстрактних класів створити похідні класи (умовно FirstEntityWithArray і SecondEntityWithData), які повинні містити поля конкретних типів, зокрема, у класі FirstEntityWithArray послідовність елементів SecondAbstractEntity повинна бути представлена у вигляді масиву.

Створити ще один клас (умовно FirstEntityWithSorting), похідний від попередньо створеного класу FirstEntityWithArray. Цей клас повинен перевизначати методи сортування – замість сортування бульбашкою і включенням застосувати стандартну функцію Arrays.sort(). Одне з сортувань повинне бути забезпечене реалізацією інтерфейсу Comparable для сутності, об'єкти якої зберігаються в масиві. Друге сортування забезпечується створенням окремого класу, який реалізує інтерфейс Comparator.

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

Індивідуальні завдання вказані в таблиці:

№№
Перша ознака Друга ознака
1, 17
За зменшенням температури За алфавітом коментаря
2, 18
За збільшенням кількості студентів За збільшенням довжини теми
3, 19
За збільшенням кількості пасажирів За алфавітом коментаря
4, 20
За збільшенням кількості слів у темі За алфавітом теми
5, 21
За збільшенням температури За зменшенням довжини коментаря
6, 22
За збільшенням кількості учасників За алфавітом назви
7, 23
За збільшенням кількості відвідувачів За алфавітом коментаря
8, 24
За зменшенням кількості пасажирів За зменшенням довжини коментаря
9, 25
За датою у зворотному порядку За збільшенням кількості відвідувачів
10, 26
За збільшенням кількості концертів За алфавітом міста
11, 27
За номером зміни За збільшенням кількості комп'ютерів
12, 28
За збільшенням кількості відвідувачів За алфавітом коментаря
13, 29
За зменшенням кількості пасажирів За алфавітом назви
14, 30
За зменшенням кількості покупців За алфавітом коментаря
15, 31
За датою у зворотному порядку За збільшенням кількості глядачів
16, 32
За зменшенням кількості хвилин розмов За збільшенням кількості коштів, що використано на розмови

Примітка: слід створити класи зі змістовними іменами, які відображають фізичну сутність індивідуального завдання, а не FirstEntity, SecondEntity тощо.

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

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

Примітка: слід створити класи зі змістовними іменами.

1.3 Мінімум функції

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

Реалізувати п'ять варіантів розв'язання:

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

Перевірити роботу програми на двох різних функціях.

1.4 Реалізація масиву точок через масиви дійсних чисел

Реалізувати функціональність абстрактного класу AbstractArrayOfPoints, наведеного в прикладі 3.2, у два способи:

  • через використання двовимірного масиву дійсних чисел: кожен рядок масиву має відповідати точці;
  • через використання одновимірного масиву дійсних чисел: кожна пара чисел у масиві має відповідати точці.

Здійснити тестування створених класів.

Примітка: не слід вносити зміни у код класу AbstractArrayOfPoints (за винятком імені пакету і реалізації методу sortByY()).

1.5 Реалізація інтерфейсу Comparable

Створити клас Circle, який реалізує інтерфейс Comparable. Більшим вважається коло з більшим радіусом. Здійснити сортування масиву об'єктів типу Circle за допомогою функції Arrays.sort().

1.6 Реалізація інтерфейсу Comparator

Створити клас Triangle. Трикутник визначати довжинами сторін. Площа трикутника в цьому випадку може бути обчислена за формулою Герона:

Heron

де a, b і c – довжини сторін трикутника. За допомогою функції Arrays.sort()здійснити сортування масиву трикутників за зменшенням площі. Для визначення ознаки сортування використовувати об'єкт, який реалізує інтерфейс Comparator.

1.7 Обчислення визначеного інтегралу (додаткове завдання)

Створити інтерфейс Integrable, який містить опис абстрактної функції, що приймає аргумент типу double і повертає результат того ж типу. Інтерфейс повинен містити метод integral() з усталеною реалізацією (з модифікатором default) обчислення визначеного інтегралу. Метод повинен отримувати як параметри початок, кінець інтервалу і точність обчислень. Усталена реалізація обчислення інтегралу використовує метод прямокутників.

Створити клас, який перевизначає функцію integral(), реалізуючи метод трапецій обчислення визначеного інтегралу.

Обчислити визначений інтеграл за допомогою обох алгоритмів для різних математичних функцій класу java.lang.Math (див. приклад 3.3). Порівняти результати для різних алгоритмів і різних значень точності обчислення.

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

2.1 Інтегроване середовище розробки IntelliJ IDEA

2.1.1 Встановлення IDE IntelliJ IDEA і створення першого проекту

Інтегроване середовище розробки IntelliJ IDEA – застосунок, повністю написаний на Java. Для роботи IDE Java повинна бути заздалегідь встановлена на комп'ютері. Для компіляції класів IntelliJ IDEA необхідно встановити JDK.

Існує два варіанти IDE, які підтримують різні підмножини технологій Java. Безкоштовний варіант Community Edition забезпечує повній набір засобів розробки для платформи Java SE мовами Java, Kotlin, Groovy та Scala, засоби управління проектом, роботи з репозиторієм, тестування та зневадження програм. Варіант Ultimate Edition підтримує повний набір технологій, додатково включаючи Java EE, Spring Framework, спеціальні засоби роботи з базами даних, підтримку розробки на JavaScript і TypeScript тощо.

Програму встановлення середовища програмування IntelliJ IDEA можна скачати зі сторінки завантажень сайту компанії JetBrains. Обираємо варіант Community Edition. Після завантаження інсталятора його потрібно запустити на виконання. Натискаючи кнопку Next, проходимо по сторінках майстра встановлення.

  • На сторінці Choose Install Location вибираємо теку для встановлення (можна залишити без змін).
  • На сторінці Installation Options опції також можна залишити без змін.
  • На сторінці Choose Start Menu Folder вибираємо групу в меню Пуск і натискаємо кнопку Install.
  • Після встановлення IntelliJ IDEA на останній сторінці майстра можна відразу вибрати опцію Run IntelliJ IDEA Community Edition і натиснути Finish.

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

У правій частині вікна Welcome to IntelliJ IDEA можна знайти чотири позиції меню: Projects, Customize, Plugins та Learn IntelliJ IDEA. Зокрема, позиція дозволяє здійснити загальні налаштування стилів середовища: кольорова тема (Color theme) – Dracula, IntelliJ Light, Windows 10 Light і High contrast. Тема вибирається на свій розсуд. Можна також змінити розмір шрифту.

У вікні Welcome to IntelliJ IDEA усталено обрано позицію Projects. З цієї позциції логічно починати під час першого запуску. Вибираємо створення нового проекту (кнопка New Project). На першій сторінці майстра для нового Java-проекту (позиція Java у лівому меню) слід вибрати Project SDK. Середовище намагається знайти встановлені JDK. Якщо це не вдалося, можна вибрати функцію Add JDK... і вказати шлях до раніше встановленого JDK. На наступній сторінці майстру New Project вибираємо опцію Create project from template, а потім вибираємо шаблон Command Line App (консольний застосунок). На наступній сторінці вводимо ім'я проекту (Project name), теку розміщення проекту (Project location) й ім'я базового пакета (Base package). Як ім'я базового пакета найкраще вибрати звернене доменне ім'я (у автора це ua.inf.iwanoff). Далі натискаємо кнопку Finish. Відкривається головне вікно IDE з двома закладками в області редагування. Закладку What's New in IntelliJ IDEA можна закрити після ознайомлення. На закладці Main.java можна побачити такий код:

package ua.inf.iwanoff;

public class Main {

    public static void main(String[] args) {
    // write your code here
    }
}

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

Для запуску програми на виконання можна скористатися функцією головного меню Run | Run 'Main', зеленою стрілкою в панелі інструментів або клавішною комбінацією Shift+F10. У разі успішної компіляції здійснюється виконання програми. У нижній частині головного вікна з'являється спеціальна область, що імітує роботу в командному вікні.

2.1.2 Елементи графічного інтерфейсу користувача IDE IntelliJ IDEA. Використання шаблонів коду та гарячих клавіш

Головне вікно IDE включає як традиційні для всіх сучасних інтегрованих середовищ елементи користувацького інтерфейсу (меню, панель інструментів, головне вікно редактору, рядок стану), так і специфічний для середовища IntelliJ IDEA набір інструментальних вікон (Tool Windows). Інструментальні вікна мають кнопки виклику, розташовані по периметру робочої області, з піктограмою, підписом і числовим позначенням (останнє не обов'язково). Якщо на ці кнопки натиснути, поруч з ними відкриються віконця з деякою допоміжною функціональністю.

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

Підменю головного меню пропонують потужні засоби управління кодом (Navigate, Code, Analyze і Refactor). У таблиці наведені деякі функції для роботи з кодом та відповідні гарячі клавіші:

Функція меню Опис Клавіатурна комбінація
Navigate | Back Перейти до попереднього місця перегляду або редагуівання Ctrl+Alt+Left
Navigate | Forward Перейти до наступного місця перегляду або редагуівання Ctrl+Alt+Right
Navigate | Previous Highlichted Error Перейти до попередньої знайденої помилки в коді Shift+F2
Navigate | Next Highlichted Error Перейти до наступної знайденої помилки в коді F2
Code | Override methods Перевизначити метод базового класу Ctrl+O
Code | Implement methods Реалізувати метод абстрактного класу або інтерфейсу Ctrl+I
Code | Generate... Згенерувати фрагмент коду (конструктор, геттери і сеттери, перевизначені методи тощо) Alt+Insert

Редактор IntelliJ IDEA підтримує велику кількість клавішних комбінацій для редагування. Крім стандартної роботи з буфером, підтримується, наприклад, видалення рядка (Ctrl-Y), дублювання рядки або блоку (Ctrl-D), перехід до точок переривання (Ctrl+номер_точки_переривання), коментування блоку (Ctrl+/) тощо.

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

Використовуючи комбінацію Ctrl+Alt+L, можна відформатувати код.

Корисна клавішна комбінація Alt-Enter дозволяє отримати набір варіантів виправлення помилки, яка виникла (наприклад, додати директиву import, згенерувати порожній метод тощо).

Перелік всіх клавішних комбінацій можна отримати у вікні Settings, далі Keymap (File | Settings...) .

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

Середовище IntelliJ IDEA істотно полегшує введення сирцевого коду завдяки використанню шаблонів коду (Live Templates). Викликати відповідну функцію можна через головне меню (Code | Insert Live Template...) або за допомогою клавішної комбінації Ctrl-J. З'являється список шаблонів коду. Список залежить від контексту (розташування курсору у вікні редактору). Найбільш корисні шаблони наведені в таблиці:

Послідовність символів Програмний код
psvm
public static void main(String[] args)
St
String
psf
public static final
fori
for (int i = 0; i < ; i++)
itar
for (int i = 0; i < args.length; i++) {
    String arg = args[i];

}
ifn
if ( == null) {
     
}
iter
for (Object o : ) {
    
}
sout
System.out.println();

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

2.1.3 Використання зневаджувача

Запуск програми для зневадження може здійснюватися за допомогою функції головного меню Run | Debug 'Main', кнопкою із зображенням зеленого жука в панелі інструментів або клавішною комбінацією Shift+F9. У найпростішому випадку для зневадження програми досить вказати точку переривання (кликнути мишею на вертикальній сірій смузі ліворуч від необхідного рядку) і запустити програму на зневадження. Виконання програми зупиниться на вибраному рядку, після чого проміжні значення змінних можна подивитися в області виведення Variables або просто розмістивши курсор миші над змінною в програмному коді. В підменю Run | Debugging Actions під час налагодження доступні різні варіанти виконання програми по кроках:

  • Step into (F7) – з заходженням у функції, що викликаються
  • Step over (F8) – без заходження в функції, що викликаються.

2.1.4 Структура проекту

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

Тека src містить пакети з сирцевим кодом проекту. Тека out містить результат компіляції сирцевого коду. Всередині теки out знаходиться підкаталог production, в ньому – Проект, далі структура тек повторять аналогічну структуру тек підкаталогу src.

Додаткові опції проекту, пов'язані з компіляцією, кодуванням і т.д., представлені файлами прихованої теки .idea.

2.2 Системи контролю версій. Використання репозиторія сервісу GitHub

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

Системи контролю версій можуть бути централізованими і розподіленими. У централізованих системах зберігання версій здійснюється на спеціальному сервері. Приклад централізованої системи – Subversion (SVN). У розподілених системах є локальна копія сховища та забезпечується узгодження даних з репозиторієм на віддаленому комп'ютері. Git є відкритою системою контролю версій. Для проектів з відкритим кодом використання Git безкоштовне.

GitHub – це соціальний репозиторій для проектів з відкритим сирцевим кодом, що використовують Git для контролю версій сирцевого коду. Для створення репозиторіїв слід зареєструватися на сайті https://github.com.

Обидві реалізації IntelliJ IDEA підтримують інтегровану роботу з системами контролю версій (підменю VCS головного меню). Для роботи з GitHub в IntelliJ IDEA попередньо потрібно встановити систему Git. Необхідне програмне забезпечення можна завантажити за адресою https://git-scm.com/downloads, вибравши версію для своєї операційної системи. На сторінках майстра встановлення вибрані опції доцільно залишити без змін.

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

git config --global user.name "ім'я_користувача"
git config --global user.email адреса_користувача@пошта

Створюється файл .gitconfig, що містить відповідні налаштування.

У середовищі IntelliJ IDEA виконуємо налаштування Git (File | Settings..., далі Version Control | Git) – вказуємо шлях до файлу git.exe, наприклад, C:\Program Files\Git\bin\git.exe. В налаштуваннях GitHub (Version Control | GitHub) за допомогою кнопки + додаємо акаунт і здійснюємо авторизацію.

Для того, щоб додати створений раніше проект в IntelliJ IDEA в першу чергу слід дозволити для нього використання VCS: VCS | Enable Version Control Integration і вибрати Git у списку запропонованих VCS. Тепер у головному меню замість підменю VCS з'явиться підменю Git. Аналогічний результат можна отримати, якщо скористатися функцією меню VCS | Create Git Repository... і вибрати проект, який вас цікавить.

Примітка. Систему Git можна встановити з середовища IntelliJ IDEA, якщо в головному меню вибрати VCS | Enable Version Control Integration, потім вибрати git у списку різних VCS. У нижньому правому куті вікна виникає popup-меню, в якому буде запропоновано завантажити Git.

Тепер проект можна скопіювати на GitHub, скориставшись функцією меню Git | GitHub | Share Project on GitHub.

Якщо до проекту, який було раніше додано до Git, додавати нові файли, наприклад, класи, середовище IntelliJ IDEA пропонує додавати ці файли до репозиторію через діалогове вікно Add File to Git.

Після внесення змін до коду за допомогою функції головного меню Git | Commit... здійснюємо оновлення проекту в локальному репозиторії. Можна окремо оновлювати файли через контекстне меню (Git | Commit File...). Оновлення проекту слід здійснювати після внесення змін, пов'язаних з виконанням певної задачі модифікації коду. Сенс цієї задачі слід описувати в області введення Commit Message діалогового вікна Commit Changes.

Після оновлення проекту в локальному репозиторії функцією Commit, внесені зміни можна також перенести в репозиторій GitHub за допомогою функції головного меню Git | Push... (або аналогічною функцією контекстного меню).

Будь-який інший час, закривши всі проекти, можна скористатися функцією Get from VCS на стартовому вікні IntelliJ IDEA. Далі вибираємо GitHub, уточнюємо теку розміщення проекту, далі підтверджуємо його відкриття.

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

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

class X {
}

class Y {
}

class Z {
    X x = new X();
    Y y;
    Z() {
        y = new Y();
    }
}

Можна також створити внутрішній об'єкт безпосередньо перед його першим використанням.

Відношення, що моделюється композицією, часто називають відношенням "has-a".

Агрегування – це різновид композиції, який передбачає, що сутність (екземпляр) міститься в іншій сутності або не може бути створена та існувати без сутності, яка її охоплює. При цьому сутність, що охоплює, може існувати без внутрішньої, тобто час життя зовнішньої та внутрішньої сутностей може не збігатися. Більш строге трактування композиції (власне композиція) передбачає, що час життя зовнішньої та внутрішньої сутностей збігається. На рівні Java агрегування передбачає можливість створення внутрішнього об'єкта перед його використанням, тоді як строга композиція передбачає створення внутрішнього об'єкта в тілі класу, в блоці ініціалізації або в конструкторі.

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

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

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

class DerivedClass extends BaseClass {
    // тіло класу
}

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

Усі класи Java безпосередньо чи опосереднено походять від класу java.lang.Object. Цей клас надає набір корисних методів, таких як toString() для отримання даних будь-якого об'єкта у вигляді рядка тощо. Базовий клас Object не вказують явно.

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

Ключове слово super використовують для доступу до елементів базового класу з похідного класу, зокрема:

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

Наприклад:

class BaseClass {
    int i, j;
    BaseClass(int i, int j) {
        this.i = i;
        this.j = j;
    }
}

class DerivedClass extends BaseClass {
    int k;
    DerivedClass(int i, int j, int k) {
        super(i, j);
        this.k = k;
    }
}

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

Класи можуть бути визначені з модифікатором final (фінальний). Фінальні класи не можуть використовуватися як базові. Методи з модифікатором final не можуть бути перевизначені. Наприклад:

final class A {
    void f() { }
}

class B {
    final void g() { } 
}

class C extends A { // Помилка! Не можна успадкувати від A
  
}

class D extends B {
    void g() { }    // Помилка! g() не можна перекрити
}

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

class Base {
    static void f(Base b) { }
}

class Derived extends Base {
 
    public static void main(String[] args) {
        Base b;
        b = new Derived(); // Неявне приведення
        Derived d = new Derived();
        f(d);              // Неявне приведення
    }
}    

Зворотне приведення необхідно робити явно:

Base b = new Base();
Derived d = (Derived) b;

2.5 Анотації (метадані)

Анотації дозволяють включити в програмний код додаткову інформацію, яка не може бути визначена за допомогою засобів мови. У тексті програми анотації починаються із символу @. Типовий приклад анотації – @Override. Завдяки цій анотації компілятор може перевірити, чи дійсно відповідний метод був оголошений у базових класах.

public class MyClass {
  
    @Override
    public String toString() {
        return "My overridden method!";
    }

}

Можна навести інші приклади анотацій:

  • @SuppressWarnings("ідентифікатор_попередження") – попередження компілятора повинні бути замовчані в анотованому елементі
  • @Deprecated – використання анотованого елемента не є більше бажаним

Java дозволяє визначати власні анотації.

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

2.6.1 Загальні концепції

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

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

У мовах об'єктно-орієнтованого програмування пізнє зв'язування реалізоване через механізм віртуальних функцій. Віртуальна функція (віртуальний метод, virtual method) – це функція, визначена в базовому класі, та перевизначена (перекрита) у похідних, так, що конкретна реалізація функції для виклику визначатиметься під час виконання програми. Вибір реалізації віртуальної функції залежить від реального (а не оголошеного під час опису) типу об'єкта. Оскільки посилання на базовий тип може містити адресу об'єкта будь-якого похідного типу, поведінка раніше створених класів може бути змінена пізніше шляхом перевизначення віртуальних методів. Перевизначення передбачає відтворення імені, списку параметрів та специфікатора доступу. Фактично поліморфними є класи, які містять віртуальні функції.

У C++ для позначення віртуальної функції використовують модифікатор virtual. У Java всі методи є віртуальними, за винятком конструкторів, статичних (static), фінальних (final) і закритих (private) методів. На відміну від C++, слово virtual не використовується.

Починаючи з Java 5, перед перевизначеними віртуальними методами розміщують анотацію @Override, яка дозволяє компілятору здійснити додаткову перевірку синтаксису – відповідність сигнатури нової функції сигнатурі перекритої функції базового класу. Використання @Override є бажаним, але не обов'язковим.

Усі класи Java є поліморфними, оскільки таким є клас java.lang.Object. Зокрема, завдяки поліморфізму кожен клас може визначити свою віртуальну функцію toString(), яка буде викликана для автоматичного отримання даних про об'єкт у вигляді рядку.

У Java є ключове слово instanceof, яке дозволяє перевірити, чи є об'єкт екземпляром певного типу (або похідних типів). Вираз

об'єкт instanceof клас

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

if (x instanceof SomeClass)
    ((SomeClass)x).someMethod();

2.6.2 Абстрактні класи та методи

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

abstract class SomeConcept {
    . . .
}

Абстрактний клас може містити абстрактні методи, такі, для яких не приводиться реалізація. Такі методи не мають тіла функції. Їхнє оголошення аналогічне оголошенню функцій-елементів у С++, але оголошенню повинне передувати ключове слово abstract.

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

abstract class Shape {
    int x, y;
    . . .
    void moveTo(int newX, int newY) {
        . . .
    }
    abstract void draw();
}

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

class Circle extends Shape 
{
    void draw() {
        . . .
    }
}

class Rectangle extends Shape {
    void draw() {
        . . .
    }
}

Абстрактні методи аналогічні суто віртуальним функціям у C++.

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

2.7 Загальні відомості про інтерфейси. Упорядкування об'єктів

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

interface SomeFunctions {
    void f();
    int g(int x);
}

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

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

Для того, щоб указати, що клас реалізує інтерфейс, ім'я інтерфейсу вказують у списку реалізованих інтерфейсів. Такий список розташовують у заголовку класу після ключового слова implements. Методи, визначені в інтерфейсі, є абстрактними і відкритими. У класі, що реалізує інтерфейс, такі методи повинні бути оголошені як public:

interface SomeFunctions {
    void f();
    int g(int x);
}

class SomeClass implements SomeFunctions {
    @Override
    public void f() {

    }

    @Override
    public int g(int x) {
        return x;
    }
}

Примітка: використання анотації @Override є бажаним, але не обов'язковим.

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

interface SomeFunctions {
    void f();
    int g(int x);
}

interface AnotherFunction {
    void h(int z);
}

interface AllFunctions extends SomeFunctions, AnotherFunction {
    int g(int x); // оголошення може повторюватися
}

Тепер клас, який реалізує інтерфейс AllFunctions, повинен визначати три функції:

class Implementation implements AllFunctions {
    @Override
    public void f() {

    }

    @Override 
    // Одна реалізація використовується для базового і похідного інтерфейсів:
    public int g(int x) {
        return x;
    }

    @Override
    public void h(int z) {

    }
}

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

class AnotherImplementation implements SomeFunctions, AnotherFunction {
    @Override
    public void f() {

    }

    @Override
    public int g(int x) {
        return x;
    }

    @Override
    public void h(int z) {

    }
}

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

Інтерфейси не походять від класу java.lang.Object. Не можна створювати новий об'єкт типу інтерфейсу. Навіть для порожнього інтерфейсу треба створити клас, який його реалізує:

interface Empty {

}

class EmptyImplementation implements Empty {

}

...

public static void main(String[] args) {
    Empty empty1 = new Empty(); // Помилка
    Empty empty2 = new EmptyImplementation(); // Коректне створення об'єкта
}

JDK надає велику кількість стандартних інтерфейсів. Розглянемо застосування інтерфейсів Comparable і Comparator для сортування масивів.

У найпростішому випадку сортування всього масиву за зростанням здійснюється за допомогою функції sort() з одним параметром – посиланням на відповідний масив. Статична функція sort() класу java.util.Array реалізована для масивів усіх примітивних типів. Аналогічно можна реалізувати сортування об'єктів класів, для яких визначене натуральне порівняння, тобто реалізований інтерфейс Comparable. Єдиний метод цього інтерфейсу - compareTo():

public int compareTo(Object o)

Метод повинен повернути від'ємне значення (наприклад, -1), якщо об'єкт, для якого викликаний метод, менше об'єкта o, нульове значення, якщо об'єкти рівні, і додатне значення в протилежному випадку.

До класів, що реалізують інтерфейс Comparable, відносяться класи оболонки Double, Integer, Long і т.д., а також String. Наприклад, у такий спосіб можна розсортувати масив об'єктів типу Integer:

public class SortIntegers {

    public static void main(String[] args) {
        Integer[] a = {7, 8, 3, 4, -10, 0};
        java.util.Arrays.sort(a);
        System.out.println(java.util.Arrays.toString(a));
    }

}

Примітка. Ім'я інтерфейсу Comparable – це приклад найбільш коректного імені інтерфейсу. Бажано, щоб імена інтерфейсів закінчувалися на -able (Comparable, Runnable тощо). Але це правило дуже часто порушується навіть для стандартних інтерфейсів.

У Java 5 Comparable – це узагальнений інтерфейс. Узагальнення в Java за своїм синтаксисом та використанням схожі на шаблони C++, але реалізовані повністю в інший спосіб. Створення та використання узагальнень буде розглянуто пізніше. Завдяки узагальненням у функціях, які оголошені в інтерфейсі, замість параметрів типу Object, можна використовувати параметри інших типів. В нашому випадку функція compareTo() повинна приймати аргумент типу елементу масиву.

Можна самостійно створити клас, що реалізує інтерфейс Comparable. Наприклад, масив прямокутників сортується за площею:

class Rectangle implements Comparable<Rectangle> {
    private double width, height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public double area() {
        return width * height;
    }

    public double perimeter() {
        return 2 * (width + height);
    }
    
    @Override
    public int compareTo(Rectangle rect) {
        return Double.compare(area(), rect.area());
    }

    @Override
    public String toString() {
        return "[" + width + ", " + height + ", area = " + area() + ", perimeter = " + perimeter() + "]";
    }

}

public class SortRectangles {

    public static void main(String[] args) {
        Rectangle[] a = {new Rectangle(2, 7), new Rectangle(5, 3), new Rectangle(3, 4)};
        java.util.Arrays.sort(a);
        System.out.println(java.util.Arrays.toString(a));
    }

}

У наведеному прикладі використовується статична функція compare() класу Double. Ця функція повертає значення, необхідні методу sort().

Якщо ми не хочемо (чи не можемо) визначити функцію compareTo(), можна створити клас, що реалізує інтерфейс Comparator. Посилання на об'єкт такого класу передаються в якості в другого (четвертого) параметру функції sort():

public static void sort(Object[] a, Comparator c) // Опис функції у Java 2
public static void sort(Object[] a, int fromIndex, int toIndex, Comparator c) // Опис функції у Java 2

Інтерфейс містить опис методу compare() з двома параметрами. Функція повинна повернути від'ємне число, якщо перший об'єкт під час сортування необхідно вважати меншим, чим інший, значення 0, якщо об'єкти еквівалентні, і додатне число в протилежному випадку.

Примітка. Починаючи з Java 5 Comparator – це також узагальнений інтерфейс. Під час його реалізації після його імені слід вказувати в кутових дужках тип об'єктів, які ми порівнюємо. Якщо використовувати узагальнення, функція compare() повинна приймати два аргументи типу параметру узагальнення.

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

class CompareByPerimeter implements java.util.Comparator<Rectangle>
{

    @Override
    public int compare(Rectangle r1, Rectangle r2) {
        return Double.compare(r1.perimeter(), r2.perimeter());
    }
}

public class SortRectangles {

    public static void main(String[] args) {
        Rectangle[] a = {new Rectangle(2, 7), new Rectangle(5, 3), new Rectangle(3, 4)};
        java.util.Arrays.sort(a);                           // сортування за площею
        System.out.println(java.util.Arrays.toString(a));
        java.util.Arrays.sort(a, new CompareByPerimeter()); // сортування за периметром
        System.out.println(java.util.Arrays.toString(a));
    }

}

2.8 Вкладені класи

2.8.1 Загальні концепції

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

class Outer {
    class Inner {
        int i;
    };

    Inner inner = new Inner();
}

class Another {
    Outer.Inner i;
}

Вкладені класи можуть бути оголошені зі специфікаторами public, private або protected.

Локальні класи створюють всередині блоків. Існує також спеціальний різновид локальних класів – безіменні класи.

Окрема категорія – статичні вкладені класи, використання яких аналогічне вкладеним класам C++ і C#.

2.8.2 Внутрішні класи

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

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

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

class Outer {
    int k = 100;

    class Inner {
        void show() {
            System.out.println(k);
        }
    }

}

public class Test {

    public static void main(String[] args) {
        Outer outer = new Outer();
        Outer.Inner inner = outer.new Inner();
        inner.show();
    }

}

Нестатичні внутрішні класи не можуть містити статичних елементів.

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

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

class FirstBase {
    int a = 1;
}

class SecondBase {
    int b = 2;
}

class Outer extends FirstBase {
    int c = 3;

    class Inner extends SecondBase {
        void show() {
            System.out.println(a);
            System.out.println(b);
            System.out.println(c);
        }
    }
}

public class Test {

    public static void main(String[] args) {
        Outer outer = new Outer();
        Outer.Inner inner = outer.new Inner();
        inner.show();
    }
}

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

2.8.3 Локальні й безіменні класи

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

void f() {
    class Local {
        int j;
    }
    Local l = new Local();
    l.j = 100;
    System.out.println(l.j);
}

Можна також розміщати локальні класи всередині окремих блоків.

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

new Object() {
    // Додавання нового методу:
    void hello() {
        System.out.println("Привіт!");
    }
}.hello();

System.out.println(new Object() {
    // Перевизначення методу:
    @Override public String toString() {
        return "Це безіменний клас.";
    }
});

Безіменні класи не можуть бути абстрактними. Безіменний клас завжди є внутрішнім класом; він не може бути статичним. Безіменні класи автоматично є фінальними (final). У наведеному нижче прикладі безіменний клас створюється для визначення способу сортування масиву рядків:

    void sortByABC(String[] a) {
        Arrays.sort(a, new Comparator<String>() { 
            public int compare(String s1, String s2) {
                return (s1).compareTo(s2);
            }
        }); 
    }

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

abstract class Base {
    int k;

    Base(int k) {
        this.k = k;
    }

    abstract void show();
}

public class Test {

    static void showBase(Base b) {
        b.show();
    }

    public static void main(String[] args) {
        showBase(new Base(10) {
            void show() {
                System.out.println(k);
            }
        });
    }
}

Можна також використовувати блоки ініціалізації.

Для того, щоб безіменні класи мали доступ до локальних елементів зовнішніх блоків, ці елементи повинні бути описані як final.

2.8.4 Статичні вкладені класи

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

class Outer {
    int k = 100;
    static int m = 200;

    static class Inner {
        void show() {
            // k недоступно
            System.out.println(m);
        }
    }
}

public class Test {

    public static void main(String[] args) {
        Outer.Inner inner = new Outer.Inner();
        inner.show();
    }
}

Статичні вкладені класи можуть містити свої статичні елементи, у тому числі свої вкладені статичні і нестатичні класи.

Класи можна створювати всередині інтерфейсів. Такі класи автоматично є статичними. Усередині класів також можна створювати інтерфейси, що є також статичними.

2.9 Усталена реалізація методів інтерфейсів

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

package ua.inf.iwanoff.oop.first;

public interface Greetings {
    default void hello() {
        System.out.println("Hello everybody!");
    }
}

Клас, який реалізує інтерфейс, може бути порожнім. Можна залишити усталену реалізацію методу hello():

package ua.inf.iwanoff.oop.first;

public class MyGreetings implements Greetings {

}

Під час тестування отримаємо усталене вітання.

package ua.inf.iwanoff.oop.first;

public class GreetingsTest {

    public static void main(String[] args) {
        new MyGreetings().hello(); // Hello everybody!
    }

}

Те ж саме можна отримати, використавши безіменний клас. Його тіло також буде порожнім:

package ua.inf.iwanoff.oop.first;

public class GreetingsTest {

    public static void main(String[] args) {
        new Greetings() { }.hello(); // Hello everybody!
    }

}

Наявність методів з усталеною реалізацією робить інтерфейси ще більш схожими на абстрактні (і навіть на неабстрактні) класи. Але зберігається принципова відмінність: інтерфейс не можна безпосередньо застосовувати для створення об'єктів. Усі класи безпосередньо або опосередковано походять від базового типу java.lang.Object, який містить дані й функції, необхідні для функціонування всіх, навіть найпростіших об'єктів. Інтерфейси не є класами і не походять від java.lang.Object. Інтерфейс – це лише декларація певної поведінки, яка може бути доповнена допоміжними засобами (методами з усталеною реалізацією). Поля, описані в інтерфейсі – це не власне дані об'єкту, а константи часу компіляції. Для виконання методів з усталеною реалізацією необхідний об'єкт класу, який реалізує інтерфейс. Саме через це в останньому прикладі створюється об'єкт безіменного класу

new Greetings() { }.hello();

а не інтерфейсу

new Greetings().hello(); // Синтаксична помилка!

Метод з усталеною реалізацією можна перевизначити:

package ua.inf.iwanoff.oop.first;

public class MyGreetings implements Greetings {

    @Override
    public void hello() {
        System.out.println("Hello to me!");
    }

}

Тепер, створивши об'єкт цього класу, ми отримаємо нове привітання.

Якщо з перевизначеного методу необхідно викликати усталений метод інтерфейсу, можна скористатися ключовим словом super:

Greetings.super.hello();

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

package ua.inf.iwanoff.oop.first;

public interface FunctionToPrint {
    public double f(double x);
    default void print(double x) {
        System.out.printf("x = %7f f(x) = %7f%n", x, f(x));
    }
}

У класі PrintValues створюємо метод друку таблиці printTable(). Цей метод використовує створений раніше інтерфейс.

package ua.inf.iwanoff.oop.first;

public class PrintValues {

    static void printTable(double from, double to, 
                      double step, FunctionToPrint func) {
        for (double x = from; x <= to; x += step) {
            func.print(x);
        }
        System.out.println();
    }

    // у функції main() створюємо об'єкт безіменного класу:
    public static void main(String[] args) {
        printTable(-2, 2, 0.5, new FunctionToPrint() {
            @Override
            public double f(double x) {
                return x * x * x;
            }
        });
    }

}

Припустимо, нас не влаштувала точність значень. У цьому випадку в безіменному класі можна також перевизначити метод print():

   public static void main(String[] args) {
        printTable(-2, 2, 0.5, new FunctionToPrint() {
            @Override
            public double f(double x) {
                return x * x * x;
            }

            @Override
            public void print(double x) {
                System.out.printf("x = %9f f(x) = %9f%n", x, f(x));
            }
            
        });
    }

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

public interface SomeInterface {
    void f();
}

Цей інтерфейс реалізовувався деяким класом:

public class OldImpl implements SomeInterface {
    @Override
    public void f() {
        // реалізація
    }
}

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

interface SomeInterface {
    void f();
    default void g() {
        // реалізація
    }
}    

Цей метод буде реалізований новими класами:

public class NewImpl implements SomeInterface {

    @Override
    public void f() {
        // реалізація
    }

    @Override
    public void g() {
        // реалізація
    }
}

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

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

У Java 8 інтерфейси також можуть містити реалізацію статичних методів. Логічно всередині інтерфейсу визначати методи, що мають відношення до цього інтерфейсу (наприклад, вони можуть одержувати посилання на інтерфейс як параметр). Найчастіше, це допоміжні методи. Як і всі елементи інтерфейсу, такі статичні методи є публічними. Можна вказати public явно, але в цьому немає необхідності.

У наведеному раніше прикладі функцію printTable() можна було б розмістити всередині інтерфейсу:

package ua.inf.iwanoff.oop.first;

public interface FunctionToPrint {
    public double f(double x);
    default void print(double x) {
        System.out.printf("x = %9f f(x) = %9f%n", x, f(x));
    }
    static void printTable(double from, double to, double step, FunctionToPrint func) {
        for (double x = from; x <= to; x += step) {
            func.print(x);
        }
        System.out.println();
    }
}

Виклик функції слід здійснювати через ім'я інтерфейсу.

2.10 Робота з функціональними інтерфейсами в Java 8

2.10.1 Лямбда-вирази і функціональні інтерфейси

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

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

Термін "лямбда- вираз" пов'язаний з математичної дисципліною – лямбда-численням. Лямбда-числення – це формальна система, розроблена американським математиком Алонсо Черчем для формалізації й аналізу поняття обчислюваності. Лямбда-числення стало формальною основою мов функційного програмування (Lisp, Scheme тощо).

Лямбда-вираз у Java має такий синтаксис:

  • список формальних параметрів, розділених комами й укладених у круглі дужки; якщо параметр один, дужки можна опустити; якщо параметрів немає, потрібна порожня пара дужок;
  • стрілка (->);
  • тіло, що складається з одного виразу або блоку; якщо використовується блок, усередині нього може бути твердження return;

Наприклад, функція з одним параметром:

k -> k * k

Те саме з дужками та блоком:

(k) -> { return k * k; }

Функція з двома параметрами:

(a, b) -> a + b

Функція без параметрів:

() -> System.out.println("First")

Наприклад, маємо функціональний інтерфейс:

public interface SomeInt {
    int f(int x);
}

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

someFunc(new SomeInt() {
    @Override
    public int f(int x) {
        return x * x;
    }
});

Можна створити змінну типу об'єкта, що реалізує інтерфейс, і використовувати її замість безіменного класу:

SomeInt func = k -> k * k;
someFunc(func);

Можна також створити безіменний об'єкт під час виклику функції з параметром-функціональним інтерфейсом:

someFunc(x -> x * x);

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

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

package ua.inf.iwanoff.oop.first;

public interface FunctionToPrint {
    public double f(double x);
    default void print(double x) {
        System.out.printf("x = %9f f(x) = %9f%n", x, f(x));
    }
    static void printTable(double from, double to, double step, FunctionToPrint func) {
        for (double x = from; x <= to; x += step) {
            func.print(x);
        }
        System.out.println();
    }
}

Використання функціонального інтерфейсу з застосуванням лямбда-виразу:

package ua.inf.iwanoff.oop.first;

public class PrintWithLambda {
    public static void main(String[] args) {
        FunctionToPrint.printTable(-2.0, 2.0, 0.5, x -> x * x * x);
    }
}

2.10.2 Використання посилань на методи

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

Вид посилання на метод Синтаксис Приклад
Посилання на статичний метод ім'яКласу::ім'яСтатичногоМетоду String::valueOf
Посилання на нестатичний метод для заданого об'єкта ім'яОб'єкта::ім'яНестатичногоМетоду s::toString
Посилання на нестатичний метод для параметра ім'яКласу::ім'яНестатичногоМетоду Object::toString
Посилання на конструктор ім'яКласу::new String::new

Наприклад, є такі функціональні інтерфейси:

interface IntOperation {
    int f(int a, int b);
}

interface StringOperation {
    String g(String s);
}

Можна створити деякий клас:

class DifferentMethods
{
    public int add(int a, int b) {
        return a + b;
    }

    public static int mult(int a, int b) {
        return a * b;
    }

}

Викликаємо методи:

public class TestMethodReferences {

    static void print(IntOperation op, int a, int b) {
        System.out.println(op.f(a, b));
    }
  
    static void print(StringOperation op, String s) {
        System.out.println(op.g(s));
    }
  
    public static void main(String[] args) {
        DifferentMethods dm = new DifferentMethods();
        print(dm::add, 3, 4);
        print(DifferentMethods::mult, 3, 4);
        print(String::toUpperCase, "text");    
    }

}

2.10.3 Стандартні функціональні інтерфейси

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

Інтерфейс Опис
BiConsumer<T,U> Представляє операцію, яка приймає два вхідних аргументи та не повертає результату
BiFunction<T,U,R> Представляє функцію, яка приймає два аргументи і повертає результат
BinaryOperator<T> Представляє операцію над двома операндами одного типу, виробляючи результат того ж типу, що й операнди
BiPredicate<T,U> Представляє предикат (функцію з результатом типу boolean) з двома аргументами
BooleanSupplier Представляє "постачальника" результату типу boolean
Consumer<T> Представляє операцію, яка приймає один аргумент і не повертає результату
DoubleBinaryOperator Представляє операцію над двома аргументами типу double, яка повертає результат типу double
DoubleConsumer Представляє операцію, яка приймає один аргумент типу double і не повертає результату
DoubleFunction<R> Представляє операцію, яка приймає один аргумент типу double і повертає результат
DoublePredicate Представляє предикат (функцію з результатом типу boolean) з одним аргументом типу double
DoubleSupplier Представляє "постачальника" результату типу double
DoubleToIntFunction Представляє операцію, яка приймає один аргумент типу double і повертає результат типу int
DoubleToLongFunction Представляє операцію, яка приймає один аргумент типу double і повертає результат типу long
DoubleUnaryOperator Представляє операцію, яка приймає один аргумент типу double і повертає результат типу double
Function<T,R> Представляє операцію, яка приймає один аргумент і повертає результат
IntBinaryOperator Представляє операцію над двома аргументами типу int, яка повертає результат типу int
IntConsumer Представляє операцію, яка приймає один аргумент типу int і не повертає результату
IntFunction<R> Представляє операцію, яка приймає один аргумент типу int і повертає результат
IntPredicate Представляє предикат (функцію з результатом типу boolean) з одним аргументом типу int
IntSupplier Представляє "постачальника" результату типу int
IntToDoubleFunction Представляє операцію, яка приймає один аргумент типу int і повертає результат типу double
IntToLongFunction Представляє операцію, яка приймає один аргумент типу int і повертає результат типу long
IntUnaryOperator Представляє операцію, яка приймає один аргумент типу int і повертає результат типу int
LongBinaryOperator Представляє операцію над двома аргументами типу long, що повертає результат типу long
LongConsumer Представляє операцію, яка приймає один аргумент типу long і не повертає результату
LongFunction<R> Представляє операцію, яка приймає один аргумент типу long і повертає результат
LongPredicate Представляє предикат (функцію з результатом типу boolean) з одним аргументом типу long
LongSupplier Представляє "постачальника" результату типу long
LongToDoubleFunction Представляє операцію, яка приймає один аргумент типу long і повертає результат типу double
LongToIntFunction Представляє операцію, яка приймає один аргумент типу long і повертає результат типу int
LongUnaryOperator Представляє операцію, яка приймає один аргумент типу long і повертає результат типу long
ObjDoubleConsumer<T> Представляє функцію, яка приймає аргументи типів T і double і не повертає результату
ObjIntConsumer<T> Представляє функцію, яка приймає аргументи типів T і int і не повертає результату
ObjLongConsumer<T> Представляє функцію, яка приймає аргументи типів T і long і не повертає результату
Predicate<T> Представляє предикат (функцію з результатом типу boolean) з одним аргументом
Supplier<T> Представляє "постачальника" результату
ToDoubleBiFunction<T,U> Представляє функцію, яка приймає два аргументи і продукує результат типу double
ToDoubleFunction<T> Представляє функцію, яка продукує результат типу double
ToIntBiFunction<T,U> Представляє функцію, яка приймає два аргументи і продукує результат типу int
ToIntFunction<T> Представляє функцію, яка продукує результат типу int
ToLongBiFunction<T,U> Представляє функцію, яка приймає два аргументи і продукує результат типу long
ToLongFunction<T> Представляє функцію, яка продукує результат типу long
UnaryOperator<T> Представляє операцію над одним операндом, яка повертає результат того ж типу, що й операнд

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

2.10.4 Композиція лямбда-виразів

Можна здійснювати композицію лямбда-виразів (використовувати лямбда-вирази як параметри). З цією метою інтерфейси пакету java.util.function надають методи з усталеною реалізацією, що забезпечують виконання деякої функції, переданої як параметр до або після даного методу. Зокрема, в інтерфейсі Function визначені такі методи:

// Виконується функція before, а потім функція, що викликає:
Function compose(Function before)
// Функція after виконується після функції, що викликає:
Function andThen(Function after)

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

package ua.inf.iwanoff.oop.first;

import java.util.function.Function;

public class ComposeDemo {
  
    public static Double calc(Function<Double , Double> operator, Double x) {
        return operator.apply(x);
    }
  
    public static void main(String[] args) {
        Function<Double , Double> addTwo = x -> x + 2;
        Function<Double , Double> duplicate = x -> x * 2;
        System.out.println(calc(addTwo.compose(duplicate), 10.0)); // 22.0
        System.out.println(calc(addTwo.andThen(duplicate), 10.0)); // 24.0
    }

}

Композиція може бути більш складною:

System.out.println(calc(addTwo.andThen(duplicate).andThen(addTwo), 10.0)); // 26.0

2.11 Клонування об'єктів і перевірка еквівалентності

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

У базовому класі java.lang.Object є функція clone(), усталене використання якої дозволяє скопіювати об'єкт поелементно. Ця функція також визначена для масивів, рядків і інших стандартних класів. Наприклад, так можна отримати копію існуючого масиву і працювати з цією копією:

package ua.inf.iwanoff.oop.first;

import java.util.Arrays;

public class ArrayClone {
  
    public static void main(String[] args) {
        int[] a1 = { 1, 2, 3, 4 };
        int[] a2 = a1.clone(); // Копія елементів
        System.out.println(Arrays.toString(a2)); // [1, 2, 3, 4]
        a1[0] = 10; // змінюємо перший масив
        System.out.println(Arrays.toString(a1)); // [10, 2, 3, 4]
        System.out.println(Arrays.toString(a2)); // [1, 2, 3, 4]
    }

}

Для того, щоб можна було клонувати об'єкти користувальницьких класів, ці класи повинні реалізовувати інтерфейс Cloneable. Цей інтерфейс не оголошує жодного методу. Він всього лише вказує, що об'єкти даного класу можна клонувати. В іншому випадку виклик функції clone() призведе до генерації винятку типу CloneNotSupportedException.

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

Припустимо, нам потрібно клонувати об'єкти класу Human, що включає два поля типу Stringname і surname. Додаємо до опису класу реалізацію інтерфейсу Cloneable, генеруємо конструктор з двома параметрами, для зручності виведення вмісту полів перекриваємо функцію toString(). У функції main() здійснюємо тестування клонування об'єкта:

package ua.inf.iwanoff.oop.first;

public class Human implements Cloneable {
    private String name;
    private String surname;
  
    public Human(String name, String surname) {
        super();
        this.name = name;
        this.surname = surname;
    }

    @Override
    public String toString() {
        return name + " " + surname;
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        Human human1 = new Human("John", "Smith");
        Human human2 = (Human) human1.clone();
        System.out.println(human2); // John Smith
        human1.name = "Mary";
        System.out.println(human1); // Mary Smith
        System.out.println(human2); // John Smith
    }

}

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

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

  @Override
  public Human clone() throws CloneNotSupportedException {
      return (Human) super.clone();
  }
  . . .
  
  Human human2 = human1.clone();

Стандартне клонування, реалізоване в класі java.lang.Object, дозволяє створювати копії об'єктів, поля яких – типи значення і тип String (а також класи-обгортки). Якщо поля об'єкта – посилання на масиви або інші типи, необхідно застосовувати так зване "глибоке" клонування. Припустимо, певний клас SomeCloneableClass містить два поля типу double масив цілих. "Глибоке" клонування забезпечить створення окремих масивів для різних об'єктів.

package ua.inf.iwanoff.oop.first;

import java.util.Arrays;

public class SomeCloneableClass implements Cloneable {
    private double x, y;
    private int[] a;
  
    public SomeCloneableClass(double x, double y, int[] a) {
        super();
        this.x = x;
        this.y = y;
        this.a = a;
    }

    @Override
    protected SomeCloneableClass clone() throws CloneNotSupportedException {
        SomeCloneableClass scc = (SomeCloneableClass) super.clone(); // копіюємо х і y
        scc.a = a.clone(); // тепер два об'єкти працюють з різними масивами
        return scc;
    }

    @Override
    public String toString() {
        return " x=" + x + " y=" + y + " a=" + Arrays.toString(a);
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        SomeCloneableClass scc1 = new SomeCloneableClass(0.1, 0.2, new int[] { 1, 2, 3 });
        SomeCloneableClass scc2 = scc1.clone();
        scc2.a[2] = 4;
        System.out.println("scc1:" + scc1);
        System.out.println("scc2:" + scc2);
    }

}

Для того, щоб переконатися, що клоновані об'єкти однакові, не завадило б мати можливість автоматичного порівняння всіх полів. Посилальна модель об'єктів Java не дозволяє порівнювати вміст об'єктів за допомогою операції порівняння (==), оскільки при цьому порівнюються посилання. Для порівняння даних доцільно використовувати функцію equals(), визначену в класі java.lang.Object. Для класів, полями яких є типи-значення, метод класу Object забезпечує поелементне порівняння. Якщо ж полями є посилання на об'єкти, необхідно явно перевизначити функцію equals(). Типова реалізація методу equals() передбачає перевірку посилань (чи вони збігаються), далі перевірку об'єкта, який ми порівнюємо, на значення null, потім – перевірку типу, наприклад, за допомогою instanceof. Якщо типи збігаються, здійснюється перевірка значень полів.

Наведемо повний приклад з класом Human.

package ua.inf.iwanoff.oop.first;

public class Human implements Cloneable {
    private String name;
    private String surname;

    public Human(String name, String surname) {
        super();
        this.name = name;
        this.surname = surname;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || !(obj instanceof Human)) {
            return false;
        }
        Human h = (Human) obj;
        return name.equals(h.name) && surname.equals(h.surname);
    }

    @Override
    public Human clone() throws CloneNotSupportedException {
        return (Human) super.clone();
    }

    @Override
    public String toString() {
        return name + " " + surname;
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        Human human1 = new Human("John", "Smith");
        Human human2 = human1.clone();
        System.out.println(human2);
        human1.name = "Mary";
        System.out.println(human1);
        System.out.println(human2);
        human2.name = new String("Mary");
        System.out.println(human2);
        System.out.println(human1.equals(human2)); // true
    }

}

Якби метод equals() не було визначено, останнє порівняння дало б false.

Для порівняння двох масивів доцільно викликати статичну функцію equals() класу Arrays. Ця функція порівнює елементи масивів (викликає метод equals()):

Arrays.equals(array1, array2);

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

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

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

Для того, щоб одержати рядкове представлення об'єкта, необхідно перекрити функцію toString()

Можна запропонувати таку ієрархію класів.

package ua.inf.iwanoff.oop.first;

import java.util.*;

// Ієрархія класів
class Region {
    private String name;
    private double area;

    public Region(String name, double area) {
        this.name = name;
        this.area = area;
    }

    public String getName() {
        return name;
    }

    public double getArea() {
        return area;
    }

    @Override
    public String toString() {
        return "Регіон " + name + ".\tТериторія   " + area + " кв.км.";
    }
  
}

class PopulatedRegion extends Region {
    private int population;

    public PopulatedRegion(String name, double area, int population) {
        super(name, area);
        this.population = population;
    }

    public int getPopulation() {
        return population;
    }

    public int density() {
        return (int) (population / getArea());
    }

    @Override
    public String toString() {
        return "Населений регіон " + getName() + ".\tТериторія   " + getArea() +
               " кв.км.   \tНаселення " + population + 
               " чол.\tЩільність населення " + density() + " чол/кв.км.";
    }

}

class Country extends PopulatedRegion {
    private String capital;

    public Country(String name, double area, int population, String capital) {
        super(name, area, population);
        this.capital = capital;
    }

    public String getCapital() {
        return capital;
    }

    @Override
    public String toString() {
        return "Країна " + getName() + ".\tТериторія   " + getArea() +
               " кв.км.   \tНаселення " + getPopulation() + 
               " чол.\tЩільність населення " + density() + 
               " чол/кв.км.\tСтолиця " + capital;
    }

}

class City extends PopulatedRegion {
    private int boroughs; // Кількість районів

    public City(String name, double area, int population, int boroughs) {
        super(name, area, population);
        this.boroughs = boroughs;
    }

    public int getBoroughs() {
        return boroughs;
    }

    @Override
    public String toString() {
        return "Місто " + getName() + ".\tТериторія   " + getArea() +
               " кв.км.   \tНаселення " + getPopulation() + 
               " чол.\tЩільність населення " + density() + 
               " чол/кв.км.\tРайонів – " + boroughs;
    }
  
}

class Island extends PopulatedRegion {
    private String sea;

    public Island(String name, double area, int population, String sea) {
        super(name, area, population);
        this.sea = sea;
    }

    public String getSea() {
        return sea;
    }

    @Override
    public String toString() {
        return "Острів " + getName() + ".\tТериторія   " + getArea() +
               " кв.км.   \tНаселення " + getPopulation() + 
               " чол.\tЩільність населення " + density() + 
               " чол/кв.км.\tМоре – " + sea;
    }  
}

public class Regions {
  
    public static void main(String[] args) {
        Region[] a = { new City("Київ", 839, 2679000, 10),
                       new Country("Україна", 603700, 46294000, "Київ"),
                       new City("Харків", 310, 1461000, 9),
                       new Island("Зміїний", 0.2, 30, "Чорне") };
        for (Region region : a) {
            System.out.println(region);
        }
    }

}   

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

3.2.1 Постановка завдання і створення абстрактного класу

Припустимо, необхідно розробити клас для представлення масиву точок. Кожна точка представлена двома числами типу doublex і y. Необхідно забезпечити завдання точки, отримання інформації про координати конкретної точки та загальну кількість точок, а також додавання точки в кінець масиву і видалення останньої точки. Крім того, необхідно організувати сортування масиву за зростанням заданої координати і виведення координат точок у рядок.

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

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

package ua.inf.iwanoff.oop.first;

public abstract class AbstractArrayOfPoints {
    // Запис нових координат точки:
    public abstract void setPoint(int i, double x, double y);

    // Отримання X точки i:
    public abstract double getX(int i);

    // Отримання Y точки i:
    public abstract double getY(int i);

    // Отримання кількості точок:
    public abstract int count();

    // Додавання точки в кінець масиву:
    public abstract void addPoint(double x, double y);

    // Видалення останньої точки:
    public abstract void removeLast();

    // Сортування за значеннями X:
    public void sortByX() {
        boolean mustSort; // Повторюємо доти,
                          // доки mustSort дорівнює true
        do {
            mustSort = false;
            for (int i = 0; i < count() - 1; i++) {
                if (getX(i) > getX(i + 1)) {
                    // обмінюємо елементи місцями
                    double x = getX(i);
                    double y = getY(i);
                    setPoint(i, getX(i + 1), getY(i + 1));
                    setPoint(i + 1, x, y);
                    mustSort = true;
                }
            }
        }
        while (mustSort);
    }

    // Аналогічно можна реалізувати функцію sortByY()

    // Виведення точок у рядок:
    @Override
    public String toString() {
        String s = "";
        for (int i = 0; i < count(); i++) {
            s += "x = " + getX(i) + " \ty = " + getY(i) + "\n";
        }
        return s + "\n";
    }

    // Тестуємо сортування на чотирьох точках:
    public void test() {
        addPoint(22, 45);
        addPoint(4, 11);
        addPoint(30, 5.5);
        addPoint(-2, 48);
        sortByX();
        System.out.println(this);
    }

}

Тепер можна реалізувати різні варіанти представлення структури даних.

3.2.2 Реалізація через масив об'єктів типу Point

Першою з можливих реалізацій буде створення класу Point та використання масиву посилань на Point. Клас для представлення точки можна додати в той же пакет. Клас Point міститиме два поля і конструктор:

package ua.inf.iwanoff.oop.first;

public class Point {
    private double x, y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double getX() {
        return x;
    }

    public double getY() {
        return y;
    }

    public void setPoint(double x, double y) {
        this.x = x;
        this.y = y;
    }
  
}

У тому ж проекті створюємо клас ArrayOfPointObjects. У класі ArrayOfPointObjects створюємо поле – посилання на масив Point та ініціалізуємо його порожнім масивом. Реалізація більшості функцій видається очевидною. Найбільшу складність являють функції додавання та видалення точок. В обох випадках необхідно створити новий масив потрібної довжини й переписати в нього вміст старого. У функції main() здійснюємо тестування. Весь код файлу AbstractArrayOfPoints.java матиме такий вигляд:

package ua.inf.iwanoff.oop.first;

public class ArrayOfPointObjects extends AbstractArrayOfPoints {
    private Point[] p = { };
  
    @Override
    public void setPoint(int i, double x, double y) {
        if (i < count()) {
            p[i].setPoint(x, y);
        }
    }

    @Override
    public double getX(int i) {
        return p[i].getX();
    }

    @Override
    public double getY(int i) {
        return p[i].getY();
    }

    @Override
    public int count() {
        return p.length;
    }

    @Override
    public void addPoint(double x, double y) {
        // Створюємо масив, більший на один елемент:
        Point[] p1 = new Point[p.length + 1];
        // Копіюємо всі елементи:
        System.arraycopy(p, 0, p1, 0, p.length);
        // Записуємо нову точку в останній елемент:
        p1[p.length] = new Point(x, y);
        p = p1; // Тепер p вказує на новий масив
    }

    @Override
    public void removeLast() {
        if (p.length == 0) {
            return; // Масив вже порожній
        }
        // Створюємо масив, менший на один елемент:
        Point[] p1 = new Point[p.length - 1];
        // Копіюємо всі елементи, крім останнього:
        System.arraycopy(p, 0, p1, 0, p1.length);
        p = p1; // Тепер p вказує на новий масив
    }

    public static void main(String[] args) {
        // Можна створити безіменний об'єкт:
        new ArrayOfPointObjects().test();
    }

}

У результаті отримаємо в консольному вікні точки, розсортовані за координатою X.

3.2.3 Реалізація через два масиви

Альтернативна реалізація передбачає створення двох масивів для окремого зберігання значень X і Y. Створюємо клас ArrayWithTwoArrays із використанням аналогічних опцій. У класі ArrayWithTwoArrays створюємо два поля – посилання на масиви дійсних чисел і ініціалізуємо їх порожніми масивами. Реалізація функцій аналогічна попередньому варіанту. У функції main() здійснюємо тестування:

package ua.inf.iwanoff.oop.first;

public class ArrayWithTwoArrays extends AbstractArrayOfPoints {
    private double[] ax = { };
    private double[] ay = { };
  
    @Override
    public void setPoint(int i, double x, double y) {
        if (i < count()) {
            ax[i] = x;
            ay[i] = y;
        }
    }

    @Override
    public double getX(int i) {
        return ax[i];
    }

    @Override
    public double getY(int i) {
        return ay[i];
    }

    @Override
    public int count() {
        return ax.length; // Можна ay.length, вони однакові
    }

    @Override
    public void addPoint(double x, double y) {
        double[] ax1 = new double[ax.length + 1];
        System.arraycopy(ax, 0, ax1, 0, ax.length);
        ax1[ax.length] = x;
        ax = ax1;
        double[] ay1 = new double[ay.length + 1];
        System.arraycopy(ay, 0, ay1, 0, ay.length);
        ay1[ay.length] = y;
        ay = ay1;
    }

    @Override
    public void removeLast() {
        if (count() == 0) {
            return;
        }
        double[] ax1 = new double[ax.length - 1];
        System.arraycopy(ax, 0, ax1, 0, ax1.length);
        ax = ax1;
        double[] ay1 = new double[ay.length - 1];
        System.arraycopy(ay, 0, ay1, 0, ay1.length);
        ay = ay1;
    }

    public static void main(String[] args) {
        new ArrayWithTwoArrays().test();
    }

}

Результати мають бути ідентичними.

3.3 Використання інтерфейсів з усталеною реалізацією методів

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

f '(x) = (f(x + dx) – f(x)) / dx

Чим менше dx, тим точніше буде знайдена похідна. Другу похідну можна знайти як похідну першої похідної.

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

xn+1 = xnf(xn) / f '(xn)

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

package ua.inf.iwanoff.oop.first;

public interface FunctionWithDerivatives {
    double DX = 0.001;
  
    double f(double x);
  
    default double f1(double x) {
        return (f(x + DX) - f(x)) / DX;
    }
  
    default double f2(double x) {
        return (f1(x + DX) - f1(x)) / DX;
    }
}

Реалізуємо клас зі статичною функцією розв'язання рівняння:

package ua.inf.iwanoff.oop.first;

public class Newton {
  
    public static double solve(double from, double to, double eps, FunctionWithDerivatives func) {
        double x = from;
        if (func.f(x) * func.f2(x) < 0) { // знаки різні
            x = to;
        }
        double d;
        do {
            d = func.f(x) / func.f1(x);
            x -= d;
        }
        while (Math.abs(d) > eps);
        return x;
    }
}

Створюємо клас, який реалізує інтерфейс, і здійснюємо розв'язання рівняння:

package ua.inf.iwanoff.oop.first;

public class FirstImplementation implements FunctionWithDerivatives {

    @Override
    public double f(double x) {
        return Math.sin(x - 0.5);
    }
  
    public static void main(String[] args) {
        System.out.println(Newton.solve(0, 1, 0.000001, new FirstImplementation()));
    }
}

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

f(x) = x36x2 + 12x – 9

можна так визначити першу і другу похідну:

f '(x) = 3x2 – 12x + 12
f ''(x) = 6x – 12

Тоді клас, який реалізує інтерфейс, може бути таким:

package ua.inf.iwanoff.oop.first;

public class SecondImplementation implements FunctionWithDerivatives {

    @Override
    public double f(double x) {
        return x * x * x - 6 * x * x + 12 * x - 9;
    }

    @Override
    public double f1(double x) {
        return 3 * x * x - 12 * x + 12;
    }

    @Override
    public double f2(double x) {
        return 6 * x - 12;
    }

    public static void main(String[] args) {
        System.out.println(Newton.solve(0, 1, 0.000001, new SecondImplementation()));
    }
}

Явне визначення похідних може підвищити ефективність алгоритму.

3.4 Розв'язання рівняння методом дихотомії

3.4.1 Постановка задачі

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

f(x) = 0

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

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

3.4.2 Використання абстрактного класу

Перший варіант ґрунтується на використанні абстрактного класу. Створюємо новий клас – AbstractEquation, який містить абстрактну функцію f() і функцію розв'язання рівняння – solve():

package ua.inf.iwanoff.oop.first;

public abstract class AbstractEquation {
    public abstract double f(double x);
  
    public double solve(double a, double b, double eps) {
        double x = (a + b) / 2;
        while (Math.abs(b - a) > eps) {
            if (f(a) * f(x) > 0) {
                a = x;
            }
            else {
                b = x;
            }
            x = (a + b) / 2;
        }
        return x;
    }
}

Тепер можна створити клас з конкретною функцією f():

package ua.inf.iwanoff.oop.first;

public class SpecificEquation extends AbstractEquation {
    @Override
    public double f(double x) {
        return x * x - 2;
    }

    public static void main(String[] args) {
        SpecificEquation se = new SpecificEquation();
        System.out.println(se.solve(0, 2, 0.000001));
    }

}

3.4.3 Використання інтерфейсу і класу, який його реалізує

Інтерфейси пропонують альтернативний шлях рішення цієї проблеми. Ми можемо описати інтерфейс для представлення лівої частини рівняння.

Для створення інтерфейсу в середовищі IntelliJ IDEA використовується функція головного менюFile | New | Java Class, далі вводимо ім'я та зі списку вибираємо Interface.

Примітка: для створення інтерфейсу в середовищі Eclipse використовується функція File | New | Interafce головного меню.

package ua.inf.iwanoff.oop.first;

public interface LeftSide {
    double f(double x);
}

Клас Solver реалізує статичний метод для розв'язання рівняння:

package ua.inf.iwanoff.oop.first;

public class Solver {
    static double solve(double a, double b, double eps, LeftSide ls) {
        double x = (a + b) / 2;
        while (Math.abs(b - a) > eps) {
          if (ls.f(a) * ls.f(x) > 0) {
                a = x;
            }
            else {
                b = x;
            }
            x = (a + b) / 2;
        }
        return x;
    }
}

Клас, який реалізує інтерфейс, містить конкретну реалізацію функції f():

package ua.inf.iwanoff.oop.first;

class MyEquation implements LeftSide {
    @Override
    public double f(double x) {
        return x * x - 2;
    }
}

public class InterfaceTest {

    public static void main(String[] args) {
        System.out.println(Solver.solve(0, 2, 0.000001, new MyEquation()));
    }

}

Програму можна модифікувати з урахуванням можливостей Java 8 і функціональних інтерфейсів. Метод знаходження кореня можна реалізувати всередині інтерфейсу:

package ua.inf.iwanoff.oop.first;

public interface FunctionToSolve {
    double f(double x);
  
    static double solve(double a, double b, double eps, FunctionToSolve func) {
        double x = (a + b) / 2;
        while (Math.abs(b - a) > eps) {
            if (func.f(a) * func.f(x) > 0) {
                a = x;
            }
            else {
                b = x;
            }
            x = (a + b) / 2;
        }
        return x;
    }
}

Тепер замість Solver.solve() слід викликати FunctionToSolve.solve().

3.4.4 Використання безіменного класу

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

package ua.inf.iwanoff.oop.first;

public class SolveUsingAnonymousClass {

    public static void main(String[] args) {
        System.out.println(FunctionToSolve.solve(0, 2, 0.000001, new FunctionToSolve() {
            @Override
            public double f(double x) {
                return x * x - 2;
            }
        }));
    }

}

3.4.5 Використання лямбда-виразів

У задачі розв'язання рівняння методом дихотомії можна визначити ліву частину рівняння лямбда-виразом (замість безіменного класу):

package ua.inf.iwanoff.oop.first;

public class SolveUsingLambda {

    public static void main(String[] args) {
        System.out.println(FunctionToSolve.solve(0, 2, 0.000001, x -> x * x - 2));
    }

}

3.4.6 Використання посилань на методи

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

package ua.inf.iwanoff.oop.first;

public class SolveUsingReference {

    public static double f(double x) {
        return x * x - 2;
    }
  
    public static void main(String[] args) {
        System.out.println(FunctionToSolve.solve(0, 2, 0.000001, SolveUsingReference::f));
    }

}

3.5 Ієрархія класів "Країна" та "Перепис населення"

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

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

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

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

Створюємо новий проект і додаємо новий пакет ua.inf.iwanoff.oop.first. Створюємо абстрактний клас AbstractCensus. Його код (частково запозичений з раніше створеного класу Census прикладу лабораторної роботи № 5 курсу "Основи програмування" попереднього семестру) буде таким:

package ua.inf.iwanoff.oop.first;

import java.util.StringTokenizer;

public abstract class AbstractCensus implements Comparable<AbstractCensus> {

    @Override
    public String toString() {
        return "Перепис " + getYear() + " року. Населення: " + getPopulation() +
               ". Коментар: " + getComments();
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || !(obj instanceof AbstractCensus)) {
            return false;
        }
        AbstractCensus c = (AbstractCensus) obj;
        return c.getYear() == getYear() &&
                c.getPopulation() == getPopulation() &&
                c.getComments().equals(getComments());
    }

    @Override
    public int compareTo(AbstractCensus c) {
        return Integer.compare(getPopulation(), c.getPopulation());
    }

    public abstract String getComments();
    public abstract void setComments(String comments);
    public abstract int getPopulation();
    public abstract void setPopulation(int population);
    public abstract int getYear();
    public abstract void setYear(int year);

    public boolean containsWord(String word) {
        StringTokenizer st = new StringTokenizer(getComments());
        String s;
        while (st.hasMoreTokens()) {
            s = st.nextToken();
            if (s.equalsIgnoreCase(word)) {
                return true;
            }
        }
        return false;
    }

    public boolean containsSubstring(String substring) {
        return getComments().toUpperCase().indexOf(substring.toUpperCase()) >= 0;
    }

    private void testWord(String word) {
        if (containsWord(word)) {
            System.out.println("Слово \"" + word + "\" міститься у коментарі");
        }
        else {
            System.out.println("Слово \"" + word + "\" не міститься у коментарі");
        }
        if (containsSubstring(word)) {
            System.out.println("Текст \"" + word + "\" міститься у коментарі");
        }
        else {
            System.out.println("Текст \"" + word + "\" не міститься у коментарі");
        }
    }

    protected void testCensus() {
        setYear(2001);
        setPopulation(48475100);
        setComments("Перший перепис у незалежній Україні");
        System.out.println(this);
        testWord("Україні");
        testWord("Країні");
        testWord("Україна");
    }

}

Як видно з наведеного коду, клас AbstractCensus реалізує інтерфейс Comparable<AbstractCensus>. Реалізація цього інтерфейсу вимагає додавання методу compareTo(), в якому визначене "природне" порівняння – за кількістю населення.

Похідний клас CensusWithData містить опис полів даних і реалізацію методів доступу, а також метод main():

package ua.inf.iwanoff.oop.first;

public class CensusWithData extends AbstractCensus {
    private int year;
    private int population;
    private String comments;

    public CensusWithData() {
    }

    public CensusWithData(int year, int population, String comments) {
        this.year = year;
        this.population = population;
        this.comments = comments;
    }

    @Override
    public int getYear() {
        return year;
    }

    @Override
    public void setYear(int year) {
        this.year = year;
    }

    @Override
    public int getPopulation() {
        return population;
    }

    @Override
    public void setPopulation(int population) {
        this.population = population;
    }

    @Override
    public String getComments() {
        return comments;
    }

    @Override
    public void setComments(String comments) {
        this.comments = comments;
    }

    public static void main(String[] args) {
        new CensusWithData().testCensus();
    }
}

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

package ua.inf.iwanoff.oop.first;

import java.util.Arrays;

public abstract class AbstractCountry {
    public abstract String getName();
    public abstract void setName(String name);
    public abstract double getArea();
    public abstract void setArea(double area);
    public abstract AbstractCensus getCensus(int i);
    public abstract void setCensus(int i, AbstractCensus census);
    public abstract boolean addCensus(AbstractCensus census);
    public abstract boolean addCensus(int year, int population, String comments);
    public abstract int censusesCount();
    public abstract void clearCensuses();
    public abstract void sortByPopulation();
    public abstract void sortByComments();
    public abstract void setCensuses(AbstractCensus[] censuses);
    public abstract AbstractCensus[] getCensuses();

    public static AbstractCensus[] addToArray(AbstractCensus[] arr, AbstractCensus item) {
        AbstractCensus[] newArr;
        if (arr != null) {
            newArr = new AbstractCensus[arr.length + 1];
            System.arraycopy(arr, 0, newArr, 0, arr.length);
        }
        else {
            newArr = new AbstractCensus[1];
        }
        newArr[newArr.length - 1] = item;
        return newArr;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || !(obj instanceof AbstractCountry)) {
            return false;
        }
        AbstractCountry c = (AbstractCountry) obj;
        if (!getName().equals(c.getName()) || getArea() != c.getArea()) {
            return false;
        }
        return Arrays.equals(getCensuses(), c.getCensuses());
    }

    @Override
    public String toString() {
        String result = getName() + ". Територія: " + getArea() + " кв. км.";
        for (int i = 0; i < censusesCount(); i++) {
            result += "\n" + getCensus(i);
        }
        return result;
    }

    public double density(int year) {
        for (int i = 0; i < censusesCount(); i++) {
            if (year == getCensus(i).getYear()) {
                return getCensus(i).getPopulation() / getArea();
            }
        }
        return 0;
    }

    public int maxYear() {
        AbstractCensus census = getCensus(0);
        for (int i = 1; i < censusesCount(); i++) {
            if (census.getPopulation() < getCensus(i).getPopulation()) {
                census = getCensus(i);
            }
        }
        return census.getYear();
    }

    public AbstractCensus[] findWord(String word) {
        AbstractCensus[] result = null;
        for (AbstractCensus census : getCensuses()) {
            if (census.containsWord(word)) {
                result = addToArray(result, census);
            }
        }
        return result;
    }

    private void printWord(String word) {
        AbstractCensus[] result = findWord(word);
        if (result == null) {
            System.out.println("Слово \"" + word + "\" не міститься в коментарях.");
        }
        else {
            System.out.println("Слово \"" + word + "\" міститься в коментарях:");
            for (AbstractCensus census : result) {
                System.out.println(census);
            }
        }
    }

    public AbstractCountry createCountry() {
        setName("Україна");
        setArea(603628);
        // Додавання переписів з виведенням результату (false / true):
        System.out.println(addCensus(1959, 41869000, "Перший післявоєнний перепис"));
        System.out.println(addCensus(1970, 47126500, "Нас побільшало"));
        System.out.println(addCensus(1979, 49754600, "Просто перепис"));
        System.out.println(addCensus(1989, 51706700, "Останній радянський перепис"));
        System.out.println(addCensus(2001, 48475100, "Перший перепис у незалежній Україні"));
        System.out.println(addCensus(2001, 48475100, "Перший перепис у незалежній Україні"));
        return this;
    }

    public void testCountry() {
        System.out.println("Щільність населення у 1979 році: " + density(1979));
        System.out.println("Рік з найбільшим населенням: " + maxYear() + "\n");

        printWord("перепис");
        printWord("запис");

        sortByPopulation();
        System.out.println("\nСортування за кількістю населення:");
        System.out.println(this);

        sortByComments();
        System.out.println("\nСортування за алфавітом коментарів:");
        System.out.println(this);
    }
}

Для забезпечення порівняння переписів під час сортування створюємо окремий клас CompareByComments:

package ua.inf.iwanoff.oop.first;

import java.util.Comparator;

public class CompareByComments implements Comparator<AbstractCensus> {

    @Override
    public int compare(AbstractCensus c1, AbstractCensus c2) {
        return c1.getComments().compareTo(c2.getComments());
    }

}

У похідному класі використовуємо масив для представлення послідовності переписів. Код класу CountryWithArray буде таким:

package ua.inf.iwanoff.oop.first;

import java.util.Arrays;

public class CountryWithArray extends AbstractCountry {
    private String name;
    private double area;

    private AbstractCensus[] censuses;

    @Override
    public String getName() {
        return name;
    }

    @Override
    public void setName(String name) {
        this.name = name;
    }

    @Override
    public double getArea() {
        return area;
    }

    @Override
    public void setArea(double area) {
        this.area = area;
    }

    @Override
    public AbstractCensus getCensus(int i) {
        return censuses[i];
    }

    @Override
    public void setCensus(int i, AbstractCensus census) {
        censuses[i] = census;
    }

    @Override
    public boolean addCensus(AbstractCensus census) {
        if (getCensuses() != null) {
            for (AbstractCensus c : getCensuses()) {
                if (c.equals(census)) {
                    return false;
                }
            }
        }
        setCensuses(addToArray(getCensuses(), census));
        return true;
    }

    @Override
    public boolean addCensus(int year, int population, String comments) {
        AbstractCensus census = new CensusWithData(year, population, comments);
        return addCensus(census);
    }

    @Override
    public int censusesCount() {
        return censuses.length;
    }

    @Override
    public void clearCensuses() {
        censuses = null;
    }

    @Override
    public AbstractCensus[] getCensuses() {
        return censuses;
    }

    @Override
    public void setCensuses(AbstractCensus[] censuses) {
        this.censuses = censuses;
    }

    @Override
    public void sortByPopulation() {
        Arrays.sort(censuses);
    }

    @Override
    public void sortByComments() {
        Arrays.sort(censuses, new CompareByComments());
    }

    public static void main(String[] args) {
        new CountryWithArray().createCountry().testCountry();
    }
}

Під час виконання програми в консольне вікно спочатку виводяться результати функцій addCensus() – п'ять разів true і один раз false.

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

  1. Створити ієрархію класів Книга та Підручник. Реалізувати конструктори та функції доступу. Перекрити функцію toString(). У функції main() створити масив, який містить елементи різних типів. Вивести елементи на екран.
  2. Створити ієрархію класів Кінофільм і Серіал. Реалізувати конструктори та функції доступу. Перекрити функцію toString(). У функції main() створити масив, який містить елементи різних типів. Вивести елементи на екран.
  3. Створити ієрархію класів Місто та Столиця. Реалізувати конструктори та функції доступу. Перекрити функцію toString(). У функції main() створити масив, який містить елементи різних типів. Вивести елементи на екран.
  4. Створити клас для представлення іменованої матриці з полем типу String – іменем матриці і полем, що представляє двовимірний масив. Реалізувати методи клонування, перевірки еквівалентності та отримання подання у вигляді рядку. Здійснити тестування.

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

  1. У яких випадках доцільно використовувати композицію класів?
  2. Чи можна в Java цілком розмістити один об'єкт усередині іншого об'єкта?
  3. У чому полягає зміст успадкування?
  4. У чому є сенс наявності спільного базового класу?
  5. Які елементи базового класу не успадковуються?
  6. Як здійснити ініціалізацію базового класу?
  7. Як викликати однойменний метод базового класу з похідного?
  8. Де і для чого можна застосовувати ключове слово super?
  9. Як перекрити метод з модифікатором final?
  10. Чи допускається множинне успадкування класів?
  11. Чи можна неявно приводити посилання на базовий клас до посилання на похідний клас?
  12. У чому є сенс застосування анотацій?
  13. Які можливості надає використання поліморфізму?
  14. Чим віртуальна функція відрізняється від невіртуальної?
  15. Як у Java указати, що функція віртуальна?
  16. Чи можна в класах, описаних як final, створювати віртуальні функції?
  17. Чому функції з модифікатором private не є віртуальними?
  18. Чи можна створити абстрактний клас без абстрактних методів?
  19. Чи можуть абстрактні класи містити неабстрактні методи?
  20. У чому перевага інтерфейсів у порівнянні з абстрактними класами?
  21. Чи можуть інтерфейси містити поля?
  22. Чи допускається множинне успадкування інтерфейсів?
  23. Яким вимогам повинен відповідати клас, який реалізує інтерфейс?
  24. Чи може клас реалізовувати кілька інтерфейсів?
  25. Яким вимогам повинен задовольняти об'єкт, щоб масив таких об'єктів можна було сортувати без визначення ознаки сортування?
  26. Як визначити спеціальне правило для сортування елементів масиву?
  27. Як звернутися до локального класу ззовні блоку?
  28. Чи може в одному файлі з сирцевим кодом бути визначене більш одного відкритого класу?
  29. Чи можна створювати об'єкт нестатичного внутрішнього класу, не створюючи об'єкту обхопного класу?
  30. Чи можуть нестатичні внутрішні класи містити статичні елементи?
  31. Чим відрізняються статичні вкладені класи від внутрішніх?
  32. Чи можуть статичні вкладені класи містити нестатичні елементи?
  33. Чи можна створювати класи усередині інтерфейсів?
  34. Чи є локальний клас статичним?
  35. Чи є безіменний клас статичним?
  36. Чому не можна створити явний конструктор безіменного класу?
  37. Що таке лямбда-вираз?
  38. Що таке функціональний інтерфейс?
  39. Які переваги надають лямбда-вирази?
  40. Для чого використовуються посилання на методи?
  41. У чому полягає процес клонування об'єктів?
  42. Для чого використовується перевизначення функції equals()?
  43. Як здійснити порівняння елементів масивів?

 

up