Laboratory Training 1

Working with Big Numbers and Data Sets

1 Training Tasks

1.1 Individual Task

Design and implement classes to represent the entities of the third laboratory training of the course "Fundamentals of Java Programming". The solution should be based on the previously created class hierarchy.

You should create a derived class that represents the base entity. The class created in the fourth laboratory of the course "Fundamentals of Java Programming" should be used as base class. You should use a class that represents a sequence as a list. Create derived classes in which to override the implementation of all methods related to sequence processing through the use of Stream API tools. If the class representing the second entity has no sequence processing, the class can be left unmodified.

The program must demonstrate:

  • reproduction of the functionality of laboratory trainings No. 3 and No. 4 of the course "Fundamentals of Java Programming";
  • using Stream API tools for all sequence processing and output functions;
  • testing methods of individual classes using JUnit.

1.2 Finding an Integer Power

Write a program that fills a number of type BigInteger with random digits and calculates the integer power of this number. For the result, use BigInteger type. Implement two ways: using a pow() method and a function that provides multiplication of long integers. Compare the results.

Provide testing of class methods using JUnit.

1.3 Filtering and Sorting

Create a list of objects of type BigDecimal. Fill the list with random values. Sort by decreasing absolute value. Find the product of positive numbers. Implement three approaches:

  • using loops and conditional statements (without facilities added in Java 8);
  • without explicit loops and branches, using functions that have been defined in the Java Collection Framework interfaces since Java 8;
  • using Stream API tools.

Provide testing of classes using JUnit.

1.4 Finding all Divisors (Advanced Task)

Use the Stream API to organize a search for all divisors of a positive integer. Create a separate static function that accepts an integer and returns an array of integers. Inside the function create an IntStream object. Apply range() function and filter. Do not use explicit loops.

2 Instructions

2.1 General Features of the Java SE Platform

Java Platform, Standard Edition, (Java SE) is the standard version of the Java platform, developed for creating and executing applications designed for individual use or for use in small enterprise. Java SE is defined by the specification of packages and classes that provide solutions to solving problems in the following directions:

  • work with mathematical functions;
  • working with container classes;
  • work with time and calendar;
  • work with text;
  • internationalization and localization;
  • work with regular expressions;
  • work with input-output streams and the file system;
  • working with XML;
  • serialization and deserialization;
  • creation of graphical user interface programs;
  • use of graphic tools;
  • support for printing;
  • support for working with sound;
  • using RTTI, reflection, and class loaders;
  • use of multithreading;
  • working with databases;
  • Java Native Interface;
  • means of executing scripts;
  • network interaction support;
  • interaction with the software environment;
  • ensuring application security;
  • support for logs;
  • deployment of Java applications.

Next, some of the capabilities of Java SE will be considered.

2.2 Using BigInteger and BigDecimal Types

2.2.1 Overview

For mathematical calculations, in addition to built-in value types, you can use objects of classes derived from java.lang.Number. Previously, the classes Byte, Double, Float, Integer, Long and Short, derived from Number, were considered. There are also java.math.BigInteger and java.math.BigDecimal classes that allow you to work with numbers of arbitrary precision.

Like all classes derived from the java.lang.Number abstract class, the BigInteger and BigDecimal classes implement conversion methods to existing primitive types:

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

It should be remembered that such a transformation very often leads to a partial loss of accuracy in the representation of numbers.

Both classes implement the Comparable interface and you can use the compareTo() method to compare such numbers. This method returns –1 if the current object is less than the parameter, 1 if the current object is greater, and 0 if the values are equal.

2.2.2 Using BigInteger

Constructors of the BigInteger class allow you to create numbers from strings, byte arrays, or generate it randomly, specifying the desired length.

BigInteger number1 = new BigInteger("12345678901234567890");
BigInteger number2 = new BigInteger(new byte[] { 1, 2, 3 });
BigInteger number3 = new BigInteger(100, new Random()); // 100 - number of bits

If a BigInteger object was created from an array of bytes, they define the internal binary representation of the number. In our case, number2 will represent the integer 10000001000000011 in binary notation, or 66051.

To initialize a BigInteger object with an integer, use the valueOf() static factory method:

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

For the convenience of working, the constants BigInteger.ZERO, BigInteger.ONE, BigInteger.TWO and BigInteger.TEN are defined.

In addition to overloading the standard toString() method, the toString() function is also implemented with a parameter that is the radix:

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

Instead of operators, it is necessary to use methods of the BigInteger class:

  • BigInteger add(BigInteger secondOperand) returns the sum of two numbers;
  • BigInteger subtract(BigInteger secondOperand) returns the result of subtraction of two numbers;
  • BigInteger multiply(BigInteger secondOperand) returns the product of two numbers;
  • BigInteger divide(BigInteger secondOperand) returns the result of division of two numbers;
  • BigInteger negate() returns the result of multiplication by –1;
  • BigInteger remainder(BigInteger val) returns the remainder from division (positive or negative)
  • BigInteger mod(BigInteger secondOperand) returns the absolute value of the remainder from the division of two numbers.

Below is an example of using these operations.

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

The max() and min() methods allow you to compare the current object with a parameter. These methods also return a BigInteger:

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

You can also call mathematical methods:

  • BigInteger abs() returns the absolute value of a number;
  • BigInteger pow(int n) returns the n-th power of a number; n cannot be a negative number;
  • BigInteger gcd(BigInteger number) returns a BigInteger whose value is the greatest common divisor of absolute values of current object and number.

Example:

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

There are a number of functions for implementing bitwise operations.

  • BigInteger and(BigInteger number) returns the result of the AND operation;
  • BigInteger or(BigInteger number) returns the result of the OR operation;
  • BigInteger not() returns the result of the NOT operation (bit inversion);
  • BigInteger xor(BigInteger number) returns the result of the XOR operation (exclusive or);
  • BigInteger shiftLeft(int n) returns the operation of shifting bits to the left by n positions;
  • BigInteger shiftRight(int n) returns the operation of shifting the bits to the right by n positions.

There are methods that are a superposition of two operations, for example, andNot(). The following example demonstrates how bitwise operations work:

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

There are a number of methods for working with individual bits:

  • boolean testBit(int n) returns true if the specified bit is set to one;
  • BigInteger setBit(int n) returns a BigInteger object in which the corresponding bit is set to one;
  • BigInteger clearBit(int n)returns a BigInteger object in which the corresponding bit is set to zero;
  • BigInteger flipBit(int n) returns a BigInteger object in which the corresponding bit is set to the opposite value.

Bits are numbered starting from zero from the rightmost bit. The following example demonstrates working with individual bits:

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

There are also interesting methods for working with prime numbers. These are numbers that are probably prime. In most practical applications of prime numbers, for example in cryptography, it is sufficient to assume that some large number is probably prime and can be used for encryption. Miller-Rabin algorithm is implemented to obtain probably prime numbers. In some methods of the BigInteger class, you can adjust the probability that the resulting random number is prime by specifying the certainty parameter. The probability that the number will be prime is estimated as 1 - 1/2certainty and higher. It should be remembered that increasing the probability is associated with a significant increase in time and spending other resources. If the parameter is not defined, it defaults to 100.

A special constructor allows you to create a BigInteger object with a presumably prime number inside. A similar job is performed by the factory static method:

  • BigInteger(int bitLength, int certainty, Random rnd) creates an object that contains a probably prime number with a representation length of bitLength length of bitLength bits; the given certainty and the rnd random number generator object will be used for generation;
  • static BigInteger probablePrime(int bitLength, Random rnd) creates and returns an object that contains a probably prime number with a representation length of bitLength bits; the rnd random number generator object will be used for generation;

In addition, there are two methods for working with probably prime numbers:

  • boolean isProbablePrime(int certainty) returns true f the number is probably prime and false if it is definitely not prime;
  • BigInteger nextProbablePrime() returns the first probably prime number greater than current object.

The following example we search for consecutive probably prime numbers from 50 to 100:

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

