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

Робота з великими числами та наборами даних

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

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

Спроєктувати та реалізувати класи для представлення сутностей третьої лабораторної роботи курсу "Основи програмування Java". Рішення повинне базуватися на раніше створеній ієрархії класів.

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

Програма повинна демонструвати:

  • відтворення функціональності лабораторних робіт № 3 і № 4 курсу "Основи програмування Java";
  • використання засобів Stream API для всіх функцій обробки та виведення послідовностей;
  • тестування методів окремих класів з використанням JUnit.

1.2 Знаходження цілого степеня

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

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

1.3 Фільтрація та сортування

Створити список об'єктів типу BigDecimal. Заповнити список випадковими значеннями. Здійснити сортування за зменшенням абсолютної величини. Знайти добуток додатних чисел. Реалізувати три підходи:

  • з використанням циклів і умовних тверджень (без засобів, доданих у Java 8);
  • без явних циклів і розгалужень, з використанням функцій, які були визначені в інтерфейсах Java Collection Framework починаючи з Java 8;
  • з використанням засобів Stream API.

Забезпечити тестування класів з використанням JUnit.

1.4 Пошук усіх дільників (додаткове завдання)

З використанням засобів Stream API організувати пошук усіх дільників цілого додатного числа. Створити окрему статичну функцію, яка приймає ціле число і повертає масив цілих чисел. Всередині функції створювати потік IntStream. Застосувати функцію range() і фільтр. Не використовувати явних циклів.

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

2.1 Загальна характеристика платформи Java SE

Java Platform, Standard Edition, (Java SE) – стандартна версія платформи Java, призначена для створення та виконання застосунків, розрахованих на індивідуальне користування або на використання в масштабах малого підприємства. Java SE визначається специфікацією пакетів і класів, які забезпечують розв'язання задач за такими напрямками:

  • робота з математичними функціями;
  • робота з контейнерними класами;
  • робота з часом та календарем;
  • робота з текстом;
  • інтернаціоналізація і локалізація;
  • робота з регулярними виразами;
  • робота з потоками введення-виведення та файловою системою;
  • робота з XML;
  • серіалізація та десеріалізація;
  • створення програм графічного інтерфейсу користувача;
  • використання графічних засобів;
  • підтримка друку;
  • підтримка роботи зі звуком;
  • використання RTTI, рефлексії та завантажувачів класів;
  • використання потоків виконання;
  • робота з базами даних;
  • Java Native Interface;
  • засоби виконання сценаріїв (скриптів);
  • підтримка мережної взаємодії;
  • взаємодія з програмним середовищем;
  • забезпечення безпеки застосунків;
  • підтримка ведення системного журналу;
  • розгортання Java-застосунків.

Далі розглядатимуться деякі можливості Java SE.

2.2 Використання типів BigInteger і BigDecimal

2.2.1 Загальні відомості

Для математичних обчислень, крім вбудованих типів-значень, можна використовувати об'єкти класів, похідних від java.lang.Number. Раніше були розглянуті класи Byte, Double, Float, Integer, Long і Short, похідні від Number. Існують також класи java.math.BigInteger і java.math.BigDecimal, що дозволяють працювати з числами довільної точності.

Як усі класи, похідні від абстрактного класу java.lang.Number, класи BigInteger і BigDecimal реалізують методи перетворення у наявні примітивні типи:

  • byte byteValue()
  • double doubleValue()
  • float floatValue()
  • int intValue()
  • long longValue()
  • short shortValue()

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

Обидва класи реалізують інтерфейс Comparable і для порівняння таких чисел можна застосовувати метод compareTo(). Цей метод повертає –1, якщо поточний об'єкт менший за параметр, 1, якщо поточний об'єкт більший, і 0, якщо значення однакові.

2.2.2 Використання BigInteger

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

BigInteger number1 = new BigInteger("12345678901234567890");
BigInteger number2 = new BigInteger(new byte[] { 1, 2, 3 });
BigInteger number3 = new BigInteger(100, new Random()); // 100 - кількість бітів

Якщо BigInteger створювати з масиву байтів, вони визначають внутрішнє двійкове представлення числа. В нашому випадку number2 буде представляти ціле число 10000001000000011 у двійковій системі, або 66051.

Для ініціалізації цілим числом застосовують статичний фабричний метод valueOf():

int n = 100000;
BigInteger number4 = BigInteger.valueOf(n);

Для зручності роботи у класі BigInteger визначено константи BigInteger.ZERO, BigInteger.ONE, BigInteger.TWO і BigInteger.TEN.

Окрім перевантаження базового методу toString(), реалізовано також функцію toString() з параметром – основою системи числення:

BigInteger number = BigInteger.valueOf(122);
System.out.println(number.toString(2));  // 1111010
System.out.println(number.toString(3));  // 11112
System.out.println(number.toString(12)); // a2
System.out.println(number.toString(16)); // 7a

