API, ради которых наконец-то стоит обновиться с Java 8. Часть 2

Продолжаем рассказ про API, которые появились в новых версиях Java.

1. Files.mismatch()

Появился в: Java 12

На практике довольно часто возникает необходимость проверить, являются ли два файла в точности одинаковыми или нет. С помощью метода Files.mismatch(), появившегося в Java 12, это наконец-то можно сделать. Этот метод возвращает позицию первого несовпадающего байта в двух файлах или -1, если файлы идентичны.

Это может быть полезно, например, когда синхронизируешь содержимое двух директорий. Чтобы не перезаписывать файл при копировании тем же самым содержимым и лишний раз не нагружать диск, можно сначала проверить, идентичны файлы или нет:

public static void syncDirs(Path srcDir, Path dstDir)
        throws IOException {
    // Для простоты демонстрации считаем, что поддиректорий нет
    try (Stream<Path> stream = Files.list(srcDir)) {
        for (Path src : stream.collect(toList())) {
            Path dst = dstDir.resolve(src.getFileName());
            if (!Files.exists(dst)) {
                System.out.println("Copying file " + dst);
                Files.copy(src, dst);
            } else if (Files.mismatch(src, dst) >= 0) {
                System.out.println("Overwriting file " + dst);
                Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
            }
        }
    }
}

(Кстати, когда уже наконец Stream отнаследуют от Iterable? Хочется просто писать for (Path file : stream), а не возиться с промежуточными списками.)

2. Новые методы в java.time

Появились в: Java 9

В Java почти 20 лет не было нормального API для работы с датами и временем. Эту проблему решили лишь в Java 8, когда ввели новый пакет java.time под руководством небезызвестного Стивена Колборна, создателя библиотеки Joda Time. А в девятой версии java.time добавили множество интересных методов.

В Java 8 Duration нельзя просто разбить на составляющие (например, прошло 2 дня, 7 часов, 15 минут, 12 секунд). В Java 9 для этого появились методы toDaysPart(), toHoursPart(), toMinutesPart(), toSecondsPart() и т.д. Пример:

public static String modifiedAgo(Path path) throws IOException {
    FileTime time = Files.getLastModifiedTime(path);
    Instant to = Instant.now();
    Instant from = time.toInstant();
    Duration d = Duration.between(from, to);
    return String.format(
        "Файл был изменён %d дней, %d часов, %d минут, %d секунд назад",
        d.toDaysPart(), d.toHoursPart(),
        d.toMinutesPart(), d.toSecondsPart());
}

А что если нам надо узнать, сколько месяцев назад был изменён файл? Элегантного способа на Java 8, насколько мне известно, нет. А в Java 9 для этого можно использовать новый метод Duration.dividedBy():

public static long modifiedAgo(Path path, ChronoUnit unit)
        throws IOException {
    FileTime time = Files.getLastModifiedTime(path);
    Instant to = Instant.now();
    Instant from = time.toInstant();
    Duration d = Duration.between(from, to);
    return d.dividedBy(unit.getDuration());
}

public static void main(String[] args) throws Exception {
    Path path = ...
    System.out.printf("Файл был изменён %d месяцев назад%n",
        modifiedAgo(path, ChronoUnit.MONTHS));
}

Нововведения коснулись также класса LocalDate. С помощью метода LocalDate.ofInstant() можно сконвертировать Instant в LocalDate:

LocalDate date = LocalDate.ofInstant(
    Instant.now(), ZoneId.systemDefault());
System.out.println(date);

А используя новый метод LocalDate.datesUntil(), наконец-то можно легко получить Stream всех дат в интервале между двумя датами:

LocalDate from = LocalDate.of(2020, 1, 1);
LocalDate to = LocalDate.of(2020, 1, 9);
from.datesUntil(to)
    .forEach(System.out::println);

Вывод:

2020-01-01
2020-01-02
2020-01-03
2020-01-04
2020-01-05
2020-01-06
2020-01-07
2020-01-08

Также есть перегрузка, где можно указать период:

LocalDate from = LocalDate.of(2020, 1, 1);
LocalDate to = LocalDate.of(2020, 1, 31);
from.datesUntil(to, Period.ofWeeks(1))
    .forEach(System.out::println);

Вывод:

2020-01-01
2020-01-08
2020-01-15
2020-01-22
2020-01-29

Остальные методы:

3. Collection.toArray() с функцией-генератором

Появился в: Java 11

С конвертацией коллекций в массивы у Java была непростая история. С момента появления Collection в Java 1.2 было два способа создания массива на основе коллекции:

  • Использовать метод Collection.toArray(), который возвращает Object[].
  • Использовать метод Collection.toArray(Object[]), который принимает уже созданный массив и заполняет его. Если переданный массив недостаточной длины, то создаётся новый массив нужной длины того же типа и возвращается. С появлением дженериков в Java 1.5 метод логичным образом поменял свою сигнатуру на Collection.toArray(T[]).

Загвоздка в том, что если нужен массив конкретного типа (допустим String[]), второй метод можно использовать двумя способами:

  • Использовать конструкцию collection.toArray(new String[0]). Тем самым, мы сознательно почти всегда отбрасываем массив, а передаём его туда, чтобы метод узнал тип массива.
  • Использовать конструкцию collection.toArray(new String[collection.size()]). В этом случае массив передаётся нужной длины, а значит ничего зря не отбрасывается, и код по идее работает быстрее. К тому же здесь не нужен рефлективный вызов.

Таким образом, второй вариант долгое время считался основным, и в IntelliJ IDEA даже была инспекция, которая подсвечивала первый вариант и предлагала конвертировать его во второй, более эффективный.

Однако в 2016 году вышла статья Алексея Шипилёва, где он решил досконально разобраться в этом вопросе и пришёл к выводу, что не-а: первый вариант всё-таки быстрее (по крайней мере в версиях JDK 6+). Эта статья получила большой резонанс, и в IDEA решили изменить инспекцию, сделав у неё три опции: предпочитать пустой массив (default), предпочитать преаллоцированный массив или предпочитать то или иное в зависимости от версии Java.

Но история на этом не закончилась, потому что некоторые программисты принципиально не желали использовать эти хаки с пустыми массивами и хотели писать код "элегантно". Поэтому они вспомнили про Stream.toArray(IntFunction[]) и стали писать collection.stream().toArray(String[]::new). Медленно? Ну и что, зато красиво.

Программисты из Oracle посмотрели на всё это безобразие и подумали: а давайте уже сделаем один нормальный способ, который и будет рекомендованным? И в Java 11 добавили долгожданный метод Collection.toArray(IntFunction[]), тем самым запутав людей ещё сильнее.

Но на самом деле никакой путаницы нет. Да, теперь есть 4 варианта, но если вы не выжимаете такты из своего процессора, то вам следует просто использовать новый метод:

List<Integer> list = ...;
Integer[] array = list.toArray(Integer[]::new);

4. Методы InputStream: readNBytes(), readAllBytes(), transferTo()

Появились в: Java 9 / Java 11

Ещё одно неудобство, которое существовало в Java долгие годы – отсутствие стандартного короткого способа считать все данные из InputStream. Если не прибегать к библиотекам, то в Java 8 решить такую задачу довольно нетривиально: нужно завести список буферов, заполнять их, пока данные не кончатся, потом слить в один большой массив, учесть, что последний буфер заполнен лишь частично и т.д. Короче, нюансов хватает.

В Java 9 добавили метод InputStream.readAllBytes(), который берёт всю эту работу на себя и возвращает заполненный массив байтов точной длины. Например, прочитать stdout/stderr процесса теперь очень легко:

Process proc = Runtime.getRuntime().exec("java -version");
try (InputStream inputStream = proc.getErrorStream()) {
    byte[] bytes = inputStream.readAllBytes();
    System.out.print(new String(bytes));
}

Вывод:

openjdk version "14-ea" 2020-03-17
OpenJDK Runtime Environment (build 14-ea+33-1439)
OpenJDK 64-Bit Server VM (build 14-ea+33-1439, mixed mode, sharing)