Additional methods intValueExact(), byteValueExact(), shortValueExact() and longValueExact() are implemented for conversion into primitive integer types. The specificity of these methods is that the value of the range of the corresponding primitive type is checked. If there is no way to accurately convert the type, the methods throw an ArithmeticException.

2.2.2 Using BigDecimal

The main difference between the BigDecimal type and double is that it uses the decimal notation instead of binary to represent floating-point numbers. The traditional representation of double as mantissa × 2 exponent does not accurately represent simpler decimal fractions such as 0.3, 0.6, 0.7, etc.

Data in a BigDecimal type object is represented in the form of mantissa × 10 exponent, and the precision of the number representation is practically unlimited. This approach naturally reduces the efficiency of working with floating-point numbers, but there are many problems in which efficiency can be sacrificed for the sake of accuracy. First of all, these are financial transactions.

In general, the following can be attributed to the advantages:

  • bringing the internal representation closer to the one adopted in everyday activities;
  • high accuracy of number representation;
  • powerful tools for managing the accuracy of mathematical operations and mechanisms of getting results;
  • no overflow errors.

Disadvantages include the following:

  • inefficient use of memory;
  • slowness of calculations;
  • problems with storage in databases.

The presence of these shortcomings limits the use in ordinary calculations. This type should only be used if efficiency is less important than accuracy.

The internal representation of BigDecimal consists of an "unscaled" arbitrary-precision integer (unscaledValue) and a 32-bit integer scale. If the scale is zero or positive, the scale is the number of digits to the right of the decimal point. If the scale is negative, the unscaled value of the number is multiplied by ten to the power of -scale . Therefore, the value of the number represented is unscaledValue × 10 -scale.

The scale() and setScale() methods of BigDecimal class allow you to get and set the value of the scale, respectively. The unscaledValue() method allows you to get an unscaled value.

There are several ways to create an object of type BigDecimal. You can create an object from an integer and a double number:

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

To ensure better accuracy, the object should be created from a string, not from a number of type 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

The inaccuracy occurs when a number from a user-defined decimal representation is converted to a binary (double) and then vice versa.

The java.math.MathContext class is associated with the BigDecimal class. It encapsulates the rules for performing arithmetic operations, in particular, precision and rounding rules are defined in the constructors. The value 0 assumes an unlimited length of the number, positive integers are the number of digits of the representation:

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

The java.math.RoundingMode enumeration lists the constants for defining the rounding rule:

  • UP: away from zero;
  • CEILING: towards positive infinity;
  • DOWN: towards zero;
  • FLOOR: towards negative infinity;
  • HALF_DOWN: if "neighbors" are at the same distance, away from zero;
  • HALF_EVEN: if "neighbors" are at the same distance, towards an even value;
  • HALF_UP: if "neighbors" are at the same distance, towards zero;
  • UNNECESSARY: rounding cannot be performed; if rounding is required, an exception is thrown.

The default rounding option is HALF_UP.

An object of MathContext class can be applied, in particular, as a parameter of round() method. The example below demonstrates different rounding rules for positive and negative numbers:

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

You can use the DECIMAL32, DECIMAL64, DECIMAL128, and UNLIMITED constants to define a MathContext object. with appropriate precision and HALF_UP rounding rule.

There are several constructors of the BigDecimal class that use MathContext, for example,

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

To create an object, you can use BigInteger:

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

Like BigInteger, the BigDecimal class provides BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.TWO and BigDecimal.TEN constants.

The use of arithmetic operations on similar BigInteger. Additional methods are implemented, the last parameter of which is MathContext. There is a problem with division. In cases where the result is an infinite fraction, division without a limit on the length of the result results in the generation of an exception java.lang.ArithmeticException. For example, we will receive such an exception when trying to calculate 1/3:

BigDecimal three = new BigDecimal("3");
System.out.println(BigDecimal.ONE.divide(three)); // exception

In order to prevent this, it is necessary to specify MathContext object during division, for example:

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

You can also apply getting the integer part and the remainder separately :

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

The divideAndRemainder(BigDecimal divisor) method returns an array of two objects of BigDecimal type. The two elements of this array are the integer part and the remainder, respectively.

Mathematical functions are also implemented in the BigDecimal class, for example:

  • BigDecimal sqrt(MathContext mc) obtaining the square root;
  • BigDecimal pow(int n) obtaining an integer power;
  • BigDecimal pow(int n, MathContext mc) obtaining an integer power;
  • BigDecimal abs() obtaining an absolute value;
  • BigDecimal abs(MathContext mc) obtaining an absolute value.

There are also functions for finding the maximum and minimum of two BigDecimal numbers, as well as a number of auxiliary functions.

2.3 Problems of Storage and Processing of Data Sets

During the creation of modern information systems, working with data sets (data collections) is one of the most widespread. Typical tasks of data processing can be given:

  • data collection and registration;
  • data accumulation;
  • search and filtering;
  • transformations and calculations;
  • unification and separation;
  • sorting;
  • data output in the required form.

Very often, data processing algorithms do not differ from information storage methods. For example, you can sort arrays, lists, data loaded from a database, strings of text, etc.

One of the principles of generalized programming is to separate data structures from processing algorithms. Such separation is appropriate in all cases where it is possible. The expediency of such a separation lies in

  • Ability to create universal containers that do not depend on algorithms and algorithms that do not depend on data structures
  • Ability to independently develop and modify code related to data storage and processing.
  • The possibility of separate testing of data storage and processing tasks, which increases the reliability of the software.
  • Improved code readability

In addition, this separation of code corresponds to the principle of th Single responsibility principle, the most important principle of object-oriented design.

However, there are some cases where the separation of data structures from algorithms does not make sense. For example, it is impractical in simple tasks that do not involve scaling, as well as when the efficiency of the program, the amount of memory, etc. are critical.

Separation of the tasks of information storage and processing would be implemented in libraries of various programming languages and platforms. For example, the C++ standard includes the Standard Template Library (STL), which provides separate data structures for storing collections of objects, such as vector, list, set, etc., and separate template functions for working with arbitrary sequences, including arrays. These are generalized functions for_each(), find_if(), sort(), etc.

Its approach to the implementation of algorithms was provided in the Java 2 Collection Framework.

2.4 Using the Java Collection Framework for Data Processing

2.4.1 Overview

The Java Collection Framework (JCF) is a set of interfaces, classes, and algorithms designed to store and manipulate collections of objects.

JFC interfaces and classes were defined in Java 2. Starting with Java 5, all types are implemented as generic. Significant enhancements appeared in JDK 1.8. Different types were added and expanded in later versions.

We'll look at the facilities that were implemented before Java 8 first. Below are the most important JCF standard generic interfaces with the standard classes that implement them. In addition to these classes, for almost every interface there is an abstract class that implements it. For example, AbstractCollection, as well as classes derived from it: AbstractList, AbstractQueue, AbstractSet, etc. These classes are the base for the corresponding standard implementations and can also be applied to create custom collections.

  • Iterable is the basic interface implemented in the java.lang package. This interface declares an abstract method iterator() that returns an iterator object of type Iterator. Objects of classes that implement this interface are collections that can be used in an alternative construction of the for loop (for each). In addition, the Iterable interface provides a number of methods with default implementations.
  • Iterator is an interface, the implementation of whose methods ensures the sequential traversal over collection items. Starting with Java 5, iterators allow iterating through elements using a "for each" loop. All JFC collections provide their own iterators for traversing elements.
  • ListIterator an interface derived from Iterator, which allows iterating in long directions as well as modifying the current elements. Used in lists.
  • Collection is an interface derived from Iterable. It is basic for all collections except associative arrays (Map). The AbstractCollection class directly implements the Collection interface. This class is used to create a number of abstract classes that represent different types of collections.
  • List is interface derived from Collection; it defines a data structure in which elements can be accessed using indexes; lists support duplication and can store elements in the order they were added; the most popular standard classes that implement this interface are ArrayList (a list built on an array) and LinkedList.
  • Queue is interface derived from Collection; a collection used to store elements in first-in-first-out (FIFO) access order. The most popular class that implements this interface is LinkedList.
  • Deque is an interface derived from Queue. It extends the functionality of the queue by allowing elements to be inserted and removed both from the beginning and from the end of the collection (double-ended queue). The classes that implement this interface are ArrayDeque and LinkedList.
  • Set is an interface derived from Collection, representing a collection that does not allow duplicate elements. It ensures that each element is stored in the collection only once. The most common implementation is HashSet.
  • SortedSet is an interface derived from Set. Assumes that elements are arranged according to a specific sort attribute. The most common implementation is TreeSet.
  • Map is an interface that represents a separate branch. This interface describes a collection of key-value pairs. Each key is unique and is used to access the corresponding value. The most common implementation is HashMap.
  • SortedMap is an interface derived from Map. Assumes that the keys are arranged according to a certain sort feature. The most common implementation is TreeMap.