Замість традиційних операцій необхідно використовувати методи класу BigInteger:

  • BigInteger add(BigInteger secondOperand) повертає суму двох чисел;
  • BigInteger subtract(BigInteger secondOperand) повертає різницю двох чисел;
  • BigInteger multiply(BigInteger secondOperand) повертає добуток двох чисел;
  • BigInteger divide(BigInteger secondOperand) повертає частку двох чисел;
  • BigInteger negate() повертає значення з протилежним знаком (множення на –1).
  • BigInteger remainder(BigInteger val) повертає залишок від ділення (додатний або від'ємний)
  • BigInteger mod(BigInteger secondOperand) повертає абсолютне значення залишку від ділення двох чисел.

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

BigInteger ten = BigInteger.TEN;
BigInteger two = BigInteger.TWO;
BigInteger eleven = ten.add(BigInteger.ONE);
System.out.println(eleven);                    // 11
BigInteger minusEleven = eleven.negate();
System.out.println(minusEleven);               // -11
System.out.println(ten.add(two));              // 12
System.out.println(ten.subtract(two));         // 8
System.out.println(ten.multiply(two));         // 20
System.out.println(eleven.divide(two));        // 5
System.out.println(minusEleven.mod(two));      // 1
System.out.println(minusEleven.remainder(two));// -1

Методи max() і min() дозволяють порівнювати поточний об'єкт з параметром. Ці методи також повертають BigInteger:

System.out.println(ten.max(two)); // 10
System.out.println(ten.min(two)); // 2

Можна також викликати математичні методи:

  • BigInteger abs() повертає модуль числа;
  • BigInteger pow(int n) повертає число у степені n; n не може бути від'ємним числом;
  • BigInteger gcd(BigInteger number) повертає найбільший спільний дільник абсолютних значень поточного об'єкта й числа number.

Наприклад:

System.out.println(minusEleven.abs()); // 11
System.out.println(two.pow(10));       // 1024
System.out.println(two.gcd(ten));      // 2

Існує низка функцій для реалізації побітових операцій.

  • BigInteger and(BigInteger number) повертає результат операції AND;
  • BigInteger or(BigInteger number) повертає результат операції OR;
  • BigInteger not() повертає результат операції NOT (інверсія бітів);
  • BigInteger xor(BigInteger number) повертає результат операції XOR (виключне або);
  • BigInteger shiftLeft(int n) повертає операцію зсуву бітів ліворуч на n позицій;
  • BigInteger shiftRight(int n) повертає операцію зсуву бітів праворуч на n позицій.

Є методи, які є суперпозицією двох операцій, наприклад, andNot(). Наведений нижче приклад демонструє роботу побітових операцій:

BigInteger first = BigInteger.valueOf(23);
System.out.println(first + " " + first.toString(2)); // 23 10111
BigInteger second = BigInteger.valueOf(12);
System.out.println(second + " " + second.toString(2)); // 12 1100
BigInteger n;
n = first.and(second);
System.out.println(n + " " + n.toString(2)); // 4 100
n = first.or(second);
System.out.println(n + " " + n.toString(2)); // 31 11111
n = first.not();
System.out.println(n + " " + n.toString(2)); // -24 -11000
n = first.xor(second);
System.out.println(n + " " + n.toString(2)); // 27 11011
n = first.shiftLeft(2);
System.out.println(n + " " + n.toString(2)); // 92 1011100
n = first.shiftRight(1);
System.out.println(n + " " + n.toString(2)); // 11 1011

Є низка методів для роботи з окремими бітами:

  • boolean testBit(int n) повертає true якщо визначений біт встановлено в одиницю;
  • BigInteger setBit(int n) повертає об'єкт BigInteger в якому відповідний біт встановлено в одиницю;
  • BigInteger clearBit(int n) повертає об'єкт BigInteger в якому відповідний біт встановлено в нуль;
  • BigInteger flipBit(int n) повертає об'єкт BigInteger в якому відповідний біт встановлено в протилежне значення.

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

BigInteger n = BigInteger.valueOf(48);
System.out.println(n + " " + n.toString(2)); // 48 110000
n = n.setBit(0);
System.out.println(n + " " + n.toString(2)); // 49 110001
n = n.setBit(2);
System.out.println(n + " " + n.toString(2)); // 53 110101
n = n.clearBit(5);
System.out.println(n + " " + n.toString(2)); // 21 10101
n = n.flipBit(1);
System.out.println(n + " " + n.toString(2)); // 23 10111

Є також цікаві методи для роботи з простими числами. Йдеться про числа, які є ймовірно простими. У більшості варіантів практичного застосування простих чисел, наприклад, у криптографії, достатньо вважати, що деяке велике число ймовірно просте і його можна використовувати для шифрування. Для отримання ймовірно простих чисел реалізовано алгоритм Міллера-Рабіна. У деяких методах класу BigInteger можна регулювати ймовірність того, що отримане випадкове число є простим, визначаючи параметр certainty. Ймовірність того, що число буде простим, оцінюється як 1 - 1/2certainty і вище. Слід пам'ятати, що підвищення ймовірності пов'язане з істотним збільшенням часу та витрачанням інших ресурсів. Якщо параметр не визначається, він усталено дорівнює 100.

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

  • BigInteger(int bitLength, int certainty, Random rnd) створює об'єкт, який містить ймовірно просте число з довжиною представлення bitLength бітів; для генерації буде застосовано параметр надійності certainty та об'єкт генератор випадкових чисел rnd;
  • static BigInteger probablePrime(int bitLength, Random rnd) створює та повертає об'єкт, який містить ймовірно просте число з довжиною представлення bitLength бітів; для генерації буде застосовано об'єкт генератор випадкових чисел rnd.

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

  • boolean isProbablePrime(int certainty) повертає true, якщо число, ймовірно, є простим, і false, якщо воно точно не є простим;
  • BigInteger nextProbablePrime() повертає перше ймовірно є просте число, більше за поточний об'єкт.

У наведеному нижче прикладі відшукуються послідовні ймовірно прості числа від 50 до 100:

BigInteger n = BigInteger.valueOf(50);
while (n.intValue() <= 100) {
    n = n.nextProbablePrime();
    if (n.intValue() > 100) {
        break;
    }
    System.out.printf("%d ", n);
}

Для перетворення в примітивні цілі типи запропоновані додаткові методи intValueExact(), byteValueExact(), shortValueExact() і longValueExact(). Особливість цих методів полягає в тому, що здійснюється перевірка відповідності значення діапазону відповідного примітивного типу. Якщо немає можливості точно перетворити тип, методи генерують виняток ArithmeticException.

2.2.2 Використання BigDecimal

Головна відмінність типу BigDecimal від double полягає у використанні десяткової системи замість двійкової для представлення чисел з рухомою комою. Традиційне представлення double у формі mantissa × 2 exponent не дозволяє точно представити простіші десяткові дроби, такі як 0.3, 0.6, 0.7 тощо.

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

Загалом до переваг можна віднести такі:

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

До недоліків можна віднести такі:

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

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

Внутрішнє представлення BigDecimal складається з "немасштабованого" цілого числа довільної точності (unscaledValue) та 32-розрядного цілого значення scale (шкала, масштаб). Якщо шкала дорівнює нулю або додатна, шкалою є кількість цифр праворуч від десяткової крапки. Якщо шкала від'ємна, немасштабоване значення числа множиться на десять у ступені -scale. Отже, значення числа, представленого дорівнює unscaledValue × 10 -scale.

Методи scale() і setScale() класу BigDecimal дозволяють відповідно отримати й встановити значення шкали. Метод unscaledValue() дозволяє отримати "немасштабоване значення".

Існує декілька способів створення об'єкта типу BigDecimal. Можна створити об'єкт з цілого числа і числа типу double:

BigDecimal fromInt = new BigDecimal(1295);
BigDecimal fromDouble = new BigDecimal(1.27);

Для забезпечення більшої точності об'єкт слід створювати з рядка, а не з числа типу double:

BigDecimal fromDouble = new BigDecimal(1.27);
System.out.println(fromDouble); 
    // 1.270000000000000017763568394002504646778106689453125
BigDecimal fromString = new BigDecimal("1.27");
System.out.println(fromString); // 1.27

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

З класом BigDecimal пов'язаний клас java.math.MathContext, який інкапсулює правила здійснення арифметичних операцій, зокрема в конструкторах визначаються точність і правила округлення. Значення 0 передбачає необмежену довжину числа, додатні цілі числа – кількість цифр представлення:

  • MathContext(int setPrecision)
  • MathContext(int setPrecision, RoundingMode setRoundingMode)

У переліку java.math.RoundingMode перелічені константи для визначення правила округлення:

  • UP: від нуля;
  • CEILING: в бік збільшення;
  • DOWN: до нуля;
  • FLOOR: в бік збільшення;
  • HALF_DOWN: якщо "сусіди" на однаковій відстані, в бік від нуля;
  • HALF_EVEN: якщо "сусіди" на однаковій відстані, до парного значення;
  • HALF_UP: якщо "сусіди" на однаковій відстані, в бік нуля;
  • UNNECESSARY: округлення не може здійснюватися; якщо необхідне округлення, генерується виняток.

Усталений варіант округлення – HALF_UP.

Об'єкт класу MathContext можна застосувати, зокрема, як параметр методу round(). Наведений нижче приклад демонструє різні варіанти округлення для додатних і від'ємних чисел:

import static java.math.RoundingMode.*;
...
BigDecimal positive1 = new BigDecimal("2.4");
System.out.println(positive1.round(new MathContext(1, UP)));        // 3
System.out.println(positive1.round(new MathContext(1, CEILING)));   // 3
System.out.println(positive1.round(new MathContext(1, DOWN)));      // 2
System.out.println(positive1.round(new MathContext(1, FLOOR)));     // 2
System.out.println(positive1.round(new MathContext(1, HALF_DOWN))); // 2
System.out.println(positive1.round(new MathContext(1, HALF_UP)));   // 2
BigDecimal positive2 = new BigDecimal("2.5");
System.out.println(positive2.round(new MathContext(1, UP)));        // 3
System.out.println(positive2.round(new MathContext(1, CEILING)));   // 3
System.out.println(positive2.round(new MathContext(1, DOWN)));      // 2
System.out.println(positive2.round(new MathContext(1, FLOOR)));     // 2
System.out.println(positive2.round(new MathContext(1, HALF_DOWN))); // 2
System.out.println(positive2.round(new MathContext(1, HALF_UP)));   // 3
BigDecimal negative1 = new BigDecimal("-2.4");
System.out.println(negative1.round(new MathContext(1, UP)));        // -3
System.out.println(negative1.round(new MathContext(1, CEILING)));   // -2
System.out.println(negative1.round(new MathContext(1, DOWN)));      // -2
System.out.println(negative1.round(new MathContext(1, FLOOR)));     // -3
System.out.println(negative1.round(new MathContext(1, HALF_DOWN))); // -2
System.out.println(negative1.round(new MathContext(1, HALF_UP)));   // -2
BigDecimal negative2 = new BigDecimal("-2.5");
System.out.println(negative2.round(new MathContext(1, UP)));        // -3
System.out.println(negative2.round(new MathContext(1, CEILING)));   // -2
System.out.println(negative2.round(new MathContext(1, DOWN)));      // -2
System.out.println(negative2.round(new MathContext(1, FLOOR)));     // -3
System.out.println(negative2.round(new MathContext(1, HALF_DOWN))); // -2
System.out.println(negative2.round(new MathContext(1, HALF_UP)));   // -3

Для визначення об'єкта типу MathContext можна використовувати константи DECIMAL32, DECIMAL64, DECIMAL128 і UNLIMITED. з відповідною точністю та правилом округлення HALF_UP.

Існує декілька конструкторів класу BigDecimal, які використовують MathContext, наприклад,

  • BigDecimal(double val, MathContext mc)
  • BigDecimal(int val, MathContext mc)
  • BigDecimal(String val, MathContext mc)

Для створення об'єкта можна використовувати BigInteger:

  • BigDecimal(BigInteger val)
  • BigDecimal(BigInteger unscaledVal, int scale)
  • BigDecimal(BigInteger unscaledVal, int scale, MathContext mc)
  • BigDecimal(BigInteger val, MathContext mc)

Як і BigInteger, клас BigDecimal надає константи BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TWO і BigDecimal.TEN.

Використання математичних операцій над аналогічне BigInteger. Додатково реалізовані методи, останнім параметром яких можна вказувати MathContext. Існує проблема з діленням. У тих випадках, коли результатом є нескінчений дріб, ділення без обмеження довжини результату призводить до генерації винятку java.lang.ArithmeticException. Наприклад, такий виняток ми отримаємо намагаючись обчислити 1/3:

BigDecimal three = new BigDecimal("3");
System.out.println(BigDecimal.ONE.divide(three)); // виняток

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

BigDecimal three = new BigDecimal("3");
System.out.println(BigDecimal.ONE.divide(three, MathContext.DECIMAL128));
        // 0.3333333333333333333333333333333333

Можна також застосувати отримання цілої частини й залишку окремо:

  • BigDecimal divideToIntegralValue(BigDecimal divisor)
  • BigDecimal divideToIntegralValue(BigDecimal divisor, MathContext mc)
  • BigDecimal remainder(BigDecimal divisor)
  • BigDecimal remainder(BigDecimal divisor, MathContext mc)

Метод divideAndRemainder(BigDecimal divisor) повертає масив з двох об'єктів типу BigDecimal. Два елементи цього масиву – відповідно ціла частина і залишок.

У класі BigDecimal реалізовані також математичні функції, наприклад:

  • BigDecimal sqrt(MathContext mc) отримання квадратного кореня;
  • BigDecimal pow(int n) отримання цілого степеня;
  • BigDecimal pow(int n, MathContext mc) отримання цілого степеня;
  • BigDecimal abs() отримання абсолютної величини;
  • BigDecimal abs(MathContext mc) отримання абсолютної величини.

Існують також функції для знаходження максимуму і мінімуму з двох чисел BigDecimal, а також низка допоміжних функцій.

2.3 Задачі зберігання й обробки наборів даних

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

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

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

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

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

Крім того, таке розділення коду відповідає принципу єдиної відповідальності класу (Single responsibility principle) – найважливішого принципу об'єктно-орієнтованого проєктування.

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

Відокремлення задач зберігання й обробки інформації реалізовано б бібліотеках різних мов і платформ програмування. Наприклад, до стандарту C++ включена стандартна бібліотека шаблонів (STL), яка надає окремо структури даних для зберігання колекцій об'єктів, такі як vector, list, set тощо, й окремі шаблонні функції для роботи з довільними послідовностями, включаючи масиви. Це узагальнені функції for_each(), find_if(), sort() тощо.

Свій підхід до реалізації алгоритмів був наданий у Java 2 Collection Framework.

2.4 Використання Java Collection Framework для обробки даних

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

Java Collection Framework (JCF) – це набір взаємозв'язаних інтерфейсів, класів і алгоритмів, призначених для збереження та обробки колекцій об'єктів.

Інтерфейси й класи JFC були визначені в Java 2. Починаючи з Java 5 всі типи реалізовані як узагальнені (generic). Істотні розширення з'явилися у JDK 1.8. Різні типи додавалися й розширювалися в подальших версіях.

Спочатку доцільно розглянути засоби, які були реалізовані до Java 8. Нижче наведені найбільш важливі стандартні узагальнені інтерфейси JCF зі стандартними класами, які їх реалізують. Окрім наведених нижче класів, майже для кожного інтерфейсу існує створено абстрактний клас, який реалізує, наприклад, AbstractCollection, а також похідні від нього класи AbstractList, AbstractQueue, AbstractSet тощо. Ці класи є базовими для відповідних стандартних реалізацій, а також можуть бути застосовані для створення користувацьких колекцій.

  • Iterable – базовий інтерфейс, реалізований у пакеті java.lang. Цей інтерфейс оголошує абстрактний метод iterator(), який повертає об'єкт-ітератор типу Iterator. Об'єкти класів, які реалізують цей інтерфейс, є колекціями, можуть бути використані в альтернативній конструкції циклу for (for each). Крім того, інтерфейс Iterable надає низку методів з усталеною реалізацією.
  • Iterator – інтерфейс, реалізація методів якого забезпечує послідовне проходження елементів колекції. Починаючи з Java 5, ітератори дозволяють здійснювати обхід елементів за допомогою циклу "for each". Усі колекції JFC надають свої ітератори для обходу елементів.
  • ListIterator інтерфейс, похідний від Iterator, який дозволяє здійснювати ітерацію у двох напрямках, а також змінювати поточні елементи. Застосовується в списках.
  • Collection – інтерфейс, похідний від Iterable. Він є базовим для всіх колекцій крім асоціативних масивів (Map). Клас AbstractCollection безпосередньо реалізує інтерфейс Collection. Цей клас застосовано для створення низки абстрактних класів, які представляють різні типи колекцій.
  • List – інтерфейс, похідний від Collection; він визначає структуру даних, в якій доступ до елементів може здійснюватися за допомогою індексів; списки підтримують дублювання та можуть зберігати елементи в порядку їх додавання; найбільш популярні стандартні класи, які реалізують цей інтерфейс – ArrayList (список, побудований на масиві) і LinkedList (зв'язаний список).
  • Queue – інтерфейс, похідний від Collection; колекція, яка використовується для зберігання елементів в порядку доступу "першим прийшов – першим вийшов" (FIFO). Найпопулярніший клас, який реалізує цей інтерфейс – LinkedList.
  • Deque – інтерфейс, похідний від Queue. Він розширює функціональність черги, дозволяючи вставляти та видаляти елементи як з початку, так і з кінця колекції (Double-ended queue). Класи, які реалізують цей інтерфейс – ArrayDeque і LinkedList.
  • Set – інтерфейс, похідний від Collection, що представляє колекцію, яка не допускає дублювання елементів. Вона гарантує, що кожен елемент зберігається в колекції тільки один раз. Найбільш розповсюджена реалізація – HashSet.
  • SortedSet – інтерфейс, похідний від Set. Передбачає, що елементи розташовані відповідно до певної ознаки сортування. Найбільш розповсюджена реалізація – TreeSet.
  • Map – інтерфейс, який представляє окрему гілку. Цей інтерфейс описує колекцію пар ключ-значення. Кожен ключ унікальний, і він використовується для доступу до відповідного значення. Найбільш розповсюджена реалізація – HashMap.
  • SortedMap – інтерфейс, похідний від Map. Передбачає, що ключі розташовані відповідно до певної ознаки сортування. Найбільш розповсюджена реалізація – TreeMap.

Перелічені інтерфейси (крім Iterable) та стандартні класи, які їх реалізують, розташовані в пакеті java.util і вимагають використання відповідних директив імпорту.

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

Інтерфейс Iterator<E> оголошує такі методи:

  • boolean hasNext() повертає true якщо в колекції ще є елементи.
  • E next() Returns the next element in the iteration. Після першого виклику посилається на початковий елемент колекції.
  • void remove() видаляє з колекції елемент, на який посилається ітератор.

Інтерфейс ListIterator<E> додає такі методи:

  • int nextIndex() повертає індекс елемента, який буде повернуто наступним викликом next().
  • boolean hasPrevious() повертає true, якщо цей ітератор списку має більше елементів під час обходу списку у зворотному напрямку.
  • E previous() повертає попередній елемент у списку та переміщує позицію курсора у зворотному напрямку.
  • int previousIndex() повертає індекс елемента, який буде повернуто наступним викликом previous().
  • void add(E e) вставляє вказаний елемент у список.
  • void set(E e) замінює останній елемент, отриманий через next() або previous(), на вказаний елемент.

Методи ітераторів будуть розглянуті в контексті їхнього використання в колекціях.

В інтерфейсі Collection<T> оголошені найбільш загальні операції, які реалізовані в усіх контейнерних класах (крім асоціативних масивів), Можна виділити методи, які не змінюють колекцію:

  • int size() повертає розмір колекції;
  • boolean isEmpty() повертає true, якщо колекція порожня;
  • boolean contains(Object o) повертає true, якщо колекція містить об'єкт;
  • boolean containsAll(Collection<?> c) повертає true якщо колекція містить іншу колекцію;
  • Iterator<E> iterator() повертає ітератор – об'єкт, який послідовно вказує на елементи;
  • Object[] toArray() повертає масив посилань на Object, який містить копії всіх елементів колекції;
  • T[] toArray(T[] a) повертає масив посилань на T, який містить копії всіх елементів колекції.

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

Collection<Integer> c = Arrays.asList(1, 2, 4, 8);
System.out.println(c.size());      // 4
System.out.println(c.isEmpty());   // false
System.out.println(c.contains(4)); // true
System.out.println(c.contains(6)); // false
System.out.println(c.containsAll(Arrays.asList(1, 4))); // true
// Виведення елементів в окремих рядках:
for (var iterator = c.iterator(); iterator.hasNext(); ) {
    System.out.println(iterator.next());
}
// Виведення елементів за допомогою неявного ітератора:
for (Integer k : c) {
    System.out.println(k);
}
// Параметр необхідний для створення масиву певного типу:
Integer[] arr = c.toArray(new Integer[0]); 
System.out.println(Arrays.toString(arr));  // [1, 2, 4, 8]

Методи інтерфейсу Collection<E>, які змінюють колекцію:

  • boolean add(E e) додає об'єкт у колекцію. Повертає true, якщо об'єкт доданий
  • boolean remove(Object o) видаляє об'єкт з колекції
  • boolean addAll(Collection<? extends E> c) додає об'єкти в колекцію. Повертає true, якщо об'єкти додані.
  • boolean removeAll(Collection<?> c) видаляє об'єкти з колекції, якщо вони присутні в іншій колекції.
  • boolean retainAll(Collection<?> c) залишає об'єкти, присутні в іншій колекції.
  • void clear() видаляє всі елементи з колекції

Для тестування вказаних методів можна скористатися класом ArrayList:

Collection<Integer> modifiable = new ArrayList<>(Arrays.asList(16, 32, 64));
System.out.println(modifiable); // [16, 32, 64]
modifiable.add(128);
System.out.println(modifiable); // [16, 32, 64, 128]
modifiable.remove(16);
System.out.println(modifiable); // [32, 64, 128]
modifiable.addAll(unmodifiable);
System.out.println(modifiable); // [32, 64, 128, 1, 2, 4, 8]
modifiable.removeAll(unmodifiable);
System.out.println(modifiable); // [32, 64, 128]
modifiable.addAll(unmodifiable);
System.out.println(modifiable); // [32, 64, 128, 1, 2, 4, 8]
modifiable.retainAll(unmodifiable);
System.out.println(modifiable); // [1, 2, 4, 8]
modifiable.clear();
System.out.println(modifiable); // []

Методи, додані в інтерфейсі List<E>:

  • E get(int index) повертає елемент зі вказаним індексом.
  • E set(int index, E element) замінює елемент зі вказаним індексом на вказаний об'єкт. Повертає попереднє значення, яке зберігалося за вказаним індексом.
  • void add(int index, E element)вставляє вказаний елемент у вказану позицію в списку.
  • boolean addAll(int index, Collection<? extends E> c) вставляє елементи зі вказаної колекції в указану позицію в списку.
  • E remove(int index) видаляє елемент у вказаній позиції.
  • int indexOf(Object o) повертає індекс першого входження об'єкта, або -1, якщо об'єкт відсутній.
  • int lastIndexOf(Object o) повертає індекс останнього входження об'єкта, або -1, якщо об'єкт відсутній.
  • List<E> subList(int fromIndex, int toIndex) повертає частину списку починаючи з fromIndex (включаючи) до toIndex (не включаючи). Пам'ять для нового списку не виділяється.
  • ListIterator<E> listIterator() повертає ітератор списку. Після першого виклику next() ітератор вказуватиме на початковий елемент списку.
  • ListIterator<E> listIterator(int index) повертає ітератор списку. Індекс вказує на елемент, який буде повернуто першим викликом next(). Перший виклик previous() повертає елемент із вказаним індексом мінус один.

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

List<Integer> list = new ArrayList<>();
list.add(0, 10);
System.out.println(list);                // [10]
list.addAll(0, Arrays.asList(1, 2, 3, 4, 5));
System.out.println(list);                // [1, 2, 3, 4, 5, 10]
System.out.println(list.get(4));         // 5
list.remove(2);
System.out.println(list);                // [1, 2, 4, 5, 10]
list.set(4, 1);
System.out.println(list);                // [1, 2, 4, 5, 1]
System.out.println(list.indexOf(1));     // 0
System.out.println(list.lastIndexOf(1)); // 4
System.out.println(list.subList(2, 4));  // [4, 5]
// Виводить пари індекс / значення в зворотному порядку:
for (var iterator = list.listIterator(list.size()); iterator.hasPrevious(); ) {
    System.out.printf("%d %d%n", iterator.previousIndex(), iterator.previous());
}
// Додає до елементів індекси. Додає проміжні елементи:
for (var iterator = list.listIterator(); iterator.hasNext(); ) {
    iterator.set(iterator.nextIndex() + iterator.next());
    iterator.add(100);
}
System.out.println(list); // [1, 100, 4, 100, 8, 100, 11, 100, 9, 100]

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

Більш детально робота зі списками була розглянута в лабораторній роботі № 4 попереднього семестру.

В інтерфейсі Queue<E> оголошені такі методи:

  • boolean add(E e) вставляє елементу у чергу, генеруючи виняток IllegalStateException, якщо черга переповнена.
  • boolean offer(E e) вставляє елемент у чергу і повертає true. Якщо черга переповнена, не генерує винятку, а повертає false.
  • E remove() отримує та видаляє елемент черги. Генерує виняток, якщо черга порожня.
  • E poll() отримує та видаляє елемент черги або повертає значення null, якщо черга порожня. Не генерує винятків.
  • E element() отримує, але не видаляє, черговий елемент. Генерує виняток, якщо черга порожня.
  • E peek() отримує, але не видаляє, черговий елемент або повертає значення null, якщо черга порожня. Не генерує винятків.

Функції add(E e), remove() і element() використовують у тих випадках, коли спроба отримати елемент з порожньої черги, або переповнити чергу є малоймовірними та не є частиною нормального процесу.

Для реалізації Queue найчастіше застосовують клас LinkedList.

В інтерфейсі Deque<E>, похідному від Queue<E> додатково оголошені такі методи:

  • void addFirst(E e) вставляє елемент на початку черги, генеруючи виняток IllegalStateException, якщо черга переповнена.
  • void addLast(E e) вставляє елемент у кінець черги, генеруючи виняток IllegalStateException, якщо черга переповнена.
  • boolean offerFirst(E e) вставляє елемент на початку черги і повертає true. Якщо черга переповнена, не генерує винятку, а повертає false.
  • boolean offerLast(E e) вставляє елемент у кінець черги і повертає true. якщо черга переповнена, не генерує винятку, а повертає false.
  • E removeFirst() отримує та видаляє перший елемент черги. Генерує виняток, якщо черга порожня.
  • E removeLast() отримує та видаляє останній елемент черги. Генерує виняток, якщо черга порожня.
  • E pollFirst() отримує та видаляє перший елемент черги або повертає значення null, якщо черга порожня. Не генерує винятків.
  • E pollLast() отримує та видаляє останній елемент черги або повертає значення null, якщо черга порожня. Не генерує винятків.
  • E getFirst() отримує, але не видаляє, перший елемент черги. Генерує виняток, якщо черга порожня.
  • E getLast() отримує, але не видаляє, останній елемент черги. Генерує виняток, якщо черга порожня.
  • E peekFirst() отримує, але не видаляє, перший елемент черги або повертає значення null, якщо черга порожня. Не генерує винятків.
  • E peekLast() отримує, але не видаляє, останній елемент черги або повертає значення null, якщо черга порожня. Не генерує винятків.

В лабораторній роботі № 4 попереднього семестру була розглянута робота з чергами.

Робота з множинами в JCF (інтерфейс Set) побудована на використанні методів, оголошених в інтерфейсі Collection.

Інтерфейс Map<K,V> не є похідним від Collection. Цей інтерфейс описую колекцію пар ключ / значення, при чому ключі повинні бути різними об'єктами. Об'єкти класів, які реалізують інтерфейс Map, мають назву асоціативних масивів. Іноді також вживають терміни "карта" та "Відображення".

Методи, визначені в інтерфейсі Map (до версії Java 7 включно):

  • V put(K key, V value) додає пару або змінює значення, якщо ключ існує. Повертає попереднє значення або null, якщо ключ був відсутній.
  • void putAll(Map<? extends K,? extends V> m) копіює пари ключ-значення з іншого асоціативного масиву.
  • int size() повертає кількість пар в асоціативному масиві.
  • V get(Object key) повертає значення за ключем або null якщо ключ відсутній.
  • V remove(Object key) видаляє пару за вказаним ключем
  • Set<K> keySet() повертає множину ключів асоціативного масиву.
  • Collection<V> values() повертає колекцію значень, які містяться в асоціативному масиві.
  • boolean containsKey(Object key) повертає true, якщо асоціативний масив містить вказаний ключ.
  • boolean containsValue(Object value) повертає true, якщо асоціативний масив містить вказане значення.
  • Set<Map.Entry<K,V>> entrySet() повертає множину об'єктів, які представляють пари.
  • boolean isEmpty() повертає true, якщо асоціативний масив порожній.
  • void clear() видаляє всі пари з асоціативного масиву.

Більш детально робота з множинами й асоціативними масивами була розглянута в лабораторній роботі № 4 попереднього семестру.

Клас Collections надає низку статичних методів. Це функції для створення різних спеціальних колекцій і так звані алгоритми – узагальнені статичні методи для роботи з колекціями. Більш детально статичні методи класу Collections були розглянуті в лабораторній роботі № 4 попереднього семестру.

2.4.2 Додаткові можливості роботи з колекціями в Java 8

До Java 7 включно для роботи з колекціями застосовувався переважно традиційний імперативний підхід, побудований на явному використанні циклів, умовних тверджень, перемикачів тощо. Стандартні алгоритми, які надавали класи Arrays і Collections, лише частково забезпечували потребу в обробці даних.

Починаючи з Java 8, стандартні інтерфейси пакету java.util доповнені методами, орієнтованими на використання лямбда-виразів і посилань на методи. Для забезпечення сумісності з попередніми версіями Java нові методи інтерфейсів представлені з усталеною реалізацією. Зокрема, інтерфейс Iterable визначає метод forEach(), який дозволяє виконати в циклі деякі дії, що не змінюють елементів колекції. Дію можна задати лямбда-виразом або посиланням на метод. Наприклад:

public class ForEachDemo {
    static int sum = 0;
    
    public static void main(String[] args) {
        Iterable<Integer> numbers = new ArrayList(Arrays.asList(2, 3, 4));
        numbers.forEach(n -> sum += n);
        System.out.println(sum);
    }
}

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

Інтерфейс Collection визначає метод removeIf(), який дозволяє видалити з колекції дані, відповідні деякому правилу-фільтру. У наведеному нижче прикладі з колекції цілих чисел видаляються непарні елементи. Метод forEach() використовується для виведення елементів колекції в стовпчик:

Collection<Integer> c = new ArrayList(Arrays.asList(2, 4, 11, 8, 12, 3));
c.removeIf(k -> k % 2 != 0);
// Решта елементів виводиться в стовпчик:
c.forEach(System.out::println);

Інтерфейс List надає методи replaceAll() і sort(). Останній можна використовувати замість аналогічного статичного методу класу Collections, проте визначення ознаки сортування є обов'язковим:

List<Integer> list = new ArrayList(Arrays.asList(2, 4, 11, 8, 12, 3));
list.replaceAll(k -> k * k); // замінюємо числа їхніми квадратами
System.out.println(list);    // [4, 16, 121, 64, 144, 9]
list.sort(Integer::compare);
System.out.println(list);    // [4, 9, 16, 64, 121, 144]
list.sort((i1, i2) -> Integer.compare(i2, i1));
System.out.println(list);    // [144, 121, 64, 16, 9, 4]

У Java 8 визначено інтерфейс Spliterator, який можна застосовувати до колекцій. Цей інтерфейс визначає спеціальний вид ітератора – ітератор-роздільник. Він, зокрема, дозволяє розділити послідовність на декілька, з якими можна працювати паралельно. Отримати екземпляр Spliterator можна за допомогою метода spliterator(), визначеного в інтерфейсі Collection.

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

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

Метод forEachRemaining() забезпечує ітерацію для Spliterator. Метод оголошено так:

void forEachRemaining(Consumer<? super Double> action)

Наприклад:

List<Integer> list = List.of(1, 2, 3, 4, 5, 6, 7, 8);
Spliterator<Integer> spliterator1 = list.spliterator();
Spliterator<Integer> spliterator2 = spliterator1.trySplit();
spliterator1.forEachRemaining(System.out::println);
System.out.println("========");
spliterator2.forEachRemaining(System.out::println);

Результат роботи цього фрагменту програми буде таким:

5
6
7
8
========
1
2
3
4

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

Існує також метод tryAdvance(), який фактично об'єднує функції hasNext() та next(), які оголошені в інтерфейсі Iterator. Метод tryAdvance() оголошено так:

boolean tryAdvance(Consumer<? super T> action);

Якщо елемент, що залишається, існує, він виконує з ним задану дію, повертаючи true; інакше повертає false. Іншими словами, він виконує дію над наступним елементом у послідовності, а потім просуває ітератор. Здійснити ітерацію для виведення елементів можна також за допомогою tryAdvance():

while(spliterator2.tryAdvance(System.out::println));

Починаючи з Java 8, до інтерфейсу Map додані методи, наведені в таблиці:

Метод Опис
V getOrDefault(Object key, V& defaultValue) Повертає значення, або усталене значення, якщо ключ відсутній
V putIfAbsent(K key, V value) Додає пару, якщо ключ відсутній, і повертає значення
boolean remove(Object key, Object value) Видаляє пару, якщо вона присутня
boolean replace(K key, V oldValue, V newValue) Замінює значення на нове, якщо пара присутня
V replace(K key, V value) Замінює значення; якщо ключ є, повертає старе значення
V compute(K key, BiFunction<?& super K, super V, ? extends V> remappingFunction) Викликає функцію для побудови нового значення. Вводиться нова пара, видаляється пара, яка існувала раніше, і повертається нове значення
V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) Якщо присутній вказаний ключ, для створення нового значення викликається задана функція і нове значення замінює колишнє.
V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) Повертає значення за ключем. Якщо ключ відсутній, додається нова пара, значення обчислюється за функцією
V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) Якщо ключ відсутній, то вводиться нова пара і повертається значення v. В іншому випадку задана функція повертає нове значення, виходячи з колишнього значення і ключ оновлюється для доступу до цього значення, а потім воно повертається
void forEach(BiConsumer<? super K, ? super V> action) Виконує задану дію (action) над кожним елементом

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