Также если надо прочитать только N байтов, то можно использовать метод из Java 11 InputStream.readNBytes().

Если же надо легко и эффективно (без промежуточного массива) перенаправить InputStream в OutputStream, то можно использовать InputStream.transferTo(). Например, для вывода версии Java в файл код будет выглядеть примерно так:

Process proc = Runtime.getRuntime().exec("java -version");
Path path = Path.of("out.txt");
try (InputStream inputStream = proc.getErrorStream();
     OutputStream outputStream = Files.newOutputStream(path)) {
    inputStream.transferTo(outputStream);
}

Кстати, перенаправить Reader во Writer теперь тоже можно: с помощью метода Reader.transferTo(), появившегося в Java 10.

5. Collectors.teeing()

Появился в: Java 12

При использовании Stream часто возникает необходимость собрать элементы в два коллектора. Допустим, у нас есть Stream из Employee, и нужно узнать:

  • Сколько всего сотрудников в Stream.
  • Сколько сотрудников, у которых есть телефонный номер.

Как это сделать в Java 8? Первое, что приходит в голову: сначала позвать Stream.count(), а потом Stream.filter() и Stream.count(). Однако это не сработает, потому что Stream является одноразовым и второй вызов выбросит исключение.

Второй вариант – завести два счётчика и увеличивать их внутри Stream.forEach():

Stream<Employee> employees = ...
int[] countWithPhoneAndTotal = {0, 0};
employees
    .forEach(emp -> {
        if (emp.getPhoneNumber() != null) {
            countWithPhoneAndTotal[0]++;
        }
        countWithPhoneAndTotal[1]++;
    });
System.out.println("Employees with phone number: "
    + countWithPhoneAndTotal[0]);
System.out.println("Total employees: "
    + countWithPhoneAndTotal[1]);

В принципе, это работает, но это императивный подход, который плохо переносится на другие виды коллекторов. Stream.peek() плох по той же причине.

Ещё есть идея использовать Stream.reduce():

class CountWithPhoneAndTotal {
    final int withPhone;
    final int total;

    CountWithPhoneAndTotal(int withPhone, int total) {
        this.withPhone = withPhone;
        this.total = total;
    }
}

CountWithPhoneAndTotal countWithPhoneAndTotal = employees
    .reduce(
        new CountWithPhoneAndTotal(0, 0),
        (count, employee) -> new CountWithPhoneAndTotal(
            employee.getPhoneNumber() != null
                ? count.withPhone + 1
                : count.withPhone,
            count.total + 1),
        (count1, count2) -> new CountWithPhoneAndTotal(
            count1.withPhone + count2.withPhone,
            count1.total + count2.total));
System.out.println("Employees with phone number: "
    + countWithPhoneAndTotal.withPhone);
System.out.println("Total employees: "
    + countWithPhoneAndTotal.total);

Этот вариант, конечно же, кошмар. Во-первых, он слишком огромный, во-вторых, неэффективный, так как на каждом шагу создаётся новый экземпляр CountWithPhoneAndTotal. Если когда-нибудь доделают Валгаллу, то можно будет пометить класс CountWithPhoneAndTotal как inline, но первая проблема всё равно останется.

На этом мои идеи закончились. Если вдруг кто-то придумает, как сделать такой подсчёт в Java 8 коротким и эффективным, то напишите в комментариях. А я расскажу, как это можно сделать в Java 12 с помощью метода Collectors.teeing():

Entry<Long, Long> countWithPhoneAndTotal = employees
    .collect(teeing(
        filtering(employee -> employee.getPhoneNumber() != null, counting()),
        counting(),
        Map::entry
    ));

И всё.

С методом Collectors.teeing() была очень интересная история: когда ему придумывали имя, то долго не могли прийти к консенсусу из-за огромного количество предложенных вариантов. Чего там только не было: toBoth, collectingToBoth, collectingToBothAndThen, pairing, bifurcate, distributing, unzipping, forking, ... В итоге его назвали teeing от английского слова tee, которое само произошло от буквы T, напоминающую по форме раздваиватель. В этом и есть суть имени метода: он раздваивает поток на две части.