The listed interfaces (except Iterable) and the standard classes that implement them are located in the java.util package and require the use of appropriate import directives.

All JCF collections can be either read-only collections or read-write collections. There are no separate interfaces for such collections. It's just that read-only collections throw an exception UnsupportedOperationException in the body of all methods that could potentially modify the collection. This approach reduces the number of different interfaces, but it has a drawback: calls to unimplemented methods cannot be checked at compile time, only at runtime.

The Iterator<E> interface declares the following methods:

  • boolean hasNext() returns true if there are still elements in the collection.
  • E next() returns the next element in the iteration. After the first call, the initial element of the collection is referenced.
  • void remove() removes the element referenced by the iterator from the collection.

The ListIterator<E> interface adds the following methods:

  • int nextIndex() returns the index of the element that will be returned by the next call of next().
  • boolean hasPrevious() returns true if this list iterator has more elements when traversing the list backwards.
  • E previous() returns the previous item in the list and moves the cursor position in the reverse direction.
  • int previousIndex() returns the index of the element that will be returned by the next call of previous().
  • void add(E e) inserts the specified item into the list.
  • void set(E e) replaces the last element obtained via next() or previous() with the specified element.

Iterator methods will be discussed in the context of their use in collections.

The most general operations that are implemented in all container classes (except associative arrays) are declared in the Collection<T> interface. The methods that do not change the collection are listed below:

  • int size() returns the size of the collection;
  • boolean isEmpty() returns true if the collection is empty;
  • boolean contains(Object o) returns true if the collection contains an object;
  • boolean containsAll(Collection<?> c) returns true if the collection contains another collection;
  • Iterator<E> iterator() returns an iterator - an object that sequentially points to elements;
  • Object[] toArray() returns an array of references to Object, which contains copies of all elements of the collection;
  • T[] toArray(T[] a) returns an array of references to T, which contains copies of all elements of the collection.

The following example demonstrates how the methods work on the elements of a read-only list that is created from an array using the Arrays.asList() method:

Collection<Integer> unmodifiable = Arrays.asList(1, 2, 4, 8);
System.out.println(unmodifiable.size());      // 4
System.out.println(unmodifiable.isEmpty());   // false
System.out.println(unmodifiable.contains(4)); // true
System.out.println(unmodifiable.contains(6)); // false
System.out.println(unmodifiable.containsAll(Arrays.asList(1, 4))); // true
// Display elements in separate lines:
for (var iterator = unmodifiable.iterator(); iterator.hasNext(); ) {
    System.out.println(iterator.next());
}
// Output elements using an implicit iterator:
for (Integer k : c) {
    System.out.println(k);
}
// The parameter is necessary to create an array of a certain type:
Integer[] arr = unmodifiable.toArray(new Integer[0]); 
System.out.println(Arrays.toString(arr));  // [1, 2, 4, 8]

Interface methods Collection<E> that modify a collection:

  • boolean add(E e) adds an object to the collection. Returns true if the object is added.
  • boolean remove(Object o) removes an object from the collection.
  • boolean addAll(Collection<? extends E> c) adds objects to the collection. Returns true if objects are added.
  • boolean removeAll(Collection<?> c) removes objects from a collection if they are present in another collection.
  • boolean retainAll(Collection<?> c) leaves objects present in another collection.
  • void clear() removes all elements from the collection.

To test the specified methods, you can use the ArrayList class:

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); // []

Methods added in the List<E> interface:

  • E get(int index) returns the element with the specified index.
  • E set(int index, E element) replaces the element at the specified index with the specified object. Returns the previous value that was stored at the specified index.
  • void add(int index, E element) inserts the specified element into the specified position in the list.
  • boolean addAll(int index, Collection<? extends E> c) inserts elements from the specified collection into the specified position in the list.
  • E remove(int index) removes the element at the specified position.
  • int indexOf(Object o) returns the index of the first occurrence of the object, or -1 if the object does not exist.
  • int lastIndexOf(Object o) returns the index of the last occurrence of the object, or -1 if the object is not present.
  • List<E> subList(int fromIndex, int toIndex) returns the part of the list from fromIndex (including) to toIndex (not including). Memory for the new list is not allocated.
  • ListIterator<E> listIterator() returns a list iterator. After the first call of next() method the iterator will point to the initial element of the list.
  • ListIterator<E> listIterator(int index) returns a list iterator. The index points to the element that will be returned by the first call of next(). The first call of previous() returns the element with the specified index minus one.

The following example demonstrates the listed methods. An ArrayList class (a list built on the use of an array) is used for demonstration.

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]
// Outputs index / value pairs in reverse order:
for (var iterator = list.listIterator(list.size()); iterator.hasPrevious(); ) {
    System.out.printf("%d %d%n", iterator.previousIndex(), iterator.previous());
}
// Adds indexes to elements. Adds intermediate elements:
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]

In cases where insertion and deletion are used more often than getting an element by index, it is more efficient to use a LinkedList class (a doubly linked list).

Work with lists was considered in more detail in Laboratory training No. 4 of the previous semester.

The following methods are declared in the Queue<E> interface:

  • boolean add(E e) inserts an element into the queue, throwing the IllegalStateException if the queue is full.
  • boolean offer(E e) inserts an element into the queue and returns true. If the queue is full, does not generate an exception, but returns false.
  • E remove() gets and removes a queue element. Throws an exception if the queue is empty.
  • E poll() gets and removes the queue element or returns null if the queue is empty. Does not generate exceptions.
  • E element() gets, but does not remove, the next element. Throws an exception if the queue is empty.
  • E peek() gets, but does not remove, the queue element or returns null if the queue is empty. Does not generate exceptions.

The methods add(E e), remove() and element() are used in those cases when an attempt to get an element from an empty queue or to overflow the queue is unlikely and is not part of a normal process.

The LinkedList class is most often used for implementation of Queue.

The following methods are additionally declared in the Deque<E> interface derived from Queue<E>:

  • void addFirst(E e) inserts an element at the beginning of the queue, throwing the IllegalStateException if the queue is full.
  • void addLast(E e) inserts an element at the end of the queue, throwing the IllegalStateException if the queue is full.
  • boolean offerFirst(E e) inserts an element at the beginning of the queue and returns true. If the queue is full, does not generate an exception, but returns false.
  • boolean offerLast(E e) inserts an element at the end of the queue and returns true. If the queue is full, does not generate an exception, but returns false.
  • E removeFirst() gets and removes the first element of the queue. Throws an exception if the queue is empty.
  • E removeLast() gets and removes the last element of the queue. Throws an exception if the queue is empty.
  • E pollFirst() gets and removes the first element of the queue or returns null if the queue is empty. Does not generate exceptions.
  • E pollLast() gets and removes the first element of the queue or returns null if the queue is empty. Does not generate exceptions.
  • E getFirst() gets, but does not remove, the first element of the queue. Throws an exception if the queue is empty.
  • E getLast() gets, but does not remove, the last element of the queue. Throws an exception if the queue is empty.
  • E peekFirst() gets, but does not remove, the first element of the queue or returns null if the queue is empty. Does not generate exceptions.
  • E peekLast() gets, but does not remove, the last element of the queue or returns nnull if the queue is empty. Does not generate exceptions.