package ua.inf.iwanoff.java.advanced.first;

import java.util.HashMap;
import java.util.Map;

public class MapDemo {

    static void print(Integer i, String s) {
        System.out.printf("%3d %10s %n", i, s);
    }

    public static void main(String[] args) {
        Map<Integer, String> map = new HashMap<>();
        map.put(1, "one");
        map.put(2, "two");
        map.put(7, "seven");
        map.forEach(MapDemo::print); // порядкове виведення
        System.out.println(map.putIfAbsent(7, "eight")); // seven
        System.out.println(map.putIfAbsent(8, "eight")); // null
        System.out.println(map.getOrDefault(2, "zero")); // two
        System.out.println(map.getOrDefault(3, "zero")); // zero
        map.replaceAll((i, s) -> i > 1 ? s.toUpperCase() : s);
        System.out.println(map); // {1=one, 2=TWO, 7=SEVEN, 8=EIGHT}
        map.compute(7, (i, s) -> s.toLowerCase());
        System.out.println(map); // {1=one, 2=TWO, 7=seven, 8=EIGHT}
        map.computeIfAbsent(2, (i) -> i + "");
        System.out.println(map); // нічого не змінилося
        map.computeIfAbsent(4, (i) -> i + "");
        System.out.println(map); // {1=one, 2=TWO, 4=4, 7=seven, 8=EIGHT}
        map.computeIfPresent(5, (i, s) -> s.toLowerCase());
        System.out.println(map); // нічого не змінилося
        map.computeIfPresent(2, (i, s) -> s.toLowerCase());
        System.out.println(map); // {1=one, 2=two, 4=4, 7=seven, 8=EIGHT}
        // Уводиться нова пара:
        map.merge(9, "nine", (value, newValue) -> value.concat(newValue));
        System.out.println(map.get(9));                  // nine
        // Текст зшивається з попереднім:
        map.merge(9, " as well", (value, newValue) -> value.concat(newValue));
        System.out.println(map.get(9));                  // nine as well
    }
}

