Лабораторна робота 1
Робота з великими числами та наборами даних
1 Завдання на лабораторну роботу
1.1 Індивідуальне завдання
Спроєктувати та реалізувати класи для представлення сутностей третьої лабораторної роботи курсу "Основи програмування Java". Рішення повинне базуватися на раніше створених класах.
Програма повинна демонструвати:
- відтворення функціональності лабораторних робіт № 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) – це набір взаємозв'язаних інтерфейсів, класів і алгоритмів, призначених для збереження та обробки колекцій об'єктів.
Простіший набір контейнерних класів з'явився у першій версії Java. Це Vector
, Enumeration
, Stack
, BitSet
і
деякі інші. Наприклад, клас Vector
надає функціональність, аналогічну ArrayList
. У першій
версії Java ці контейнери не забезпечували стандартизованого інтерфейсу. Вони також не дозволяють користувачеві
відмовитися від надмірної синхронізації, яка актуальна лише в багатопотоковому оточенні, і отже, недостатньо ефективні.
Внаслідок цього вони вважаються застарілими та не рекомендовані для використання. Замість них слід використовувати
відповідні узагальнені контейнери Java 5.
Інтерфейси й класи 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()
Повертає наступний елемент ітерації. Після першого виклику посилається на початковий елемент колекції.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()
, на вказаний елемент.
Методи ітераторів будуть розглянуті в контексті їхнього використання в колекціях.
2.4.2 Інтерфейси Collection і List
Інтерфейс Collection<E>
є базовим для багатьох інтерфейсів Collection Framework. В цьому інтерфейсі
оголошені найбільш загальні операції, які реалізовані в усіх контейнерних класах (крім асоціативних масивів), Можна
виділити методи, які не змінюють колекцію:
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
(двобічно зв'язаний список).
2.4.3 Робота з чергами та стеками
Черга в широкому сенсі є структурою даних, яку заповнюють поелементно, та отримують з неї об'єкти за певним правилом. У вузькому сенсі цим правилом є "першим прийшов – першим вийшов" (FIFO, First In – First Out). У черзі, організованій за принципом FIFO, додавання елемента можливо лише в кінець черги, отримання – тільки з початку черги.
У бібліотеці контейнерів черга представлена інтерфейсом Queue
. Методи, оголошені в цьому інтерфейсі,
наведені в таблиці:
Тип операції | Генерує виняток | Повертає спеціальне значення |
---|---|---|
Додавання | add(e) |
offer(e) |
Видалення з отриманням елемента | remove() |
poll() |
Отримання елемента без видалення | element() |
peek() |
Метод offer()
повертає false
, якщо не вдалося додати елемент, наприклад, якщо
реалізована черга з обмеженою кількістю елементів. У цьому випадку метод add()
генерує виняток. Аналогічно remove()
і element()
генерують
виняток, якщо черга порожня, а poll()
і peek()
в цьому випадку повертають null
.
Для реалізації черги найзручніше використовувати клас LinkedList
, який реалізує інтерфейс Queue
.
Наприклад:
package ua.inf.iwanoff.java.advanced.first;import java.util.LinkedList;import java.util.Queue;public class SimpleQueueTest {public static void main(String[] args) { Queue<String> queue =new LinkedList<>(); queue.add("First"); queue.add("Second"); queue.add("Third"); queue.add("Fourth"); String s;while ((s = queue.poll()) !=null ) { System.out.print(s + " ");// First Second Third Fourth } } }
Клас PriorityQueue
впорядковує елементи відповідно до компаратора (об'єкта класу, що реалізує інтерфейс Comparator
),
заданого в конструкторі як параметр. Якщо об'єкт створити за допомогою конструктора без параметрів, елементи будуть
упорядковані в природному порядку (для чисел – за зростанням, для рядків – за абеткою). Наприклад:
package ua.inf.iwanoff.java.advanced.first;import java.util.PriorityQueue;import java.util.Queue;public class PriorityQueueTest {public static void main(String[] args) { Queue<String> queue =new PriorityQueue<>(); queue.add("First"); queue.add("Second"); queue.add("Third"); queue.add("Fourth"); String s;while ((s = queue.poll()) !=null ) { System.out.print(s + " ");// First Fourth Second Third } } }
Інтерфейс Deque
(дек, double-ended-queue) надає можливість додавати й видаляти елементи з обох кінців.
Методи, оголошені в цьому інтерфейсі, наведені в таблиці:
Тип операції | Робота з першим елементом | Робота з останнім елементом |
---|---|---|
Додавання | addFirst(e) |
addLast(e) |
Видалення з отриманням елемента | removeFirst() |
removeLast() |
Отримання елемента без видалення | getFirst() |
getLast() |
Кожна з пар представляє відповідно функцію, яка генерує виняток, і функцію, яка повертає спеціальне значення. Є
також методи, що дозволяють видалити перше або останнє входження заданого елемента (removeFirstOccurrence()
і removeLastOccurrence()
відповідно).
Для реалізації інтерфейсу можна використовувати спеціальний клас ArrayDeque
, або зв'язний список (LinkedList
).
Стек – це структура даних, організована за принципом "останній прийшов – перший вийшов" (LIFO, last in – first out). Можливі три операції зі стеком: додавання елементу (push), видалення елементу (pop) і читання головного елемента (peek).
У JRE 1.1 стек представлений класом Stack
. Наприклад:
package ua.inf.iwanoff.java.advanced.first;import java.util.Stack;public class StackTest {public static void main(String[] args) { Stack<String> stack =new Stack<>(); stack.push("First"); stack.push("Second"); stack.push("Third"); stack.push("Fourth"); String s;while (!stack.isEmpty()) { s = stack.pop(); System.out.print(s + " ");// Fourth Third Second First } } }
Цей клас зараз не рекомендований до використання. Замість нього можна використовувати інтерфейс Deque
,
який оголошує аналогічні методи. Наприклад:
package ua.inf.iwanoff.java.advanced.first;import java.util.ArrayDeque;import java.util.Deque;public class AnotherStackTest {public static void main(String[] args) { Deque<String> stack =new ArrayDeque<>(); stack.push("First"); stack.push("Second"); stack.push("Third"); stack.push("Fourth"); String s;while (!stack.isEmpty()) { s = stack.pop(); System.out.print(s + " ");// Fourth Third Second First } } }
Стеки часто використовуються в різних алгоритмах. Зокрема, за допомогою стеку в деяких задачах можна позбутися рекурсії.
2.4.4 Додаткові можливості роботи з колекціями в 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.5 Додаткові можливості 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; } @Overridepublic String toString() {return String.format("%-9s %d", name, population); } @Overridepublic boolean equals(Object o) {return toString().equals(o.toString()); } @Overridepublic 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) |
повертає потік елементів, розсортованих в зазначеному порядку | проміжна операція |
|
застосовує задану функцію до елементів потоку і повертає новий потік | проміжна операція |
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 має бути додатним";
В цьому випадку відповідний рядок є рядком повідомлення винятку.
Робота діагностичних тверджень зазвичай вимкнена в інтегрованих середовищах. Для того, щоб увімкнути виконання 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 {//... @Testpublic 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.Testvoid sum() { } @org.junit.jupiter.api.Testvoid 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 { @Testvoid sum() { assertEquals(MathFuncs.sum(4, 5), 9); } @Testvoid 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):
@BeforeAllpublic 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; @BeforeAllpublic static void init() { funcs =new MathFuncs(); } @Testvoid sum() { assertEquals(MathFuncs.sum(4, 5), 9); } @Testvoid mult() { assertEquals(funcs.mult(3, 4), 12); } @AfterAllpublic static void done() { System.out.println("Tests finished"); } }
Анотація @BeforeEach
(@Before
у JUnit 4) вказує, що метод викликається перед
кожним тестовим методом. Відповідно @AfterEach
(@After
у JUnit 4) вказує, що метод викликається
після кожного успішного тестового методу. Методи, помічені цими анотаціями, не повинні бути статичними.
Можна також тестувати методи, які повертають void
. Виклик такого методу передбачає
виконання якоїсь дії (наприклад, створення файлу, зміна значення поля тощо). Потрібно перевірити, чи мала така дія
місце. Наприклад:
void setValue(into value) {this .value = value; }//... @Testpublic void testSetValue() { someObject.setValue(123); assertEquals(123, someObject.getValue()); }
Однак, як правило, тестувати найпростіші методи доступу до властивостей і установки властивостей (сетери й гетери) видається надмірним і не рекомендується.
3 Приклади програм
3.1 Пошук факторіалів
Традиційна математична задача обчислення факторіала для великих цілих чисел викликає труднощі, пов'язані з обмеженнями
на розміри результату. Для типу int
максимальне певне значення – 12!
, для long
– 20!
Для значень 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.Testvoid factorialBigInteger() { } @org.junit.jupiter.api.Testvoid factorialBigDecimal() { } @org.junit.jupiter.api.Testvoid 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.Testvoid factorialLong() { assertEquals(Factorial.factorialLong(5) + "", FACTORIAL_5); assertEquals(Factorial.factorialLong(50) + "", FACTORIAL_50); assertEquals(Factorial.factorialLong(500) + "", FACTORIAL_500); } @org.junit.jupiter.api.Testvoid 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.Testvoid factorialBigInteger() { assertEquals(Factorial.factorialBigInteger(5) + "", FACTORIAL_5); assertEquals(Factorial.factorialBigInteger(50) + "", FACTORIAL_50); assertEquals(Factorial.factorialBigInteger(500) + "", FACTORIAL_500); } @org.junit.jupiter.api.Testvoid 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 Добуток чисел, що вводяться
У наведеному нижче прикладі вводяться цілі числа, виводяться за зменшенням, а також обчислюється їх добуток. Введення завершується нулем:
package ua.inf.iwanoff.java.advanced.first;import java.util.*;public class Product {public static void main(String[] args) { Queue<Integer> queue =new PriorityQueue<>(100,new Comparator<Integer>() { @Overridepublic int compare(Integer i1, Integer i2) {return -Double.compare(i1, i2); } }); Scanner scanner =new Scanner(System.in); Integer k;do { k = scanner.nextInt();if (k != 0) { queue.add(k); } }while (k != 0);int p = 1;while ((k = queue.poll()) !=null ) { p *= k; System.out.print(k + " "); } System.out.println(); System.out.println(p); } }
3.3 Отримання таблиці простих чисел за допомогою потоків даних
Наведена нижче програма дозволяє отримати таблицю простих чисел в заданому діапазоні. Для отримання простих чисел
доцільно використовувати потік 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.4 Класи "Країна" та "Перепис населення"
У прикладах курсу "Основи програмування Java" було розглянуто ієрархію класів для представлення країни
та переписів населення. У лабораторній роботі № 3 цього курсу
було створено абстрактний клас AbstractCountry
, а також конкретні класи Census
і CountryWithArray
.
Далі, в лабораторній роботі № 4 було створено класи CountryWithArrayList
і
CountryWithSet
.
Тепер, використовуючи класи CountryWithArrayList
і Census
, ми створимо застосунок, який відтворює
пошук і сортування, реалізовані в прикладах вказаних лабораторних робіт, через застосування Stream API.
У раніше створеному проєкті створюємо новий пакет ua.inf.iwanoff.java.advanced.first
. До пакета додаємо
клас CensusChecker
.
В ньому доцільно визначити власну реалізацію методу containsWord()
, реалізувавши його за допомогою
потоків. Наприклад, код класу може бути таким:
package ua.inf.iwanoff.java.advanced.first;import ua.inf.iwanoff.java.third.Census;import java.util.Arrays;/** * Надає статичний метод для пошуку слова в коментарі * Метод використовує засоби StreamAPI */ public class CensusChecker {/** * Перевіряє, чи міститься слово в тексті коментаря до перепису * @param census посилання на перепис * @param word слово, яке ми шукаємо в коментарі * @return {@code true}, якщо слово міститься в тексті коментаря * {@code false} в протилежному випадку */ public static boolean containsWord(Census census, String word) {return Arrays.stream(census.getComments().split("\s")).anyMatch(s -> s.equalsIgnoreCase(word)); } }
Можна було б також визначити функцію main()
для здійснення тестування, але кращий підхід – скористатися
можливостями модульного тестування (JUnit).
Заздалегідь створюємо теку test
у корені проєкту й помічаємо її як корінь тестів (функція Mark
Directory as | Test Sources Root контекстного меню). У вікні коду вибираємо ім'я класу й через контекстне
меню Generate...
| Test... вибираємо JUnit5 і функції, для яких слід згенерувати тестові методи. В нашому випадку – це
метод containsWord()
.
IntelliJ IDEA автоматично генерує всі необхідні паралельні пакети гілки test і створює клас CensusCheckerTest
.
Від початку він такий:
package ua.inf.iwanoff.java.advanced.first;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class CensusCheckerTest { @Testvoid containsWord() { } }
Якщо у згенерованому коді підсвічуються помилки, виправляємо їх, як це було описано вище.
Тепер можна додати необхідне тестування.
Код файлу CensusCheckerTest.java
буде таким:
package ua.inf.iwanoff.java.advanced.first;import ua.inf.iwanoff.java.third.Census;import static org.junit.jupiter.api.Assertions.*;import org.junit.jupiter.api.Test;class CensusCheckerTest { @Testvoid containsWord() { Census census =new Census(); census.setComments("Перший перепис у незалежній Україні"); assertTrue(CensusChecker.containsWord(census, "Україні")); assertTrue(CensusChecker.containsWord(census, "ПЕРШИЙ")); assertFalse(CensusChecker.containsWord(census, "Країні")); assertFalse(CensusChecker.containsWord(census, "Україна")); } }
Виконавши тести, ми отримаємо код успішного завершення. Якщо в коді змінити очікувані результати, тести згенерують виняток, а в коді буде підкреслено твердження, яке не виконалося.
Створюємо окремий клас для роботи зі списком переписів через засоби Stream API:
package ua.inf.iwanoff.java.advanced.first;import ua.inf.iwanoff.java.fourth.CountryWithArrayList;import ua.inf.iwanoff.java.third.Census;import static ua.inf.iwanoff.java.advanced.first.CensusChecker.containsWord;import java.util.Comparator;/** * Клас для обробки даних про країну, в якій здійснюється перепис населення. * Для обробки послідовностей застосовано засоби Stream API */ public class CountryProcessorWithStreams {/** * Повертає густоту населення для вказаного року * * @param country посилання на країну * @param year рік (наприклад, 1959, 1979, 1989 тощо) * @return густота населення для вказаного року */ public static double density(CountryWithArrayList country,int year) { Census census = country.getList().stream() .filter(c -> c.getYear() == year) .findFirst().orElse(null );return census ==null ? 0 : census.getPopulation() / country.getArea(); }/** * Знаходить і повертає рік з максимальним населенням * * @param country посилання на країну * @return рік з максимальним населенням */ public static int maxYear(CountryWithArrayList country) {return country.getList().stream() .max(Comparator.comparing(Census::getPopulation)) .get().getYear(); }/** * Створює та повертає масив переписів зі вказаним словом в коментарях * * @param country посилання на країну * @param word слово, яке відшукується * @return масив переписів зі вказаним словом в коментарях */ public static Census[] findWord(CountryWithArrayList country, String word) {return country.getList().stream() .filter(c -> containsWord(c, word)) .toArray(Census[]::new ); }/** * Здійснює сортування послідовності переписів за кількістю населення * * @param country посилання на країну */ public static void sortByPopulation(CountryWithArrayList country) { country.setList(country.getList().stream() .sorted() .toList()); }/** * Здійснює сортування послідовності переписів за алфавітом коментарів * * @param country посилання на країну */ public static void sortByComments(CountryWithArrayList country) { country.setList(country.getList().stream().sorted(Comparator.comparing(Census::getComments)).toList()); } }
У наведеному коді застосовано виклик функції toArray(Census[]::new)
. Це забезпечує
створення масиву необхідного типу (посилань на Census
), а не масиву посилань на Object
,
яку повертає відповідна функція без параметрів.
Додаємо клас для тестування методів класу CountryProcessorWithStreams
. Це здійснюється так, як і для
класу CensusChecker
.
Доцільно вибрати всі методи, а також setUp/@Defore.
Отримаємо такий код:
package ua.inf.iwanoff.java.advanced.first;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;class CountryProcessorWithStreamsTest { @BeforeEachvoid setUp() { } @Testvoid density() { } @Testvoid maxYear() { } @Testvoid findWord() { } @Testvoid sortByPopulation() { } @Testvoid sortByComments() { } }
Оскільки необхідно виконати декілька тестів над об'єктом і ці тести повинні бути незалежними, об'єкт доцільно створювати
перед виконанням кожного тестового методу. У методі setUp()
створюємо
новий об'єкт. Відповідне поле country
типу CountryWithArrayList
створюємо вручну. Об'єкт
буде використаний в тестових методах.
Для зручного тестування функцій, пов'язаних з пошуком та сортуванням, можна створити функцію getYears()
,
яка отримує масив років з масиву переписів. Ця статична функція використовуватиме статичну змінну index
для
заповнення конкретних елементів масиву. Змінна не може бути локальною, оскільки ми використовуємо її в лямбда-виразі.
Отримуємо такий код:
package ua.inf.iwanoff.java.advanced.first;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import static org.junit.jupiter.api.Assertions.*;import ua.inf.iwanoff.java.fourth.CountryWithArrayList;import ua.inf.iwanoff.java.third.Census;import java.util.Arrays;class CountryProcessorWithStreamsTest {private CountryWithArrayList 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; } @BeforeEachvoid setUp() { country =new CountryWithArrayList(); country.setArea(603628); country.addCensus(1959, 41869000, "Перший перепис після другої світової війни"); country.addCensus(1970, 47126500, "Нас побільшало"); country.addCensus(1979, 49754600, "Просто перепис"); country.addCensus(1989, 51706700, "Останній радянський перепис"); country.addCensus(2001, 48475100, "Перший перепис у незалежній Україні"); } @Testvoid density() { assertEquals(CountryProcessorWithStreams.density(country, 1979), 82.42593120266125); } @Testvoid maxYear() { assertEquals(CountryProcessorWithStreams.maxYear(country), 1989); } @Testvoid findWord() { assertArrayEquals(getYears(CountryProcessorWithStreams.findWord(country, "перепис")),new int [] { 1959, 1979, 1989, 2001 }); } @Testvoid sortByPopulation() { CountryProcessorWithStreams.sortByPopulation(country); assertArrayEquals(getYears(country.getCensuses()),new int [] { 1959, 1970, 2001, 1979, 1989 }); } @Testvoid sortByComments() { CountryProcessorWithStreams.sortByComments(country); assertArrayEquals(getYears(country.getCensuses()),new int [] { 1970, 1989, 2001, 1959, 1979 }); } }
Масиви років, які відповідають правильним результатам сортування й пошуку, були підготовлені вручну.
У
функції main()
класу CountryWithStreamsDemo
здійснюємо тестування програми:
package ua.inf.iwanoff.java.advanced.first;import ua.inf.iwanoff.java.fourth.CountryWithArrayList;import ua.inf.iwanoff.java.third.StringRepresentations;import ua.inf.iwanoff.java.third.Census;import static ua.inf.iwanoff.java.third.CountryDemo.setCountryData;/** * Програма тестування можливості роботи з країною через застосування Stream API */ public class CountryWithStreamsDemo {/** * Виводить на екран дані про переписи, які містять певне слово в коментарях * @param country посилання на країну * @param word слово, яке відшукується */ public static void printWord(CountryWithArrayList country, String word) { Census[] result = CountryProcessorWithStreams.findWord(country, word);if (result.length == 0) { System.out.println("Слово \"" + word + "\" не міститься в коментарях."); }else { System.out.println("Слово \"" + word + "\" міститься в коментарях:");for (Census census : result) { System.out.println(StringRepresentations.toString(census)); } } }/** * Здійснює тестування методів пошуку * @param country посилання на країну */ public static void testSearch(CountryWithArrayList country) { System.out.println("Щільність населення у 1979 році: " + CountryProcessorWithStreams.density(country, 1979)); System.out.println("Рік з найбільшим населенням: " + CountryProcessorWithStreams.maxYear(country) + "\n"); printWord(country, "перепис"); printWord(country, "запис"); }/** * Здійснює тестування методів сортування * @param country посилання на країну */ public static void testSorting(CountryWithArrayList country) { CountryProcessorWithStreams.sortByPopulation(country); System.out.println("\nСортування за кількістю населення:"); System.out.println(StringRepresentations.toString(country)); CountryProcessorWithStreams.sortByComments(country); System.out.println("\nСортування за алфавітом коментарів:"); System.out.println(StringRepresentations.toString(country)); }/** * Демонстрація роботи програми * @param args аргументи командного рядка (не використовуються) */ public static void main(String[] args) { CountryWithArrayList country = (CountryWithArrayList) setCountryData(new CountryWithArrayList()); testSearch(country); testSorting(country); } }
4 Вправи для контролю
- Реалізувати функцію отримання цілої частини квадратного кореня з числа типу
BigInteger
. - Проініціалізувати список дійсних чисел масивом зі списком початкових значень. Знайти суму додатних елементів.
- В масиві цілих значень замінити від'ємні значення модулями, додатні – нулями. Застосувати засоби Stream API.
5 Контрольні запитання
- Назвіть основні компоненти платформи Java SE.
- Для чого використовуються класи
BigInteger
таBigDecimal
? - Як можна створити число типу
BigInteger
? - Чим відрізняється внутрішнє представлення
double
таBigDecimal
? - Чи можна застосовувати математичні операції до чисел типів
BigInteger
таBigDecimal
? - Для чого застосовують клас
MathContext
? - Що таке JCF? Які стандартні інтерфейси надає JCF?
- Як створити колекцію тільки для читання?
- Які методи інтерфейсу
Queue
використовують для додавання елементів? - З якою метою методи роботи з чергою реалізовані у двох варіантах – з генерацією винятку і без?
- Для чого використовують клас
PriorityQueue
? - Для чого використовують стеки?
- Які є стандартні способи реалізації стека?
- У чому переваги й особливості Stream API?
- Як отримати потік з колекції?
- Як отримати потік з масиву?
- Чим відрізняються проміжні й кінцеві операції?
- Які є потоки для роботи з примітивними типами?
- Які стандартні засоби перевірки тверджень в Java?
- Що таке модульне тестування?
- Що таке JUnit?
- Як здійснюється анотування методів тестування в JUnit?
- Як здійснити логічне групування тестів?
- Як скористатися JUnit у програмному середовищі?