Also, work with queues was considered in Laboratory training No. 4 of the previous semester.

Working with sets in JCF (the Set interface) is based on the use of methods declared in the Collection interface.

The Map<K,V> interface is not derived from Collection Methods defined in the Map interface (up to and including Java version 7):

  • V put(K key, V value) adds a pair or modifies the value if the key exists. Returns the previous value or null if the key was missing.
  • void putAll(Map<? extends K,? extends V> m) copies key-value pairs from another associative array.
  • int size() returns the number of pairs in the associative array.
  • V get(Object key) returns the value by key or null if the key is missing.
  • V remove(Object key) deletes pair by the specified key
  • Set<K> keySet() returns the set of keys of an associative array.
  • Collection<V> values() returns the collection of values contained in an associative array.
  • boolean containsKey(Object key) returns true if the associative array contains the specified key.
  • boolean containsValue(Object value) returns true if the associative array contains the specified value.
  • Set<Map.Entry<K,V>> entrySet() returns an array of objects that represent pairs.
  • boolean isEmpty() returns true if the associative array is empty.
  • void clear() removes all pairs from the associative array.

Work with sets and associative arrays was considered in more detail in Laboratory training No. 4 of the previous semester.

The Collections class provides a number of static methods. These are functions for creating various special collections and so-called algorithms that are generic static methods for working with collections. The static methods of the class were considered in more detail in Laboratory training No. 4 of the previous semester.

2.4.2 Additional Features for Collections in Java 8

Up to and including Java 7, the traditional imperative approach, based on the explicit use of loops, conditional statements, switches, etc., was used to work with collections. The standard algorithms provided by classes Arrays and Collections, only partially met the need for data processing.

Starting with Java 8, the standard interfaces of the java.util package are supplemented with methods that focus on using lambda expressions and references to methods. To ensure compatibility with previous versions, new interfaces provide the default implementation of the new methods. In particular, the Iterable interface defines the forEach() method, which allows you to perform some actions in the loop that do not change the elements of the collection. You can specify an action using a lambda expression or a reference to a method. For example:

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);
    }
}

In the above example, the sum of collection elements is calculated. The variable that holds the sum is described as a static class field, since lambda expressions cannot change local variables.

The Collection interface defines the removeIf() method, which allows you to remove items from the collection if items match a certain filter rule. In the following example, odd items are removed from the collection of integers. The forEach() method is used for columnwise output the collection items:

Collection<Integer> c = new ArrayList(Arrays.asList(2, 4, 11, 8, 12, 3));
c.removeIf(k -> k % 2 != 0);
// The rest of the items are displayed columnwise:
c.forEach(System.out::println);

The List interface provides methods replaceAll() and sort(). The second one can be used instead of the analogous static method of the Collections class, but the definition of the sorting feature is obligatory:

List<Integer> list = new ArrayList(Arrays.asList(2, 4, 11, 8, 12, 3));
list.replaceAll(k -> k * k); // replace the numbers with their second powers
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 defines the Spliterator interface that can be applied to collections. This interface defines a special kind of iterator, a separator iterator. In particular, it allows you to split a sequence into several subsequences that you can work with in parallel. You can get an instance of Spliterator using the spliterator() method defined in the Collection interface.

The reason in dividing collections into separate parts arises first of all in cases where parallel processing of elements of a large data set is possible. But usage of Spliterator can also be useful in a single-threaded environment.

The trySplit() method divides the elements into two approximately equal parts. The method creates and returns a new Spliterator object using which you can work with the first half of the sequence. The object for which the trySplit() method was called will work with the second half of the sequence.

The forEachRemaining() method provides an iteration for Spliterator. The method is declared as follows:

void forEachRemaining(Consumer<? super Double> action)

Example:

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);

The result of this program fragment will be as follows:

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

Now you can work with the two parts of the list separately. As you can see from the example, after splitting, the first iterator works with the second half of the sequence, and the second with the first one.

There is also tryAdvance() method that actually combines the hasNext() and next() those declared in the Iterator interface. The tryAdvance() method is declared as follows::

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

If the remaining element exists, it performs the specified action on it, returning true; otherwise it returns false. In other words, it performs an action on the next element in the sequence and then shifts the iterator. You can also display elements using tryAdvance():

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

Starting with Java 8, new methods have been added to the Map interface. The added methods listed in the table:

Method Description
V getOrDefault(Object key, V& defaultValue) Returns a value, or a default value, if the key is missing
V putIfAbsent(K key, V value) Adds a pair if the key is missing and returns the value
boolean remove(Object key, Object value) Removes a pair if it is present
boolean replace(K key, V oldValue, V newValue) Replaces value with the new one if pair is present
V replace(K key, V value) Replaces the value if the key is present, returns the old value
V compute(K key, BiFunction<?& super K, super V, ? extends V> remappingFunction) Invokes the function to construct a new value. A new pair is added, a pair that existed before is deleted, and a new value is returned
V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) If a specified key is present, a new function is called to create a new value, and the new value replaces the previous one.
V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) Returns the value by the key. If the key is missing, a new pair is added, the value is calculated by function
V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) If the key is absent, then a new pair is entered and the value v is returned. Otherwise, the given function returns a new value based on the previous value and the key is updated to access this value. and then it returns
void forEach(BiConsumer<? super K, ? super V> action) Performs a given action on each element

The following example demonstrates the use of some of these methods:

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); // columnwise output
        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); // nothing changed
        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); // nothing changed
        map.computeIfPresent(2, (i, s) -> s.toLowerCase());
        System.out.println(map); // {1=one, 2=two, 4=4, 7=seven, 8=EIGHT}
        // Adding a new pair:
        map.merge(9, "nine", (value, newValue) -> value.concat(newValue));
        System.out.println(map.get(9));                  // nine
        // The text is concatenated with the previous one:
        map.merge(9, " as well", (value, newValue) -> value.concat(newValue));
        System.out.println(map.get(9));                  // nine as well
    }
}

2.4.3 Additional JCF Features in Later JDK Versions

Let's consider some innovations that have appeared after Java 8.

In version 9, the of() methods were added to the interfaces that represent collections, which provide convenient creation of collections, for example:

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");

In Java 10, methods for creating immutable collections have been added.

In version 21, the useful functions addFirst(), addLast(), getFirst(), getLast(), removeFirst() and removeLast(), as well as reversed() were added to collections. The putFirst(), putLast() and reversed() methods were added to SortedMap interface.

2.5 Using the Java 8 Stream API

2.5.1 Overview

Streams for work with collections, or streams of elements, data streams (Stream API) are designed for high-level processing of data stored in containers. They should not be confused with input / output streams.

Stream API's tools have been added to the standard starting with Java 8.

The Stream API is used to search, filter, transform, find the minimum and maximum values, as well as other data manipulation. An important advantage of the Stream API is the ability to work reliably and efficiently in a multithreading environment.

Streams should be understood not as a new kind of collections, but as a channel for transmission and processing of data. The stream of elements works with some data source, such as an array or collection. The stream does not store data directly, but performs transferring, filtering, sorting, etc. The actions performed by the stream do not change the source data. For example, sorting data in a stream does not change their order in the source, but creates a separate resulting collection.

You can create sequential and parallel streams of elements. Parallel streams are secure in terms of multithreading. From the available parallel stream you can get sequential one and vice versa.

To work with streams Java 8 java.util.stream package provides a set of interfaces and classes that provide operations on a stream of elements in the style of functional programming. The stream is represented by an object that implements the java.util.stream.Stream interface. In turn, this interface inherits the methods of the general interface java.util.stream.BaseStream.