2.4.3 Додаткові можливості JCF у подальших версіях JDK

Розглянемо деякі новації, які виникли після Java 8.

У версії 9 до інтерфейсів, які представляють колекції, додані методи of(), які забезпечують зручне створення колекцій, наприклад:

List<String> list = List.of("one", "two", "three");
Set<String> set = Set.of("one", "two", "three");
Map<String, String> map = Map.of("first", "one", "second", "two");

У версії Java 10 додані методи створення незмінних колекцій.

У версії 21 до колекцій додані зручні функції addFirst(), addLast(), getFirst(), getLast(), removeFirst() і removeLast(), а також функція reversed(). До інтерфейсу SortedMap додані методи putFirst(), putLast() і reversed().

2.5 Використання Stream API

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

Потоки для роботи з колекціями, або потоки елементів, потоки даних (Stream API) призначені для високорівневої обробки даних, що зберігаються в контейнерах. Їх не слід плутати з потоками введення-виведення (input / output streams) і потоками управління (threads).

Засоби Stream API були додані до стандарту починаючи з Java 8.

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

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

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

Для роботи з потоками реалізований пакет java.util.stream, що надає набір інтерфейсів і класів, що забезпечують операції над потоками елементів в стилі функціонального програмування. Потік представлений об'єктом, що реалізує інтерфейс java.util.stream.Stream. Своєю чергою, цей інтерфейс успадковує методи узагальненого інтерфейсу java.util.stream.BaseStream.

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

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