6. Runtime.version()

Появился в: Java 9

Иногда нужно узнать версию Java во время выполнения. Помните ли вы, как это сделать? Скорее всего, вы полезете искать название нужного свойства в интернете. Возможно некоторые вспомнят, что оно называется java.version. А ещё вроде бы есть java.specification.version... На самом деле, таких свойств как минимум пять:

for (String key : Arrays.asList(
        "java.version",
        "java.runtime.version",
        "java.specification.version",
        "java.vm.version",
        "java.vm.specification.version")) {
    System.out.println(key + " = " + System.getProperty(key));
}

Если запустить код на Java 8, то он выведет примерно следующее:

java.version = 1.8.0_192
java.runtime.version = 1.8.0_192-b12
java.specification.version = 1.8
java.vm.version = 25.192-b12
java.vm.specification.version = 1.8

Как отсюда вытащить цифру 8? Наверное, надо взять java.specification.version, отбросить 1., потом сконвертировать строку в число... Но не торопитесь, потому что на Java 9 это всё сломается:

java.version = 9.0.1
java.runtime.version = 9.0.1+11
java.specification.version = 9
java.vm.version = 9.0.1+11
java.vm.specification.version = 9

Однако не печальтесь, потому что в Java 9 появилось нормальное API для получения версий и было немного допилено в Java 10. С этим API больше не нужно ничего «вытаскивать» и парсить, а можно просто позвать метод Runtime.version(). Этот метод возвращает объект типа Runtime.Version, у которого можно запросить все нужные части версии:

Runtime.Version version = Runtime.version();
System.out.println("Feature = " + version.feature());
System.out.println("Interim = " + version.interim());
System.out.println("Update = " + version.update());
System.out.println("Patch = " + version.patch());

Например, вот что он вернёт, если его позвать на JDK 11.0.5:

Feature = 11
Interim = 0
Update = 5
Patch = 0

7. Optional.isEmpty()

Появился в: Java 11

Я не стану утверждать, что этот метод изменит вашу жизнь радикальным образом, но всё же в некоторых случаях он сможет избавить вас от ненужных отрицаний:

if (!stream.findAny().isPresent()) {
    System.out.println("Stream is empty");
}

Используя метод Optional.isEmpty(), код можно немножко упростить:

if (stream.findAny().isEmpty()) {
    System.out.println("Stream is empty");
}

Также этот метод позволяет заменить лямбды на ссылки на методы в некоторых случаях:

Stream<Optional<Integer>> stream = Stream.of(
    Optional.of(1),
    Optional.empty(),
    Optional.of(2));

long emptyCount = stream
    .filter(Optional::isEmpty) // Было opt -> !opt.isPresent()
    .count();

8. HTTP-клиент

Появился в: Java 11

Долгое время единственным API для клиентского HTTP был класс HttpURLConnection, который существовал в Java практически с момента её появления. Спустя два десятилетия стало очевидно, что он больше не отвечает современным требованиям: он неудобен в использовании, не поддерживает HTTP/2 и веб-сокеты, работает только в блокирующем режиме, а ещё его очень трудно поддерживать. Поэтому было принятое решение создать новый HTTP Client, который попал в Java 9 в качестве инкубационного модуля, а позже был стандартизован в Java 11.

Новый клиент находится в модуле java.net.http, и его использование осуществляется через главный класс HttpClient. Приведём пример, как можно сделать простой HTTP-запрос с сайта и получить содержимое страницы с кодом ответа:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest
    .newBuilder(new URI("https://minijug.ru"))
    .build();

HttpResponse<Stream<String>> response = client.send(request,
    HttpResponse.BodyHandlers.ofLines());

System.out.println("Status code = " + response.statusCode());
System.out.println("Body = ");
// Первые 4 строки
response.body().limit(4).forEach(System.out::println);

Вывод:

Status code = 200
Body =
<!doctype html>
<html>
  <head>
    <title>miniJUG</title>

В модуле java.net.http большое количество возможностей, и на их описание уйдёт много времени, поэтому сегодня мы ограничимся только примером выше.

9. Lookup.defineClass()

Появился в: Java 9

Приходилось ли вам загружать классы во время выполнения? Если да, вы наверняка знаете, что в Java 8 без нового загрузчика класса это сделать нельзя. Ну или ещё можно использовать Unsafe.defineClass() или Unsafe.defineAnonymousClass(), но это нестандартное API, которое крайне не рекомендуется использовать.

Однако есть хорошая новость: если вам нужно загрузить класс в том же пакете, не создавая новый загрузчик класса, то для этого можно использовать стандартный метод MethodHandles.Lookup.defineClass(), который появился в Java 9. Этому методу достаточно передать массив байтов класса:

// Main.java
import java.lang.invoke.MethodHandles;
import java.nio.file.Files;
import java.nio.file.Path;

public class Main {
    public static void main(String[] args) throws Exception {
        byte[] bytes = Files.readAllBytes(Path.of("Temp.class"));
        Class<?> clazz = MethodHandles.lookup().defineClass(bytes);
        Object obj = clazz.getDeclaredConstructor().newInstance();
        System.out.println(obj);
    }
}
// Temp.java
class Temp {
    @Override
    public String toString() {
        return "Hello from Temp!";
    }
}

Теперь скомпилируем класс Temp, а затем скомпилируем и запустим класс Main

> javac Temp.java

> javac Main.java

> java Main
Hello from Temp!

Повторюсь, чтобы это сработало, класс Temp и класс Main должны находиться в одном пакете (в данном случае они оба находятся в дефолтном пакете, поэтому всё хорошо). Если класс Temp будет находиться в другом пакете, то понадобится завести специальный класс-делегат в том же пакете, что и Temp, и осуществлять загрузку через него.

Да, пример выше совсем простой, но это сделано исключительно для краткости и простоты демонстрации. Так как defineClass() принимает массив байтов, то загружать класс можно откуда угодно, а не только с файловой системы. Можно даже загрузить класс, скомпилированный в память во время исполнения. Для этого можно использовать ToolProvider.getSystemJavaCompiler(), который находится в модуле java.compiler (конкретную реализацию я оставлю в качестве упражнения для читателя).

10. ByteArrayOutputStream.writeBytes()

Появился в: Java 11

Метод ByteArrayOutputStream.writeBytes() – это дублёр метода ByteArrayOutputStream.write() с одним важным отличием: в сигнатуре write() есть throws IOException, а в сигнатуре writeBytes() – нету (IOException есть во write(), потому что этот метод наследуется от OutputStream). Это значит, что начиная с Java 11, использование ByteArrayOutputStream становится немножко проще:

private static byte[] concat(Stream<byte[]> stream) {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    // stream.forEach(out::write); (Не скомпилируется)
    stream.forEach(out::writeBytes);
    return out.toByteArray();
}

Бонус: конструктор IndexOutOfBoundsException(int)

Появился в: Java 9

Сегодняшний рассказ хочу завершить мелким улучшением в Java 9: если вам надо выбросить IndexOutOfBoundsException с указанием неправильного индекса, то теперь можно просто передать этот индекс в конструктор, и он сам сгенерирует сообщение:

private static void doAtIndex(int index) {
    if (index < 0) {
        throw new IndexOutOfBoundsException(index);
    }
    // ...
}

public static void main(String[] args) {
    // java.lang.IndexOutOfBoundsException: Index out of range: -1
    doAtIndex(-1);
}

Заключение

Итак, мы рассмотрели ещё 10 (+1) новых API, которые появились в новых версиях Java. Всё ещё не хотите обновляться? Если нет, то тогда ждите следующую часть.

Подписывайтесь на канал в Telegram, чтобы не пропускать новости.

Все материалы на этом сайте выложены под лицензией CC BY-SA 4.0
© Евгений Козлов, 2017-2020