Stream operations (methods) defined in the BaseStream, Stream, and other derived interfaces are divided into intermediate and terminal. Intermediate operations receive and generate data streams and serve to create so-called pipelines, in which a sequence of actions is performed over a sequence. Terminal operations give the final result and thus "consume" the output stream. This means that the output stream cannot be reused and, if necessary, must be re-created.

Intermediate operations are characterized by so-called lazy behavior: they are performed not instantaneously, but as the need arises - when the final operation is working with a new data stream. Lazy behavior increases the efficiency of work with the stream of elements.

The advantages of the approach built on data streams can be demonstrated on the problem of processing data about cities. Suppose you need to create a list of cities, leaving in it only different cities whose population exceeds one million inhabitants, sort the list by population growth and display the names of cities sorted by population.

First you need to create a City class:

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

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

    public String getName() {
        return name;
    }

    public int getPopulation() {
        return population;
    }

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

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

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

The traditional approach to solving the problem (before Java 7 inclusive) involves creating a list of cities, which will make it impossible to add the same objects. Next, you need to create a list from the set, sort by population and display the names of the cities using a loop:

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());
}

The disadvantages of this approach are obvious. A small task requires essential error-prone code. In addition, unnecessary objects are created in memory, in particular, a set.

Lambda expressions, method references, and new interface methods that make up the Java Collection Framework have made it easier to implement code.

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()));

By calling the removeIf() method with a lambda expression as a parameter, we delete unnecessary data. Next, the list is sorted according to the specified criterion (call of sort() method). The sort condition is defined by a method reference. The output of city names in separate lines is carried out by the forEach() function.

With streams, everything can be done in a single statement:

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);

In this example, a stream is created using the static function of(). The array of items that the stream will work with is created on rom a list of actual parameters. Next, the stream is filtered by calling the filter() function. The filtering condition is defined by a lambda expression. and further, only different data are selected (the distinct() method) and sorting is performed according to the specified criterion (call of sorted() method). The sort condition is defined by a method reference. Using the call of map() function, a stream is created to work with city names, after which the names are output in separate lines using the forEach() method.

2.5.2 Basic Methods for Working with Streams

The most significant methods of the generic java.util.stream.BaseStream interface are given in the table (S - type of the stream, E - type of the element, R - container type):

Method Description Note
S parallel() returns a parallel stream received from the current one intermediate operation
S sequential() returns a sequential stream received from the current one intermediate operation
boolean isParallel() returns true if the stream is parallel or false if it is sequential  
S unordered() returns an unordered data stream obtained from the current intermediate operation
Iterator<T> iterator() returns an iterator for the elements of this stream terminal operation
Spliterator<T> spliterator() returns a spliterator (split iterator) for the elements of this stream. terminal operation

The use of stream iterators will be discussed later.

The Stream interface extends the set of methods for working with streaming elements. It is also a generic interface and is suitable for working with any reference types. The following are the most commonly used Stream interface methods:

Method Description Note
void forEach(Consumer<? super T> action) executes the code specified by the action for each element of the stream terminal operation
Stream<T> filter(Predicate<? super T> pred) returns a stream of elements satisfying the predicate intermediate operation
Stream<T> sorted() returns a stream of elements sorted in natural order intermediate operation
Stream<T> sorted(Comparator<? super T> comparator) returns a stream of elements sorted in the specified order intermediate operation

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

applies the given function to the elements of the stream and returns a new stream intermediate operation
Optional<T> min(Comparator<? super T> comp) returns the minimum value using the specified comparison terminal operation
Optional<T> max(Comparator<? super T> comp) returns the maximum value using the specified comparison terminal operation
long count() returns the number of elements in the stream
terminal operation
Stream<T> distinct() returns a stream of differing elements intermediate operation
Optional<T> reduce(BinaryOperator<T> accumulator) returns the scalar result calculated by the values of the elements terminal operation
Object[] toArray() creates and returns an array of stream elements terminal operation

2.5.3 Creation of Streams

There are several ways to create a stream. You can use the factory methods added to the Collection interface (with default implementations), respectively stream() (for synchronous work) and parallelStream() (for asynchronous work):

List<Integer> intList = Arrays.asList(3, 4, 1, 2);
Stream<Integer> sequential = intList.stream();
Stream<Integer> parallel = intList.parallelStream();

You can create a stream from an array:

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

You can create a data source with the specified items. To do this, use the "factory" method of():

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

Streams of items can be created from input streams (BufferedReader.lines()), filled with random values (Random.ints()), and also obtained from archives, bit sets, etc.

You can get an array from a stream using the toArray() method. The following example creates a stream and then outputs to the console by creating an array and obtaining a string representation using the static Arrays.toString() method:

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

There is also a special class StreamSupport that provides static methods for creating streams. An example of use StreamSupport will be considered later.

2.5.4 Iteration by Elements

Streams provide iteration over data elements using the forEach() method. The function parameter is the standard Consumer functional interface, which defines a method with a single parameter and a void result type. For example:

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

Streams provide iterators. The iterator() method of the Stream interface returns an object that implements the java.util.Iterator interface. The iterator can be used explicitly:

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

You can also apply Spliterator to streams. In particular, the static methods of the StreamSupport class allow you to create a stream from a Spliterator object. Example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Spliterator<Integer> spliterator = numbers.spliterator();
  // true indicates that parallel processing can be used:
Stream<Integer> stream = StreamSupport.stream(spliterator, true);
stream.forEach(System.out::println);

Use of Spliterator in this context provides efficiency due to the possibility of parallel processing.

2.5.5 Operations with Streams

The simplest stream operation is filtering. The intermediate filter() operation returns a filtered stream, taking a parameter of Predicate type. The Predicate type is a functional interface that describes a method with a single parameter and boolean result type. For example, you can filter out only even numbers from the stream s:

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

The previous example illustrates the use of lambda expressions when working with streams, as well as a small conveyor that includes one intermediate operation.

The intermediate sorted() operation returns the sorted representation of the stream. Elements are ordered in the natural order (if it is defined). In other cases, the Comparator interface should be implemented, for example, using the lambda expression:

// Sort ascending:
Stream<Integer> s = Stream.of(4, 5, 6, 1, 2, 3);
s.sorted().forEach(System.out::println);
// Sort descending:
s = Stream.of(4, 5, 6, 1, 2, 3);
s.sorted((k1, k2) -> Integer.compare(k2, k1)).forEach(System.out::println);

The last example shows that after each call to the terminal operation, the stream should be recreated.

Most operations are implemented in such a way that actions on individual elements do not depend on other elements. Such operations are called stateless operations. Other operations that require working on all elements at once (for example, sorted()) are called stateful operations.

The intermediate operation map() receives a functional interface that defines a certain function for transforming and forming a new stream from the resulting transformed elements. For example, we calculate the squares of numbers:

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

Using the distinct() method, you can get a stream containing only different elements of the collection. For example:

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

Intermediate operations are characterized by the so-called delayed behavior (lazy behavior): they are not performed immediately, but as needed, when the final operation works with a new data stream. Delayed behavior increases the efficiency of working with streams of elements.

The terminal operation count() with the resulting type long returns the number of elements in the stream:

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

The terminal operations min() and max() return Optional objects with a minimum and maximum value, respectively. A Comparator type parameter is used for comparison. For example:

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

Using a terminal reduce() operation, we can calculate a scalar value. The reduce() operation in its simplest form performs the specified action with two operands, the first of which is the result of performing the action on the previous elements, and the second is the current element. In the following example, we find the sum of the elements of the data stream:

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

The min(), max(), and reduce() operations get a scalar value from the stream, so they are called reduction operations.

Sometimes it is necessary to reproduce a stream to perform several terminal operations. A Supplier functional interface with an abstract function get() will be used to create identical streams according to a defined rule. The rule can be described as a lambda expression. Example:

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

The concat() method joins two streams:

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

The skip() method creates a new stream in which the first n elements are omitted. Example:

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

There are also findFirst() and findAny() operations for searching for a certain object. These methods return Optional type. To check the presence of objects that satisfy certain conditions, anyMatch(), allMatch() and noneMatch() methods are used::

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 Using the Collectors Class