Спочатку необхідно створити клас City:

public class City {
    private String name;
    private int population;

    public City(String name, int population) {
        this.name = name;
        this.population = population;
    }

    public String getName() {
        return name;
    }

    public int getPopulation() {
        return population;
    }

    @Override
    public String toString() {
        return String.format("%-9s %d", name, population);
    }

    @Override
    public boolean equals(Object o) {
        return toString().equals(o.toString());
    }

    @Override
    public int hashCode() {
        return name.hashCode();
    }
}

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

Set<City> citiesSet = new HashSet<>();
citiesSet.add(new City("Kyiv", 2_967_360));
citiesSet.add(new City("Kharkiv", 1_443_207));
citiesSet.add(new City("Odesa", 1_017_699));
citiesSet.add(new City("Donetsk", 908_456));
citiesSet.add(new City("Odesa", 1_017_699));
List<City> cities = new ArrayList<>(citiesSet);
for (int i = 0; i < cities.size(); i++) {
    if (cities.get(i).getPopulation() < 1_000_000) {
        cities.remove(i);
    }
}
Collections.sort(cities, new Comparator<City>() {
    public int compare(City a, City b) {
        return Integer.compare(a.getPopulation(), b.getPopulation());
    }
});
for (City c : cities) {
    System.out.println(c.getName());
}

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

Лямбда-вирази, посилання на методи та нові методи інтерфейсів, які утворюють Java Collection Framework, дозволили спростити реалізацію коду.

Set<City> citiesSet = new HashSet<>(List.of(
        new City("Kyiv", 2_967_360),
        new City("Kharkiv", 1_443_207),
        new City("Odesa", 1_017_699),
        new City("Donetsk", 908_456),
        new City("Odesa", 1_017_699)));
List<City> cities = new ArrayList<>(citiesSet);
cities.removeIf(city -> city.getPopulation() < 1_000_000);
cities.sort(Comparator.comparing(City::getPopulation));
cities.forEach(city -> System.out.println(city.getName()));

Викликом методу removeIf() з лямбда-виразом як параметром, видаляємо непотрібні дані. Далі здійснюється сортування списку за вказаним критерієм (виклик метода sort()). Умова сортування визначається посиланням на метод. Виведення імен міст в окремих рядках здійснюється функцією forEach().

За допомогою потоків усе можна виконати в одному твердженні:

Stream.of(
        new City("Kyiv", 2_967_360),
        new City("Kharkiv", 1_443_207),
        new City("Odesa", 1_017_699),
        new City("Donetsk", 908_456),
        new City("Odesa", 1_017_699))
        .filter(city -> city.getPopulation() > 1_000_000)
        .distinct()
        .sorted(Comparator.comparing(City::getPopulation))
        .map(City::getName)
        .forEach(System.out::println);

В цьому прикладі створюється потік за допомогою статичної функції of(), Масив елементів, з яким працюватиме потік, створюється на льоту зі списку фактичних параметрів. Далі потік фільтрується шляхом виклику функції filter(). Умова фільтрації визначається лямбда-виразом. і далі вибираються тільки різні дані (метод distinct()) і здійснюється сортування за вказаним критерієм (виклик метода sorted()). Умова сортування визначається посиланням на метод. За допомогою виклику функції map() створюється потік для роботи з іменами міст, після цього здійснюється виведення імен в окремих рядках методом forEach().

2.5.2 Основні методи для роботи з потоками

Найбільш істотні методи узагальненого інтерфейсу java.util.stream.BaseStream наведені в таблиці (S – тип потоку, E – тип елемента, R – тип контейнера):

Метод Опис Примітка
S parallel() повертає паралельний потік даних, отриманий з поточного проміжна операція
S sequential() повертає послідовний потік даних, отриманий з поточного проміжна операція
boolean isParallel() повертає true, якщо викликає потік даних є паралельним, або false, якщо він є послідовним проміжна операція
S unordered() повертає невпорядкований потік даних, отриманий з поточного проміжна операція
Iterator<T> iterator() повертає посилання на ітератор потоку даних кінцева операція
Spliterator<T> spliterator() повертає посилання на ітератор-роздільник потоку даних кінцева операція

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

Інтерфейс Stream розширює набір методів для роботи з потоками елементів. Це також узагальнений інтерфейс і він придатний для роботи з будь-якими посилальними типами. Нижче наведені найбільш вживані методи інтерфейсу Stream:

Метод Опис Примітка
void forEach(Consumer<? super T> action) виконує код, заданий дією (action) для кожного елемента потоку кінцева операція
Stream<T> filter(Predicate<? super T> pred) повертає потік елементів, що задовольняють предикату проміжна операція
Stream<T> sorted() повертає потік елементів, розсортованих в природному порядку проміжна операція
Stream<T> sorted(Comparator<? super T> comparator) повертає потік елементів, розсортованих в зазначеному порядку проміжна операція

<R> Stream<R> map(Function<? super T,
? extends R> mapFunc)

застосовує задану функцію до елементів потоку і повертає новий потік проміжна операція
Optional<T> min(Comparator<? super T> comp) повертає мінімальне значення з використанням заданого порівняння кінцева операція
Optional<T> max(Comparator<? super T> comp) повертає максимальне значення з використанням заданого порівняння кінцева операція
long count() повертає кількість елементів в потоці кінцева операція
Stream<T> distinct() повертає потік різних елементів проміжна операція
Optional<T> reduce(BinaryOperator<T> accumulator) повертає скалярний результат, обчислений за значеннями елементів кінцева операція
Object[] toArray() створює і повертає масив елементів потоку кінцева операція

2.5.3 Створення потоків

Існує кілька способів створення потоку. Можна скористатися "фабричними" методами, що були додані до інтерфейсу Collection (з усталеними реалізаціями) – відповідно stream() (для послідовної роботи) и parallelStream() (для багатопотокової роботи). Так можна створити потік для послідовної роботи:

List<Integer> intList = List.of(3, 4, 1, 2);
Stream<Integer> fromList = intList.stream();

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

Integer[] a = { 1, 2, 3 };
Stream<Integer> fromArray = Arrays.stream(a);

Можна створити джерело даних із зазначеними елементами. Для цього слід скористатися "фабричним" методом of():

Stream<Integer> newStream = Stream.of(4, 5, 6);

Потоки елементів можна створити з потоків введення (BufferedReader.lines()), заповнити випадковими значеннями (Random.ints()), а також отримати з архівів, бітових наборів тощо.

З потоку можна отримати масив за допомогою методу toArray(). У наведеному нижче прикладі створюється потік, а потім виводиться на консоль через створення масиву і приведення до подання рядком за допомогою статичного методу Arrays.toString():

Stream<Integer> s = Stream.of(1, -2, 3);
Object[] a = s.toArray();
System.out.println(Arrays.toString(a)); // [1, -2, 3]

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

2.5.4 Ітерація за елементами

Потоки забезпечують ітерацію за елементами даних за допомогою методу forEach(). Параметр функції - стандартний функціональний інтерфейс Consumer, що визначає метод з одним параметром і типом результату void. Наприклад:

Stream<Integer> s = Stream.of(4, 5, 6, 1, 2, 3);
s.forEach(System.out::println);

Потоки елементів надають ітератори. Метод iterator() інтерфейсу Stream повертає об'єкт, який реалізує інтерфейс java.util.Iterator. Ітератор можна використовувати в явному вигляді:

s = Stream.of(11, -2, 3);
Iterator<Integer> it = s.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

До потоків можна також застосовувати Spliterator. Зокрема, статичні методи класу StreamSupport дозволяють створити потік з об'єкта Spliterator. Наприклад:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Spliterator<Integer> spliterator = numbers.spliterator();
  // true вказує, що можна використовувати паралельну обробку:
Stream<Integer> stream = StreamSupport.stream(spliterator, true);
stream.forEach(System.out::println);

Використання Spliterator у такому контексті забезпечує ефективність завдяки можливості паралельної обробки.

2.5.5 Операції з потоками

Найпростіша операція з потоками – фільтрація. Проміжна операція filter() повертає фільтрований потік, приймаючи параметр типу Predicate. Тип Predicate – це функціональний інтерфейс, що описує метод з одним параметром і типом результату boolean. Наприклад, можна відфільтрувати з потоку s тільки парні числа:

s.filter(k -> k % 2 == 0).forEach(System.out::println);

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

Проміжна операція sorted() повертає відсортоване представлення потоку. Елементи упорядковано в природному порядку (якщо він визначений). В інших випадках слід реалізовувати інтерфейс Comparator, наприклад, за допомогою лямбда-виразу:

// Сортуємо за зростанням:
Stream<Integer> s = Stream.of(4, 5, 6, 1, 2, 3);
s.sorted().forEach(System.out::println);
// Сортуємо в порядку зменшення:
s = Stream.of(4, 5, 6, 1, 2, 3);
s.sorted((k1, k2) -> Integer.compare(k2, k1)).forEach(System.out::println);

Останній приклад показує, що після кожного виклику кінцевої операції потік потрібно створювати знову.

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

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

s = Stream.of(1, 2, 3);
s.map(x -> x * x).forEach(System.out::println);

За допомогою методу distinct() з колекції можна отримати потік, що містить тільки різні елементи. Наприклад:

s = Stream.of(1, 1, -2, 3, 3);
System.out.println(Arrays.toString(s.distinct().toArray())); // [1, -2, 3]

Проміжні операції характеризуються так званою відкладеною поведінкою (lazy behaviour): вони виконуються не миттєво, а за потреби – коли кінцева операція працює з новим потоком даних. Відкладена поведінка підвищує ефективність роботи з потоками елементів.

Кінцева операція count() з типом результату long повертає кількість елементів в потоці:

s = Stream.of(4, 5, 6, 1, 2, 3);
System.out.println(s.count()); // 6

Кінцеві операції min() і max() повертають об'єкти Optional з відповідно мінімальним і максимальним значенням. Для порівняння використовується параметр типу Comparator. Наприклад:

s = Stream.of(11, -2, 3);
System.out.println(s.min(Integer::compare).get()); // -2

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

s = Stream.of(1, 1, -2, 3, 3);
Optional<Integer> sum = s.reduce((s1, s2) -> s1 + s2);
sum.ifPresent(System.out::println); // 6

Операції min(), max() і reduce() отримують скалярне значення з потоку, тому вони мають назву операцій зведення.

Іноді для виконання декількох кінцевих операцій необхідно відтворювати потік. Функційний інтерфейс Supplier з абстрактною функцією get() застосовують для створення однакових потоків за визначеним правилом. Правило можна описати лямбда-виразом. Наприклад:

Supplier<Stream<Integer>> supplier = () -> Stream.of(1, 2, 3);
supplier.get().forEach(System.out::println);
System.out.println(Arrays.toString(supplier.get().toArray()));

Метод concat() забезпечує об'єднання потоків:

Stream<Integer> stream1 = Stream.of(1, 2, 3);
Stream<Integer> stream2 = Stream.of(8, 9);
Stream<Integer> result = Stream.concat(stream1, stream2);
result.forEach(System.out::print); // 12389

Метод skip() створює новий потік даних, в якому пропущені перші n елементів. Приклад:

Stream<Integer> stream1 = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> stream2 = stream1.skip(2); // 3 4 5

Є також операції пошуку певного об'єкта findFirst() і findAny(), які повертають об'єкт типу Optional. Для перевірки наявності об'єктів, які задовольняють певні умови застосовують методи anyMatch(), allMatch() і noneMatch():

Supplier<Stream<Integer>> supplier = () -> Stream.of(1, 2, 3, -5);
System.out.println(supplier.get().allMatch(i -> i > 0));   // false
System.out.println(supplier.get().anyMatch(i -> i > 0));   // true
System.out.println(supplier.get().noneMatch(i -> i > 10)); // true

2.5.6 Використання класу Collectors

Для отримання колекцій з потоків визначено спеціальний узагальнений інтерфейс java.util.stream.Collector.

Кінцева операція collect() класу Stream дозволяє отримати традиційну колекцію з потоку. Тип колекції залежить від параметра. Параметр – екземпляр типу Collector. Клас Collectors надає статичні методи для отримання об'єктів типу Collector, такі як toCollection(), toList(), toSet() і toMap().

Наприклад:

Supplier<Stream<Integer>> supplier = () -> Stream.of(1, 2, 3);
List<Integer> list = supplier.get().collect(Collectors.toList());
System.out.println("list: " + list); // list: [1, 2, 3]
Set<Integer> set = supplier.get().collect(Collectors.toSet());
System.out.println("set: " + set); // set: [1, 2, 3]
Map<Integer, Double> map = supplier.get().collect(
Collectors.toMap(Integer::intValue, Integer::doubleValue));
System.out.println("map :" + map); // map :{1=1.0, 2=2.0, 3=3.0}

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

Існують також потоки для роботи з примітивними типами – IntStream, LongStream і DoubleStream. Розглянемо роботу з IntStream і DoubleStream.

Найпростіший спосіб створення потоків – використання статичної функції of():

IntStream intStream = IntStream.of(1, 2, 4, 8);
DoubleStream doubleStream = DoubleStream.of(1, 1.5, 2);

Можна створити потоки з відповідних масивів:

int[] intArr = { 10, 11, 12 };
double[] doubleArr = { 10, 10.5, 11, 11.5, 12 };
intStream = Arrays.stream(intArr);
doubleStream = Arrays.stream(doubleArr);

За допомогою метода range() класу IntStream можна створити потоки заповнивши їх послідовними значеннями. Можна також одночасно визначити фільтр:

intStream = IntStream.range(0, 10).filter(n -> n % 2 == 0); // 0 2 4 6 8

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

intStream = IntStream.iterate(1, i -> i * 3).limit(6); // 1 3 9 27 81 243

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

doubleStream = DoubleStream.generate(() -> (Math.random() * 10000)).limit(20);

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

intStream = IntStream.of(11, 2, 43, 81, 8, 0, 5, 3); 
intStream.sorted().filter(n -> n % 2 != 0).forEach(System.out::println);

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

int[] newIntArr = intStream.toArray();
double[] newDoubleArr = doubleStream.toArray();

Примітка: передбачається, що потоки intStream і doubleStream не були використані в кінцевих операціях.

2.6 Тестування в Java. Використання JUnit

2.6.1 Основні концепції тестування

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

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

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

2.6.2 Засоби Java для діагностики помилок під час виконання

Багато сучасних мов програмування, зокрема Java, включають синтаксичні механізми перевірки тверджень (assertions). Ключове слово assert з'явилося в Java починаючи з версії JDK 1.4 (Java 2). Роботу assert можна вмикати або вимикати. Якщо виконання діагностичних тверджень увімкнено, робота assert полягає у такому: виконується вираз типу boolean і якщо результат дорівнює true, робота програми продовжується далі, в протилежному випадку виникає виняток java.lang.AssertionError. Припустимо, відповідно до логіки програми змінна c повинна завжди бути додатною. Виконання такого фрагмента програми не призведе до будь-яких наслідків (винятків, аварійної зупинки програми тощо):

int a = 10;
int b = 1;
int c = a - b;
assert c > 0;

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

int a = 10;
int b = 11;
int c = a - b;
assert c > 0; // генерація винятку

Після твердження можна поставити двокрапку, після якої вказати деякий рядок повідомлення. Наприклад:

int a = 10;
int b = 11;
int c = a - b;
assert c > 0 : "c cannot be negative";

В цьому випадку відповідний рядок є рядком повідомлення винятку.

Робота діагностичних тверджень зазвичай вимкнена в інтегрованих середовищах. Для того, щоб увімкнути виконання assert в середовищі IntelliJ IDEA слід скористатися функцією меню Run | Edit Configurations. У вікні Run/Debug Configurations уводимо -ea в рядку введення VM Options.

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

public static void main(String[] args) {
    //...
    assert f() : "failed";
    //...
}

public static boolean f() {
    // Very important calculations
    return true;
}

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

2.6.3 Основи використання JUnit

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

Найбільш розповсюдженим засобом підтримки модульного тестування для програмного забезпечення мовою Java є JUnit – відкрита бібліотека модульного тестування. JUnit дозволяє:

  • створювати тести для окремих класів;
  • створювати набори тестів;
  • створювати серії тестів над наборами об'єктів, що повторюються.

Зараз актуальною є версія JUnit 5. Але також вельми розповсюдженою є версія JUnit 4.

Для створення тесту необхідно створити клас, який необхідно тестувати, а також створити відкритий клас для тестування з набором методів, що реалізують конкретні тести. Кожен тестовий метод повинен бути public, void, без параметрів. Метод повинен бути маркованим анотацією @Test:

public class MyTestCase { 
    ...
    @Test
    public void testXXX() { 
    ...
    } 
    ...
}

Примітка: для використання анотації @Test та інших подібних анотацій слід додати твердження імпорту import org.junit.jupiter.api.*; (для JUnit 5) або import org.junit.*; (для JUnit 4).

Усередині таких методів можна використовувати методи перевірки:

assertTrue(вираз);                // Якщо false - завершує тест невдачею
assertFalse(вираз);               // Якщо true - завершує тест невдачею
assertEquals(expected, actual);       // Якщо не еквівалентні - завершує тест невдачею
assertNotNull(new MyObject(params));  // Якщо null - завершує тест невдачею
assertNull(new MyObject(params));     // Якщо не null - завершує тест невдачею
assertNotSame(вираз1, вираз2);// Якщо обидва посилання посилаються на один об'єкт - завершує тест невдачею
assertSame(вираз1, вираз2);   // Якщо об'єкти різні - завершує тест невдачею  
fail(повідомлення)            // Негайно завершує тест невдачею з виведенням повідомлення.

Тут MyObject – клас, який тестується. Доступ до цих методів класу Assertion (Assert для JUnit 4) здійснюється за допомогою статичного імпорту import static org.junit.jupiter.api.Assertion.*; (для JUnit 5) або import static org.junit.Assert.*; (для JUnit 4). Ці методи існують також з додатковим параметром message типу String, який задає повідомлення, що буде відображатися в разі невдалого виконання тесту.

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

package ua.inf.iwanoff.java.advanced.first;

public class MathFuncs {
    public static int sum(int a, int b) {
        return a + b;
    }

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

У проєкті можна вручну створити теку, наприклад, tests. Далі за допомогою контекстного меню встановити Mark Directory as | Test Sources Root.

Повертаючись до класу MathFuncs, обравши його в редакторі коду, через контекстне меню можна здійснити генерацію тестів: Generate... | Test.... У діалоговому вікні, яке відкрилось, вибираємо версію бібліотеки JUnit. Бажаний варіант – JUnit5. Можемо також скорегувати ім'я класу, яке нам пропонують: MathFuncsTest. У більшості випадків корекція цього імені не є доцільною. Обов'язково треба вибрати імена методів, які підлягають тестуванню. В нашому випадку це sum() і mult(). Отримаємо такий код:

package ua.inf.iwanoff.java.advanced.first;

import static org.junit.jupiter.api.Assertions.*;

class MathFuncsTest {

    @org.junit.jupiter.api.Test
    void sum() {
    }

    @org.junit.jupiter.api.Test
    void mult() {
    }
}

У цьому коді IntelliJ IDEA вказує на наявність помилок (Cannot resolve symbol 'junit'). Натиснувши Alt+Enter, вибираємо More actions..., отримуємо підказку: Add 'JUnit 5.8.1' to classpath. Скориставшись цією підказкою, далі завантажуємо бібліотеку з репозиторію і отримуємо код без помилок.

Можна оптимізувати код, додавши імпорт. До коду методів класу MathFuncsTest додаємо перевірку методів класу MathFuncs. Для перевірки роботи mult() необхідно створити об'єкт:

package ua.inf.iwanoff.java.advanced.first;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class MathFuncsTest {

    @Test
    void sum() {
        assertEquals(MathFuncs.sum(4, 5), 9);
    }

    @Test
    void mult() {
        assertEquals(new MathFuncs().mult(3, 4), 12);
    }
}

Запустити тести на виконання можна через меню Run. Нормальне завершення процесу свідчить про відсутність помилок під час перевірки. Якщо, скажімо, додати код, який спотворює обчислення у класі MathFuncs, наприклад:

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

запуск тестів на виконання призведе до отримання повідомлення AssertionFailedError. При цьому вказується, скільки тестів пройдено успішно, а скільки не пройдено.

Якщо перед виконанням функції тестування необхідно зробити деякі дії, наприклад, форматувати значення змінних, то така ініціалізація виноситься в окремий статичний метод, якому передує анотація @BeforeAll(@BeforeClass у JUnit 4):

@BeforeAll
public static void setup(){
      ...
}

Аналогічно методам, в яких виконуються дії, необхідні після тестування, передує анотація @AfterAll (@AfterClass у JUnit 4). Відповідні методи повинні бути public static void.

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

package ua.inf.iwanoff.java.advanced.first;

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class MathFuncsTest {
    private static MathFuncs funcs;

    @BeforeAll
    public static void init() {
        funcs = new MathFuncs();
    }

    @Test
    void sum() {
        assertEquals(MathFuncs.sum(4, 5), 9);
    }

    @Test
    void mult() {
        assertEquals(funcs.mult(3, 4), 12);
    }

    @AfterAll
    public static void done() {
        System.out.println("Tests finished");
    }
}

Анотація @BeforeEach (@Before у JUnit 4) вказує, що метод викликається перед кожним тестовим методом. Відповідно @AfterEach (@After у JUnit 4) вказує, що метод викликається після кожного успішного тестового методу. Методи, помічені цими анотаціями, не повинні бути статичними.

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

void setValue(into value) {
    this.value = value;
}

...

@Test
public void testSetValue() {
    someObject.setValue(123);
    assertEquals(123, someObject.getValue());
}

Однак, як правило, тестувати найпростіші методи доступу до властивостей і установки властивостей (сетери й гетери) видається надмірним і не рекомендується.

2.7 Системи контролю версій. Використання репозиторію сервісу 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, що містить відповідні налаштування. Робота з Git в IntelliJ IDEA не вимагає глобальної реєстрації користувача.

У середовищі 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, уточнюємо теку розміщення проєкту, далі підтверджуємо його відкриття.

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

3.1 Пошук факторіалів

Традиційна математична задача обчислення факторіала для великих цілих чисел викликає труднощі, пов'язані з обмеженнями на розміри результату. Для типу int максимальне певне значення – 12!, для long20! Для значень 171! і більше не можна отримати навіть наближене значення, використовуючи double. Використання BigInteger і BigDecimal дозволяють отримати факторіали великих чисел. Розміри результату фактично обмежені можливостями відображення в консольному вікні.

Наведена нижче програма здійснює обчислення факторіала цілих чисел, використовуючи різні типи: long, double, BigInteger і BigDecimal.

package ua.inf.iwanoff.java.advanced.first;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Scanner;

public class Factorial {
    private static long factorialLong(int n) {
        long result = 1;
        for (int i = 1; i <= n; i++) {
            result *= i;
        }
        return result;
    }

    public static double factorialDouble(int n) {
        double result = 1;
        for (int i = 1; i <= n; i++) {
            result *= i;
        }
        return result;
    }

    public static BigInteger factorialBigInteger(int n) {
        BigInteger result = BigInteger.ONE;
        BigInteger current = BigInteger.ZERO;
        for (int i = 1; i <= n; i++) {
            result = result.multiply(current = current.add(BigInteger.ONE));
        }
        return result;
    }

    public static BigDecimal factorialBigDecimal(int n) {
        BigDecimal result = BigDecimal.ONE;
        BigDecimal current = BigDecimal.ZERO;
        for (int i = 1; i <= n; i++) {
            result = result.multiply(current = current.add(BigDecimal.ONE));
        }
        return result;
    }

    public static void main(String[] args) {
        int n = new Scanner(System.in).nextInt();
        System.out.println(factorialLong(n));
        System.out.println(factorialDouble(n));
        System.out.println(factorialBigInteger(n));
        System.out.println(factorialBigDecimal(n));
    }

}

Робота програми для великих значень n вимагає багато часу.

Для перевірки певності результатів не достатньо ручного тестування. Для тестування наведених функцій можна скористатися можливостями бібліотеки JUnit. Заздалегідь треба створити окрему теку test і помітити її як корінь тестів (функція Mark Directory as | Test Sources Root контекстного меню). Далі можна згенерувати тести: функція Code | Generate... | Test... головного меню. У діалоговому вікні вибираємо, які функції, для яких слід створити тести. В нашому випадку це всі функції крім main(). Буде згенеровано такий код:

package ua.inf.iwanoff.java.advanced.first;

import static org.junit.jupiter.api.Assertions.*;

class FactorialTest {

    @org.junit.jupiter.api.Test
    void factorialBigInteger() {
    }

    @org.junit.jupiter.api.Test
    void factorialBigDecimal() {
    }

    @org.junit.jupiter.api.Test
    void factorialDouble() {
    }
}

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

Для тестування функції factorialDouble() необхідно додати допоміжну функцію compareDoubles(), а також створити константи для деяких значень факторіалів. Код файлу FactorialTest.java буде таким:

package ua.inf.iwanoff.java.advanced.first;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

class FactorialTest {
    public static final String FACTORIAL_5 = "120";
    public static final String FACTORIAL_50 =
            "30414093201713378043612608166064768844377641568960512000000000000";
    public static final String FACTORIAL_500 =
            "12201368259911100687012387854230469262535743428031928421924135883858453731538819" +
            "97605496447502203281863013616477148203584163378722078177200480785205159329285477" +
            "90757193933060377296085908627042917454788242491272634430567017327076946106280231" +
            "04526442188787894657547771498634943677810376442740338273653974713864778784954384" +
            "89595537537990423241061271326984327745715546309977202781014561081188373709531016" +
            "35632443298702956389662891165897476957208792692887128178007026517450776841071962" +
            "43903943225364226052349458501299185715012487069615681416253590566934238130088562" +
            "49246891564126775654481886506593847951775360894005745238940335798476363944905313" +
            "06232374906644504882466507594673586207463792518420045936969298102226397195259719" +
            "09452178233317569345815085523328207628200234026269078983424517120062077146409794" +
            "56116127629145951237229913340169552363850942885592018727433795173014586357570828" +
            "35578015873543276888868012039988238470215146760544540766353598417443048012893831" +
            "38968816394874696588175045069263653381750554781286400000000000000000000000000000" +
            "00000000000000000000000000000000000000000000000000000000000000000000000000000000" +
            "000000000000000";

    public static final BigDecimal EPS = BigDecimal.ONE;

    private boolean compareDoubles(double d, String s) {
        return new BigDecimal(d).subtract(new BigDecimal(s)).abs().compareTo(EPS) <= 0;
    }

    @org.junit.jupiter.api.Test
    void factorialLong() {
        assertEquals(Factorial.factorialLong(5) + "", FACTORIAL_5);
        assertEquals(Factorial.factorialLong(50) + "", FACTORIAL_50);
        assertEquals(Factorial.factorialLong(500) + "", FACTORIAL_500);
    }

    @org.junit.jupiter.api.Test
    void factorialDouble() {
        assertTrue(compareDoubles(Factorial.factorialDouble(5), FACTORIAL_5));
        assertTrue(compareDoubles(Factorial.factorialDouble(50), FACTORIAL_50));
        assertTrue(compareDoubles(Factorial.factorialDouble(500), FACTORIAL_500));
    }

    @org.junit.jupiter.api.Test
    void factorialBigInteger() {
        assertEquals(Factorial.factorialBigInteger(5) + "", FACTORIAL_5);
        assertEquals(Factorial.factorialBigInteger(50) + "", FACTORIAL_50);
        assertEquals(Factorial.factorialBigInteger(500) + "", FACTORIAL_500);
    }

    @org.junit.jupiter.api.Test
    void factorialBigDecimal() {
        assertEquals(Factorial.factorialBigDecimal(5) + "", FACTORIAL_5);
        assertEquals(Factorial.factorialBigDecimal(50) + "", FACTORIAL_50);
        assertEquals(Factorial.factorialBigDecimal(500) + "", FACTORIAL_500);
    }

}

Значення 50! і 500! були отримані зі сторінки https://zeptomath.com/calculators/factorial.php та застосовується як еталонне в наших тестах.

Як і очікувалося, коректні значення 50! і 500! можна отримати лише за допомогою BigInteger і BigDecimal.

3.2 Отримання таблиці простих чисел за допомогою потоків даних

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

package ua.inf.iwanoff.java.advanced.first;

import java.util.stream.IntStream;

public class PrimeFinder {
    private static boolean isPrime(int n) {
        return n > 1 && IntStream.range(2, n - 1).noneMatch(k -> n % k == 0);
    }

    public static void printAllPrimes(int from, int to) {
        IntStream primes = IntStream.range(from, to + 1).filter(PrimeFinder::isPrime);
        primes.forEach(System.out::println);
    }

    public static void main(String[] args) {
        printAllPrimes(6, 199);
    }
}

Метод isPrime() перевіряє, чи є число n простим. Для цього для чисел понад 1, формується набір послідовних цілих чисел, для кожного з яких перевіряється, чи ділиться n на це число. У методі printAllPrimes() формуємо потік простих цілих чисел з використанням фільтра і виводимо числа за допомогою методу forEach().

3.3 Класи "Країна" та "Перепис населення"

У прикладах курсу "Основи програмування Java" було розглянуто ієрархію класів для представлення країни та переписів населення. У лабораторній роботі № 3 цього курсу було створено абстрактний клас AbstractCountry, а також конкретні класи Census і CountryWithArray. Далі, в лабораторній роботі № 4 було створено класи CountryWithList і CountryWithSet. І наостанок в лабораторній роботі № 5 було створено абстрактний клас CountryWithFile, а також конкретні класи CountryWithTextFile і CountryWithDataFile.

Тепер, використовуючи класи CountryWithList і Census, ми створимо застосунок, який відтворює пошук і сортування, реалізовані в прикладах вказаних лабораторних робіт, через застосування Stream API.

Створюємо новий пакет ua.inf.iwanoff.java.advanced.third. До пакета додаємо клас CensusWithStreams. В ньому доцільно створити конструктори й перевизначити метод containsWord(), реалізувавши його за допомогою потоків. Наприклад, код класу може бути таким:

package ua.inf.iwanoff.java.advanced.first;

import ua.inf.iwanoff.java.third.Census;
import java.util.Arrays;

/**
 * Клас відповідає за представлення перепису населення.
 * Для обробки послідовності слів застосовано засоби Stream API
 */
public class CensusWithStreams extends Census {
    /**
     * Конструктор ініціалізує об'єкт усталеними значеннями
     */
    public CensusWithStreams() {
    }

    /**
     * Конструктор ініціалізує об'єкт вказаними значеннями
     *
     * @param year рік перепису
     * @param population кількість населення
     * @param comments текст коментаря
     */
    public CensusWithStreams(int year, int population, String comments) {
        setYear(year);
        setPopulation(population);
        setComments(comments);
    }