A special generic interface java.util.stream.Collector is defined for receiving collections from streams.

The terminal operation collect() of the Stream class allows you to retrieve a traditional collection from a stream. The type of collection depends on the parameter. The parameter is an instance of the Collector type. The Collectors class provides static methods for obtaining objects of type Collector, such as toCollection(), toList(), toSet() and toMap().

Example:

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 Using Streams to Work with Primitive Types

There are also streams for working with primitive types: IntStream, LongStream and DoubleStream. Consider the work with IntStream and DoubleStream.

The easiest way to create streams is to use a static of() function:

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

You can create streams from the corresponding arrays:

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

With the help of a range() method of IntStream class, you can create streams by filling them with sequential values. You can also simultaneously define a filter:

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

The iterate() method can be used to create an infinite stream. The next element is calculated from the previous one. You can limit the stream using the limit() function. So, for example, you can get consecutive powers of 3:

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

The generate() method lso allows you to generate elements, but without taking into account the previous ones. For example, you can fill an array with random numbers:

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

Further work is similar to work with common streams. For example, you can sort and display only odd values:

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

The resulting streams can be used to create new arrays:

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

Note: it is assumed that the streams intStream and doubleStream were not used in the terminal operations.

2.6 Testing in Java. Using JUnit

2.6.1 Overview

Testing is one of the most important components of the software development process. Software testing is performed in order to obtain information about the quality of the software product. There are many approaches and techniques for testing and verifying software.

The paradigm of test-driven development (development through testing) defines the technique of software development, based on the use of tests to stimulate the writing of code, and to verify it. Code development is reduced to repeating the test-code-test cycle with subsequent refactoring.

The level of testing at which the least possible component to be tested, such as a single class or function, is called unit testing. Appropriate testing technology assumes that tests are developed in advance, before writing the real code, and the development of the code of the unit (class) is completed when its code passes all the tests.

2.6.2 Java Tools for Diagnosing Runtime Errors

Many modern programming languages, including Java, include syntactic assertions. The assert keyword has appeared in Java since version JDK 1.4 (Java 2). The assert work can be turned on or off. If the execution of diagnostic statements is enabled, the work of assert is as follows: an expression of type boolean is executed and if the result is true, the program continues, otherwise an exception of java.lang.AssertionError throws. Suppose, according to the logic of the program, the variable c must always be positive. Execution of such a fragment of the program will not lead to any consequences (exceptions, emergency stop of the program, etc.):

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

If, due to an incorrect software implementation of the algorithm, the variable c still received a negative value, the execution of a fragment of the program will lead to the throwing of an exception and an abnormal termination of the program, if the processing of this exception was not provided:

int a = 10;
int b = 11;
int c = a - b;
assert c > 0; // exception is thrown

After the assertion, you can put a colon, followed by a string of the message. Example:

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

In this case, the corresponding string is the exception message string.

Assert execution is usually disabled in integrated development environments. To enable assert execution in the IntelliJ IDEA environment, use the Run | Edit Configurations menu function. In the Run/Debug Configurations window, enter -ea in the VM Options input line.

In these examples, the values that are checked with assert are not entered from the keyboard, but are defined in the program to demonstrate the correct use of assert - the search for logical errors, rather than checking the correctness of user input. Exceptions, conditional statements, etc. should be used to verify the correctness of the data entered. The use of assertion validation is not allowed, because in the future the program will be started without the -ea option and all assertions will be ignored. The expression specified in the statement should not include actions that are important in terms of program functionality. For example, if the assertion check is the only place in the program from which a very important function is called,

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

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

then after disabling assertions the function will not be called at all.

2.6.3 Basics of Using JUnit

In contrast to the use of diagnostic statements, which performs testing of algorithms "from the inside", unit testing provides verification of a particular unit as a whole, testing "outside" the functionality of the unit.

The most common unit testing support for Java software is JUnit, an open unit testing library. JUnit allows:

  • create tests for individual classes;
  • create test suits;
  • create a series of tests on repeating sets of objects.

Now the JUnit 5 version is now relevant. But also a very widespread is JUnit 4 version.

To create a test, you need to create a class that needs to be tested, as well as create a public class for testing with a set of methods that implement specific tests. Each test method must be public, void, and have no parameters. The method must be marked with an annotation @Test:

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

Note: to use the @Test and other similar annotations should be added import statements import org.junit.jupiter.api.*; for JUnit 5) or import org.junit.*; (for JUnit 4) .

Within such methods, you can use the following assertion methods:

assertTrue(expression);                 // Fails the test if false
assertFalse(expression);                // Fails the test if true
assertEquals(expected, actual);         // Fails the test if not equivalent
assertNotNull(new MyObject(params));    // Fails the test if null
assertNull(new MyObject(params));       // Fails the test if not null
assertNotSame(expression1, expression2);// Fails the test if both links refer to the same object
assertSame(expression1, expression2);   // Fails the test if the objects are different
fail(message)                           // Immediately terminates the test with a failure message

Here MyObject is a class that is being tested. These Assertion class methods (Assert class methods for JUnit 4) are accessed using static import: import static org.junit.jupiter.api.Assertion.*; (for JUnit 5) or import static org.junit.Assert.*;. These methods also are implemented with an additional message parameter of type String, which specifies the message that will be displayed if the test failed.

The IntelliJ IDEA provides built-in JUnit support. Suppose a new project has been created. The project contains a class with two functions (static and non-static) that should be tested:

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

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

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

Within the project, we can manually create a folder, for example, tests. Next we should set Mark Directory as | Test Sources Root with the context menu.

Returning to the MathFuncs class, choosing it in the code editor, through the context menu we can generate tests: Generate... | Test.... In the dialog that opened, we select the version of the JUnit library. The desired option is JUnit5. We can also correct the class name that we offer: MathFuncsTest. In most cases, the correction of this name is not needed. Then we select the names of methods that are subject to testing. In our case, there are sum() and mult(). Such a code will be received:

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

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

class MathFuncsTest {

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

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

IntelliJ IDEA indicates errors in this code (Cannot resolve symbol 'junit'). By clicking Alt+Enter, we get a hint: Add 'JUnit 5.7.0' to classpath. Taking advantage of this prompt, we add the relevant library and get the code without errors.

We can optimize the code by adding imports. We add testing of MathFuncs class methods into MathFuncsTest methods. To test the work of mult() we need to create an object:

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

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

class MathFuncsTest {

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

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

You can run tests to run through the Run menu. The normal completion of the process indicates no errors during verification. If you add a code that distorts computing in the MathFuncs class, for example

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

running tests will result in AssertionFailedError message. You can see how many tests have been successful, and how much it is not passed.

If some actions need to be taken before performing the test function, for example, to format the values of variables, then such initialization is made in a separate static method, which is preceded by an annotation @BeforeAll(@BeforeClass in JUnit 4):

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

Similarly, the methods in which the actions needed after testing are preceded by@AfterAll annotation (@AfterClass in JUnit 4). Methods must be public static void.

In our example, we can create an object in advance, as well as add messages after the tests are completed:

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

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

class MathFuncsTest {
    private static MathFuncs funcs;

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

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

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

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

Annotation @BeforeEach (@Before in JUnit 4) indicates that the method is called before each test method. Accordingly, @AfterEach (@After in JUnit 4) indicates that the method is called after each successful test method. Methods marked by these annotations should not be static.

You can also test methods that return void. Calling such a method involves performing an action (for example, creating a file, changing the value of a field, etc.). It is necessary to check whether such action took place. For example:

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

...

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

However, as a rule, testing the simplest access methods (setters and getters) seems excessive and is not recommended.

2.7 Version Control Systems. Using the GitHub Service Repository

During the development of large projects, there is a need for additional means of control over different versions of artifacts, in particular, the source code. You must be able to access previous versions of documents to trace changes. To solve these problems, as well as to provide collective access to the project, use version control systems – special software for managing changing documents and providing access to these documents. The daily cycle of interaction with the version control system includes updating the working copy, modifying the project and fixing changes. While working on the project, you can create several branches (forks) for different solutions, and then merge the versions.

Version control systems can be centralized and distributed. In centralized systems, version storage is performed on a special server. An example of a centralized system is Subversion (SVN). Distributed systems have a local copy of the repository and ensure that the data is reconciled with the repository on the remote computer. Git is an open version control system. For open source projects, using Git is free.

GitHub is a social repository for open source projects that use Git to control source versions. To create repositories, register at https://github.com.

Both implementations of IntelliJ IDEA support integrated work with version control systems (VCS submenu of the main menu). To work with GitHub in IntelliJ IDEA, you must first install the Git system. You can download the required software at https://git-scm.com/downloads for your operating system. You can retain unchanged selected options on the installation wizard pages.

After installing the necessary software, you need to make some settings. To do this, run the program Git Bash, in the command line set the name and address of the user, specified earlier during registration on GitHub:

git config --global user.name "user_name"
git config --global user.email user_address@mail

A .gitconfig file is created containing the appropriate settings.

In the IntelliJ IDEA environment you should configure Git (File | Settings..., then Version Control | Git). You should specify the path to the git.exe file, for example, C:\Program Files\Git\bin\git.exe. In the GitHub settings (Version Control | GitHub) add an account with the + button and carry out an authorization.

In order to add a previously created project in IntelliJ IDEA, you should first allow use of VCS: VCS | Enable Version Control Integration and select Git in the list of proposed VCS. Now in the main menu, instead of the VCS submenu will appear Git. A similar result can be obtained if you use the menu function VCS | Create Git Repository... and choose a project that interests you.

Note. GIT system can be installed from IntelliJ IDEA, if in the main menu select VCS | Enable Version Control Integration, then select git in the list of different VCS. In the lower right corner of the window there is a popup menu, which will be prompted to download Git.

You can now copy the project to GitHub using the Git | GitHub | Share Project on GitHub menu function.

If you have a project that was previously added to Git and want to add new files, such as classes, IntelliJ IDEA offers to add these files to a repository through the Add File to Git dialog box.

If you have previously added to GIT, add new files, such as classes, IntelliJ IDEA offers to add these files to a repository through the Add File to Git dialog box.

After making changes to the code, you should update the project in the local repository using the menu function Git | Commit.... You can individually update files through the context menu (Git | Commit File...) . The project update should be carried out after making changes related to the execution of a certain code of code modification. The meaning of this problem should be described in the Commit Message of Commit Changes dialog box.

After updating the project in the local repository using Commit function, the changes made can also be transferred to GitHub repository using the Git | Push... menu function (or similar to the function of the context menu).

At any other time, after closing all projects, you can use the Get from VCS function on the IntelliJ IDEA start window. Next, select GitHub, specify the folder for placing the project, and then confirm its opening.

3 Sample Programs

3.1 Finding Factorials

The traditional mathematical problem of calculating the factorial for large integers causes difficulties associated with restrictions on the size of the result. For int type, int the maximum certain value is 12!, for the long type it is 20! For values 171! and more, even an approximate value cannot be obtained using double. Using BigInteger and BigDecimal allow you to get factorials of large numbers. The size of the result is actually limited by the display capabilities of the console window.

The following program calculates the factorial of integers using different types: long, double, BigInteger and 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));
    }

}

The execution of the program for large values of n takes a long time.

Manual testing is not enough to verify the certainty of the results. To test these functions, we can use the capabilities of the JUnit library. In advance, we need to create a separate test folder and mark it as the root of the tests (Mark Directory as | Test Sources Root function of the context menu). Next, you can generate tests: the Code | Generate... | Test... function of main menu. In the dialog box, select the functions for which tests should be created. In our case, these are all functions except main(). The following code will be generated:

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

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

class FactorialTest {

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

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

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

Errors related to the use of JUnit tools are corrected according to the instructions that were described above.

To test the factorialDouble() function, it is necessary to add an auxiliary compareDoubles() function, as well as to create constants for some factorial values. The code of the FactorialTest.java file will be as follows:

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

import java.math.BigDecimal;

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

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

    public static final BigDecimal EPS = BigDecimal.ONE;

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

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

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

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

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

}

The values of 50! and 500! were obtained from the page https://zeptomath.com/calculators/factorial.php and are used as a reference in our tests.

As expected, the correct values of 50! and 500! can be obtained only using BigInteger and BigDecimal.

3.2 Obtaining a Table of Prime Numbers using Data Streams

The following program allows you to get a table of prime numbers in a given range. To obtain simple numbers, it is advisable to use 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);
    }
}

The isPrime() method checks whether the number n is prime. For numbers greater than 1, a set of consecutive integers is formed, for each of which it is checked whether n is divisible by this number. In the printAllPrimes() method, we form a stream of simple numbers using a filter and output the numbers using the forEach() method.

3.3 "Country" and "Census" Classes

In the examples from the "Fundamentals of Java Programming" course, class hierarchies to represent country and population censuses were considered. In laboratory training # 3 of this course, an abstract class AbstractCountry was created, as well as concrete classes Census and CountryWithArray. Next, in laboratory training # 3, classes CountryWithList and CountryWithSet were created. And finally, in laboratory training # 5, an abstract class CountryWithFile was created, as well as concrete classes CountryWithTextFile and CountryWithDataFile.

Now, using the CountryWithList and Census classes, we will create an application that reproduces the search and sorting implemented in the examples of the specified labs through the use of the Stream API.

We can create a new package: ua.inf.iwanoff.java.advanced.third. We add CensusWithStreams class to the package. It is advisable to create constructors and override the containsWord() method, implementing it with the help of streams. For example, the class code could be as follows:

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

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

/**
 * The class is responsible for presenting the census.
* Stream API tools are used to process the sequence of words */ public class CensusWithStreams extends Census { /** * The constructor initializes the object with default values */ public CensusWithStreams() { } /** * The constructor initializes the object with the specified values * * @param year census year * @param population the population * @param comments comment text */ public CensusWithStreams(int year, int population, String comments) { setYear(year); setPopulation(population); setComments(comments); } /** * Checks either the word is contained in the text of the comment * * @param word the word we're looking for in the comment * @return {@code true} if the word is in the comment text * {@code false} otherwise */ @Override public boolean containsWord(String word) { return Arrays.stream(getComments().split("\s")).anyMatch(s -> s.equalsIgnoreCase(word)); } }

It is also possible to define main() function to test class, but a better approach is to use the capabilities of unit testing (JUnit).

In advance, create a folder test in the root of the project and mark it as the root of the tests (Mark Directory as | Test Sources Root function of the context menu). In the code window, we select the name of the class and using the context menu Generate... | Test... select the functions for which test methods should be generated. In our case, this is the containsWord() method. IntelliJ IDEA automatically generates all necessary parallel packages of the test branch and creates the class called CensusWithStreamsTest. It looks like this:

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

import org.junit.jupiter.api.Test;

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

class CensusWithStreamsTest {

    @Test
    void containsWord() {
    }
}

If errors are highlighted in the generated code, we correct them as described above.

Now we can add the necessary testing, which partially reproduces the behavior of the testWord() method of the Census class. The code of the file CensusWithStreamsTest.java will be as follows:

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

import org.junit.jupiter.api.Test;

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

class CensusWithStreamsTest {

    @Test
    void containsWord() {
        CensusWithStreams census = new CensusWithStreams();
        census.setComments("The first census in independent Ukraine");
        assertTrue(census.containsWord("Ukraine"));
        assertTrue(census.containsWord("FIRST"));
        assertFalse(census.containsWord("rain"));
        assertFalse(census.containsWord("censuses"));
    }
}

After completing the tests, we will receive a successful exit code. If the expected results are changed in the code, the tests will throw an exception and the assertion that failed will be underlined in the code.