    /**
     * Перевіряє, чи міститься слово в тексті коментаря
     *
     * @param word слово, яке ми шукаємо в коментарі
     * @return {@code true}, якщо слово міститься в тексті коментаря
     * {@code false} в протилежному випадку
     */
    @Override
    public boolean containsWord(String word) {
        return Arrays.stream(getComments().split("\s")).anyMatch(s -> s.equalsIgnoreCase(word));
    }
}

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

Заздалегідь створюємо теку test у корені проєкту й помічаємо її як корінь тестів (функція Mark Directory as | Test Sources Root контекстного меню). У вікні коду вибираємо ім'я класу й через контекстне меню Generate... | Test... вибираємо JUnit5 і функції, для яких слід згенерувати тестові методи. В нашому випадку – це метод containsWord(). IntelliJ IDEA автоматично генерує всі необхідні паралельні пакети гілки test і створює клас CensusWithStreamsTest. Від початку він такий:

package ua.inf.iwanoff.java.advanced.first;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class CensusWithStreamsTest {

    @Test
    void containsWord() {
    }
}

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

Тепер можна додати необхідне тестування, яке частково відтворює роботу методу testWord() класу Census. Код файлу CensusWithStreamsTest.java буде таким:

package ua.inf.iwanoff.java.advanced.first;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class CensusWithStreamsTest {

    @Test
    void containsWord() {
        CensusWithStreams census = new CensusWithStreams();
        census.setComments("Перший перепис у незалежній Україні");
        assertTrue(census.containsWord("Україні"));
        assertTrue(census.containsWord("ПЕРШИЙ"));
        assertFalse(census.containsWord("Країні"));
        assertFalse(census.containsWord("Україна"));
    }
}

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

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

  • похідний клас від CountryWithArray;
  • похідний клас від CountryWithList.

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

Обираючи другий варіант, ми повинні зберегти набір публічних методів без змін. Щоб забезпечити це, ми можемо додати захищені (protected) методи доступу до класу CountryWithList:

     protected List<Census> getList() {
         return list;
     }

     protected void setList(List<Census> list) {
         this.list = list;
     }

Окрім методів для роботи з послідовністю sortByPopulation(), sortByComments() і maxYear() слід перевизначити методи для доступу до списку, оскільки слід відстежувати, щоб у список потрапили лише посилання типу CensusWithStreams. Це методи setCensus(), addCensus() в двох варіантах і setCensuses(). Сирцевий код класу CountryWithStreams буде таким:

package ua.inf.iwanoff.java.advanced.first;

import ua.inf.iwanoff.java.third.Census;
import ua.inf.iwanoff.java.fourth.CountryWithList;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

/**
 * Клас для представлення країни, в якій здійснюється перепис населення.
 * Для обробки послідовностей застосовано засоби Stream API
 */
public class CountryWithStreams extends CountryWithList {
    /**
     * Встановлює посилання на новий перепис всередині позиції послідовностей
     * за вказаним індексом.
     *
     * @param i      номер (індекс) позиції в послідовності
     * @param census посилання на новий перепис
     */
    @Override
    public void setCensus(int i, Census census) {
        if (census instanceof CensusWithStreams) {
            super.setCensus(i, census);
        }
        else {
            throw new RuntimeException();
        }
    }

    /**
     * Додає посилання на новий перепис в кінець послідовності
     *
     * @param census посилання на новий перепис
     * @return {@code true}, якщо посилання вдалося додати
     * {@code false} в протилежному випадку
     */
    @Override
    public boolean addCensus(Census census) {
        if (census instanceof CensusWithStreams) {
            return super.addCensus(census);
        }
        return false;
    }

    /**
     * Створює новий перепис та додає посилання на нього в кінець послідовності.
     *
     * @param year       рік перепису
     * @param population кількість населення
     * @param comments   текст коментаря
     * @return {@code true}, якщо посилання вдалося додати
     * {@code false} в протилежному випадку
     */
    @Override
    public boolean addCensus(int year, int population, String comments) {
        return super.addCensus(new CensusWithStreams(year, population, comments));
    }

    /**
     * Переписує дані з масиву переписів у послідовність
     *
     * @param censuses довільний масив переписів
     */
    @Override
    public void setCensuses(Census[] censuses) {
        if (Arrays.stream(censuses).allMatch(c -> c instanceof CensusWithStreams)) {
            super.setCensuses(censuses);
        }
        else {
            throw new RuntimeException();
        }
    }

    /**
     * Здійснює сортування послідовності переписів за кількістю населення
     */
    @Override
    public void sortByPopulation() {
        setList(getList().stream().sorted().toList());
    }

    /**
     * Здійснює сортування послідовності переписів за алфавітом коментарів
     */
    @Override
    public void sortByComments() {
        setList(getList().stream().sorted(Comparator.comparing(Census::getComments)).toList());
    }

    /**
     * Знаходить і повертає рік з максимальним населенням
     *
     * @return рік з максимальним населенням
     */
    @Override
    public int maxYear() {
        return getList().stream().max(Comparator.comparing(Census::getPopulation)).get().getYear();
    }

    /**
     * Створює та повертає масив переписів зі вказаним словом в коментарях
     *
     * @param word слово, яке відшукується
     * @return масив переписів зі вказаним словом в коментарях
     */
    @Override
    public Census[] findWord(String word) {
        return getList().stream().filter(c -> c.containsWord(word)).toArray(Census[]::new);
    }

    /**
     * Створює й повертає список рядків, в який послідовно заносяться дані
     * про країну в цілому та про всі переписи населення
     *
     * @return список рядків з даними про країну
     */
    public List<String> toListOfStrings() {
        ArrayList<String> list = new ArrayList<>();
        list.add(getName() + " " + getArea());
        Arrays.stream(getCensuses()).forEach(c -> list.add(
                c.getYear() + " " + c.getPopulation() + " " + c.getComments()));
        return list;
    }

    /**
     * Читає зі списку рядків дані про країну та заносить їх у відповідні поля
     *
     * @param list список рядків з даними про країну
     */
    public void fromListOfStrings(List<String> list) {
        String[] words = list.get(0).split("\s");
        setName(words[0]);
        setArea(Double.parseDouble(words[1]));
        list.remove(0);
        list.stream().forEach(s -> { String[] line = s.split("\s");
                    addCensus(Integer.parseInt(line[0]), Integer.parseInt(line[1]),
                    s.substring(s.indexOf(line[2]))); });
    }

  /**
     * Створює та повертає об'єкт типу CountryWithStreams для тестування
     * @return об'єкт типу CountryWithStreams
     */
    public static CountryWithStreams createCountryWithStreams() {
        CountryWithStreams country = new CountryWithStreams();
        country.setName("Україна");
        country.setArea(603628);
        country.addCensus(1959, 41869000, "Перший післявоєнний перепис");
        country.addCensus(1970, 47126500, "Нас побільшало");
        country.addCensus(1979, 49754600, "Просто перепис");
        country.addCensus(1989, 51706700, "Останній радянський перепис");
        country.addCensus(2001, 48475100, "Перший перепис у незалежній Україні");
        return country;
    }
}

У наведеному коді застосовано виклик функції toArray(Census[]::new). Це забезпечує створення масиву необхідного типу (посилань на Census), а не масиву посилань на Object, яку повертає відповідна функція без параметрів.

Додаємо клас для тестування CountryWithStreams. Це здійснюється так, як і для класу CensusWithStreams. Доцільно вибрати методи sortByPopulation(), sortByComments(), maxYear() і findWord(). Отримаємо такий код:

package ua.inf.iwanoff.java.advanced.first;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class CountryWithStreamsTest {

    @Test
    void sortByPopulation() {
    }

    @Test
    void sortByComments() {
    }

    @Test
    void maxYear() {
    }

    @Test
    void findWord() {
    }
}

Оскільки необхідно виконати декілька тестів над об'єктом і ці тести повинні бути незалежними, об'єкт доцільно створювати перед виконанням кожного тестового методу. Для цього слід додати метод з анотацією @BeforeEach. Відповідний метод також можна згенерувати автоматично (в контекстному меню Generate... | SetUp Method). У методі setUp() створюємо новий об'єкт. Відповідне поле country типу CountryWithStreams створюємо вручну. Об'єкт буде використаний в тестових методах.

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

package ua.inf.iwanoff.java.advanced.first;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import ua.inf.iwanoff.java.second.Census;

import java.util.Arrays;

import static org.junit.jupiter.api.Assertions.*;

class CountryWithStreamsTest {
    private CountryWithStreams country;

    static int index;

    static int[] getYears(Census[] censuses) {
        int[] years = new int[censuses.length];
        index = 0;
        Arrays.stream(censuses).forEach(c -> years[index++] = c.getYear());
        return years;
    }

    @BeforeEach
    void setUp() {
        country = new CountryWithStreams();
        country.addCensus(1959, 41869000, "Перший післявоєнний перепис");
        country.addCensus(1970, 47126500, "Нас побільшало");
        country.addCensus(1979, 49754600, "Просто перепис");
        country.addCensus(1989, 51706700, "Останній радянський перепис");
        country.addCensus(2001, 48475100, "Перший перепис у незалежній Україні");
    }

    @Test
    void sortByPopulation() {
        country.sortByPopulation();
        assertArrayEquals(getYears(country.getCensuses()), new int[] { 1959, 1970, 2001, 1979, 1989 });
    }

    @Test
    void sortByComments() {
        country.sortByComments();
        assertArrayEquals(getYears(country.getCensuses()), new int[] { 1970, 1989, 2001, 1959, 1979 });
    }

    @Test
    void maxYear() {
        assertEquals(country.maxYear(), 1989);
    }

    @Test
    void findWord() {
        assertArrayEquals(getYears(country.findWord("перепис")), new int[] { 1959, 1979, 1989, 2001 });
    }
}

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

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

package ua.inf.iwanoff.java.advanced.first;

/**
 * Клас демонструє обробку даних з використанням StreamAPI
 */
public class Program {

    /**
     * Демонстрація роботи програми
     * @param args аргументи командного рядка (не використовуються)
     */
    public static void main(String[] args) {
        createCountryWithStreams().createCountry().testCountry();
    }

}

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

  1. Реалізувати функцію отримання цілої частини квадратного кореня з числа типу BigInteger.
  2. Проініціалізувати список дійсних чисел масивом зі списком початкових значень. Знайти суму додатних елементів.
  3. В масиві цілих значень замінити від'ємні значення модулями, додатні – нулями. Застосувати засоби Stream API.

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

  1. Назвіть основні компоненти платформи Java SE.
  2. Для чого використовуються класи BigInteger та BigDecimal?
  3. Як можна створити число типу BigInteger?
  4. Чим відрізняється внутрішнє представлення double та BigDecimal?
  5. Чи можна застосовувати математичні операції до чисел типів BigInteger та BigDecimal?
  6. Для чого застосовують клас MathContext?
  7. Що таке JCF? Які стандартні інтерфейси надає JCF?
  8. Як створити колекцію тільки для читання?
  9. Які алгоритми надає клас Collections?
  10. У чому переваги й особливості Stream API?
  11. Як отримати потік з колекції?
  12. Як отримати потік з масиву?
  13. Чим відрізняються проміжні й кінцеві операції?
  14. Які є потоки для роботи з примітивними типами?
  15. Які стандартні засоби перевірки тверджень в Java?
  16. Що таке модульне тестування?
  17. Що таке JUnit?
  18. Як здійснюється анотування методів тестування в JUnit?
  19. Як здійснити логічне групування тестів?
  20. Як скористатися JUnit у програмному середовищі?

 

up