In the class that is responsible for the country, we can also override all methods through the use of threads. There are two ways for creating a new class:

  • derived class from CountryWithArray;
  • derived class from CountryWithList.

The advantages of the second way are in working with a ArrayList, which is more convenient compared to a regular array, and even more effective in the case of adding new elements. The disadvantage of the second way compared to the first one is the need to provide direct access to the list, which in our case involves adding methods to the CountryWithList class that was created earlier. Making changes to the base classes is generally not desirable, but if you must do so, you should not change the set of public methods of the class.

Choosing the second way, we should keep the set of public methods unchanged. To ensure this, we can add protected methods to the CountryWithList class:

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

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

In addition to the sortByPopulation(), sortByComments() and maxYear() methods that work with the sequence, the methods for accessing the list should be overridden, since it should be monitored so that only references of type CensusWithStreams can be put into list. These methods are setCensus(), addCensus() in two variants and setCensuses(). The source code of the CountryWithStreams class will be as follows:

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

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

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

/**
 * A class to represent the country in which the census is conducted.
* Stream API tools are used to process the sequences */ public class CountryWithStreams extends CountryWithList { /** * Sets a reference to the new census inside the sequence position * by the indicated index. * * @param i number (index) of the position in the sequence * @param census reference to the new census */ @Override public void setCensus(int i, Census census) { if (census instanceof CensusWithStreams) { super.setCensus(i, census); } else { new RuntimeException(); } } /** * Adds a reference to the new census at the end of the sequence * * @param census reference to the new census * @return {@code true} if the reference was successfully added * {@code false} otherwise */ @Override public boolean addCensus(Census census) { if (census instanceof CensusWithStreams) { return super.addCensus(census); } return false; } /** * Adds a reference to the new census at the end of the sequence. * * @param year census year * @param population the number of the population * @param comments comment text * @return {@code true} if the reference was successfully added * {@code false} otherwise */ @Override public boolean addCensus(int year, int population, String comments) { return super.addCensus(new CensusWithStreams(year, population, comments)); } /** * Rewrites data from an array of censuses to a sequence * * @param censuses an array of censuses */ @Override public void setCensuses(Census[] censuses) { if (Arrays.stream(censuses).allMatch(c -> c instanceof CensusWithStreams)) { super.setCensuses(censuses); } else { new RuntimeException(); } } /** * Sorts the sequence of censuses by population */ @Override public void sortByPopulation() { setList(getList().stream().sorted().toList()); } /** * Sorts the sequence of censuses alphabetically by comment */ @Override public void sortByComments() { setList(getList().stream().sorted(Comparator.comparing(Census::getComments)).toList()); } /** * Finds and returns the year with the maximum population * * @return year with maximum population */ @Override public int maxYear() { return getList().stream().max(Comparator.comparing(Census::getPopulation)).get().getYear(); } /** * Creates and returns an array of censuses with the specified word in comments * * @param word the word to search for * @return an array of records with the specified word in comments */ @Override public Census[] findWord(String word) { return getList().stream().filter(c -> c.containsWord(word)).toArray(Census[]::new); } /** * Creates and returns a list of stings with data * about the country and about all population censuses * * @return a list of strings with country data */ public List<String> toListOfStrings() { ArrayList<String> list = new ArrayList<>(); list.add(getName() + " " + getArea()); Arrays.stream(getCensuses()).forEach(c -> list.add( c.getYear() + " " + c.getPopulation() + " " + c.getComments())); return list; } /** * Reads data about the country from the list of strings and puts data into the appropriate fields * * @param list a list of strings with country data */ public void fromListOfStrings(List<String> list) { String[] words = list.get(0).split("\s"); setName(words[0]); setArea(Double.parseDouble(words[1])); list.remove(0); list.stream().forEach(s -> { String[] line = s.split("\s"); addCensus(Integer.parseInt(line[0]), Integer.parseInt(line[1]), s.substring(s.indexOf(line[2]))); }); } /** * Creates and returns an object of type CountryWithStreams for testing * @return an object of type CountryWithStreams */ public static CountryWithStreams createCountryWithStreams() { CountryWithStreams country = new CountryWithStreams(); country.setName("Ukraine"); country.setArea(603628); country.addCensus(1959, 41869000, "The first postwar census"); country.addCensus(1970, 47126500, "Population increases"); country.addCensus(1979, 49754600, "No comments"); country.addCensus(1989, 51706700, "The last soviet census"); country.addCensus(2001, 48475100, "The first census in the independent Ukraine"); return country; } }

The given code uses a function call: toArray(Census[]::new). This ensures that an array of the required type (references to Census) is created, rather than an array of references to Object, which is returned by the corresponding function without parameters.

We add a CountryWithStreams class for testing. This is done in the same way as for CensusWithStreams. It is advisable to choose methods sortByPopulation(), sortByComments(), maxYear() and findWord(). We will get the following code:

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

import org.junit.jupiter.api.Test;

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

class CountryWithStreamsTest {

    @Test
    void sortByPopulation() {
    }

    @Test
    void sortByComments() {
    }

    @Test
    void maxYear() {
    }

    @Test
    void findWord() {
    }
}

Since it is necessary to perform several tests on the object and these tests must be independent, it is advisable to create the object before executing each test method. For this, we need to add a method with @BeforeEach annotation. The corresponding method can also be generated automatically (Generate... | SetUp Method in the context menu). We create a new country object in the method called setUp(). We create the appropriate field of type CountryWithStreams manually. The object will be used in test methods.

For convenient testing of functions related to searching and sorting, we can create getYears() function that retrieves an array of years from an array of censuses. This static function will use a static variable index to fill specific array items. The variable cannot be local because we are using it in a lambda expression. We get the following code:

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

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

import java.util.Arrays;

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

class CountryWithStreamsTest {
    private CountryWithStreams country;

    static int index;

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

    @BeforeEach
    void setUp() {
        country = new CountryWithStreams();
        country.addCensus(1959, 41869000, "The first postwar census");
        country.addCensus(1970, 47126500, "Population increases");
        country.addCensus(1979, 49754600, "No comments");
        country.addCensus(1989, 51706700, "The last soviet census");
        country.addCensus(2001, 48475100, "The first census in the independent Ukraine");
    }

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

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

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

    @Test
    void findWord() {
        assertArrayEquals(getYears(country.findWord("census")), new int[] { 1959, 1979, 1989, 2001 });
    }
}

Arrays of years corresponding to correct sorting and searching results were manually prepared.

We test the program in the main() function of the Program class:

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

/**
 * The class demonstrates data processing using the StreamAPI
 */
public class Program {

    /**
     * Demonstration of the program.
     * @param args command line arguments (not used)
     */
    public static void main(String[] args) {
        createCountryWithStreams().createCountry().testCountry();
    }

}

4 Exercises

  1. Implement the function of obtaining the integer part of the square root of a number of type BigInteger.
  2. Initialize a list of real numbers of BigDecimal type with an array containing a list of initial values. Find the sum of positive elements.
  3. In the array of integer values (BigInteger), replace negative values with modules, positive values with zeros. Apply Stream API facilities.

5 Quiz

  1. Name the main components of the Java SE platform.
  2. What are classes BigInteger and BigDecimal?
  3. How can you create a type number BigInteger?
  4. What is the difference between internal representation of double and BigDecimal?
  5. Is it possible to apply mathematical operations to numbers of types BigInteger and BigDecimal?
  6. What is the MathContext class used for?
  7. What is JCF? What standard interfaces does JCF provide?
  8. How to create a read-only collection?
  9. What algorithms does the class provide Collections class?
  10. What are the advantages and features of Stream API?
  11. How to get a stream from a collection?
  12. How to get a stream from an array?
  13. What is the difference between intermediate and terminal operations?
  14. What are the streams for working with primitive types?
  15. What are the standard means of checking assertions in Java?
  16. What is unit testing?
  17. What is JUnit?
  18. How are test methods annotated in JUnit?
  19. How to make a logical grouping of tests?
  20. How to use JUnit in a development environment?

 

up