Java. Решение практических задач

c2f4fe96f9830914d6be3c8594268bb8.png

Книга Анджела Леонарда позиционируется как каталог типовых решений для Java разработчиков младшего и среднего уровней. Заявляется, что представленные решения производительны, корректны и поддерживаемы.

В книге все разбито на «задачи». Они тут нескольких типов:

  • Алгоритмические задачи — 10%

  • Структуры и алгоритмы — 5%

  • Теория языка Java — 10%

  • Стандартная библиотека Java — 50%

  • Практические приёмы — 25%

Алгоритмические задачи здесь начального уровня. Их мало. Но самое интересное — это предлагаемые решения. Автор пишет, что книга поможет укладываться в сроки. В этом она похожа на рецепты со https://stackoverflow.com/ с ровно тем же результатом: работать может и будет, но в большинстве случаев так делать не стоит. Нет, я, конечно, больше за экономическую целесообразность и согласен с цитатой:

Преждевременная оптимизация — корень всех зол.

— Д.Э. Кнут

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

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

По поводу удобочитаемости тоже можно было бы поспорить, но это, в конце концов, дело вкуса. Но вот неэффективность никуда не денешь. Например, в задаче отыскания самого часто встречающегося символа предлагается в карте символ→количество искать максимум через Collections.max() для values(), а потом ещё циклом пройтись по entrySet() и поискать какой же getKey() соответствует значению, которое вернула Collections.max().

Автор часто прибегает к регулярным выражениям там, где они совершенно излишни. И даже не предлагает их предварительно компилировать, а все время норовит вызвать replaceAll() или split() прямо внутри тела цикла. Опять же, можно было бы поспорить, что split() оптимизирована для случая односимвольного разделителя и не будет для них применять регулярные выражения. Однако, автор про это ни разу не упоминает. Более того, автор регулярно напоминает использовать Pattern.quote() при передаче параметра в split(), а значит он подразумевает именно регулярные выражения. Но даже если опираться на оптимизацию split() для односимвольных разделителей, то можно видеть, что внутри у нас ArrayList из которого данные копируются в новый массив, а автор после чуть ли не каждого split() предлагает конвертировать массив в список через Arrays.asList(). Копирования и мусор в куче, который надо будет собирать, автор просто игнорирует. StringTokenizer, по утверждению автора, обладает меньшей производительностью и из-за этого его не рекомендуется использовать. Нет,  StringTokenizer, рассчитанный на Unicode code pointы и несколько разделителей, действительно медленнее в плане обработки строки по сравнению со split() при односимвольном разделителе (за вычетом дополнительного мусора и копирований). Но автор в своей сноске явным образом говорит про гибкость регулярных выражений. Да, в Javadoc на StringTokenizer есть рекомендация по использованию split(), но там нет ничего про производительность. На самом деле речь идет о том, что StringTokenizer — это Enumeration, а не Collection и значит придётся писать явный цикл для обхода, копирования и тому подобного. Не более.

StringTokenizer tokenizer = new StringTokenizer("abc, def, xyz", ";, ");
List tokens = new ArrayList<>(tokenizer.countTokens());
while (tokenizer.hasMoreTokens()) {
    tokens.add(tokenizer.nextToken());
}

Ну или тупо Collections.list(), если лень или повторное сканирование перевешивает динамический рост списка. Если хочется красоты и однострочности, то начиная с Java 9 можно сделать .asIterator().forEachRemaining() и оно ничем не будет отличаться от цикла for-each.

new StringTokenizer("abc, def, xyz", ";, ")
        .asIterator().forEachRemaining(System.out::println);

Вообще автор везде сует потоки с цепочками вызовов заканчивающимися collect(Collectors.toList()) и ни разу не упоминает, что список будет динамически расти, что данные будут копироваться, что мусор потом придется собирать.

Можно опять поспорить, что это копейки и на сложность алгоритма в целом не влияет. Плюс/минус 5–10% (кто же будет профилировать?) — не высокая цена за скорость разработки. Пусть так. Соглашусь. Часто важнее скорость разработки, а не скорость работы. Но тут же можно найти рецепт как скопировать объект через JSON:

  1. Берем исходный объект;

  2. Сериализуем его в JSON;

  3. Парсим получившийся текст;

  4. Копия объекта готова!

И это не прикол, а целая «задача» с детальным описанием рецепта и примером кода. Бери и копипасти. И ни слова о том, на сколько это будет быстро.

Часто говорят, что Java — это тормоза и излишнее потребление памяти. Есть мнение, что проблема не в Java, а в том как на Java пишут. Так вот автор не сильно старался сделать решения эффективными по времени или потреблению памяти.

Про структуры и алгоритмы в книге откровенно мало. В стандартной библиотеке конечно много всего готового и свое реализовывать надо не так уж и часто. Но можно было бы рассказать про варианты представления деревьев и графов с использованием инструментария Java. Дерево Фенвика и фильтр Блума здесь рассмотрены чисто для галочки.

Много «задач», в которых рассказывается о самом языке Java. В основном речь идет о нововведениях после Java 8:

  • Новые выражения switch;

  • Автоматический тип локальных переменных и var;

  • Функциональные интерфейсы и лямбда-выражения.

С одной стороны это все полезно. Даже не новичку. Особенно когда подается в виде практических рецептов. Но есть проблемы. Например, автор утверждает, что автоматические типы улучшают сопровождаемость кода. Приводится пример, когда локальная переменная — результат возврата функции, функцию позже поменяли и автоматическая переменная волшебным образом изменила свой тип. В коде ничего менять не надо и все счастливы. Другой пример — использование автоматического типа для «исправления» непонимания программистом того, какой же тип вернет тернарный оператор. Ну или предложение прятать тип элемента в цикле for-each. Я бы еще мог понять, если бы речь шла о длинных вложенных обобщенных типах:

Map headers = new HashMap<>();
for (Map.Entry entry : headers.entrySet()) {

}

Тогда хоть какой-то смысл появляется:

Map headers = new HashMap<>();
for (var entry : headers.entrySet()) {

}

Но автор говорит о другом. О том, что можно менять тип элементов коллекции (с int на String) и не заморачиваться изменением кода. Сразу вспоминаешь, что Groovy ругают за def.

Также сомнительны рецепты с заменой классических шаблонов на лямбды. Смысл ведь был в инкапсуляции и полиморфизме, а тут все кишки наружу. Нет, кодить конечно быстрее, но будет ли экономия, когда придётся выбросить и переписать с нуля?

Не могу согласиться с рецептами тестирования лямбд. Тестирование — это проверка соответствия контракту. Какой контракт у лямбда-выражения? Аналогично с отладкой путем System.out.println(). Кто, как и когда будет всё это потом вычищать? А ведь будут последствия. Вместо навязчивой рекламы лямбда-выражений и рекомендаций по запихиванию их всегда и везде стоило бы описать где и когда их применять не стоит и как и на что их надо заменить. С примерами.

Стандартная библиотека — это основная часть книги. Масса рассказов, что есть вот такой-то класс или метод, полезный тем и этим. Много и более практических рецептов: если стоит такая-то задача, то решается она так-то. В принципе, все эти практические рецепты работы с датами, коллекциями, файлами, потоками, интроспекцией, многопоточностью, инструментами синхронизации, клиентом HTTP и WebSocket полезны. Особенно, если не штудировал Javadoc и не читал Core Java в двух томах.

Но в бочке мёда снова не обошлось без кастрюли дёгтя ибо есть масса неточностей и неверностей. Например, рекомендуется локальную дату конвертировать в UTC, используя нулевой сдвиг. Работать это будет только в городе Гринвич.

LocalDateTime local = LocalDateTime.now();
Instant suggested = local.toInstant(ZoneOffset.UTC);
Instant correct = local.atZone(ZoneId.systemDefault()).toInstant();

Другой пример, когда автор утверждает, что List.replaceAll() и цикл с List.set(i,v) «должны работать практически одинаково». Для ArrayList это может и так, но есть и другие списки. Нет, работать LinkedList.set(i,v) в цикле будет конечно корректно, но совсем не также как ListIterator.set(v), используемый в LinkedList.replaceAll(). Автор на такие мелочи внимание не обращает.

Частенько автор противоречит Javadoc. Например, при описании ExecutorService.invokeAll() написано:

первый вызов метода Feature.get блокирует до тех пор, пока не будут завершены все экземпляры Feature.

То есть, как будто между разными Feature есть какая-то связь и они друг друга ждут. На самом деле ничего такого не происходит, а блокируется не столько Feature.get(), сколько ExecutorService.invokeAll.

Ну или другой пример расхождения с официальной документацией — это секция про StampedLock и оптимистическую блокировку. Весь смысл оптимистичных блокировок в отсутствии блокировок. В данном случае, если очень много читателей, то писатель может ждать слишком долго, особенно для non-fair случая по умолчанию. Так вот StampedLock как раз и позволяет избежать блокировки писателя. Правильный пример кода можно найти в официальной документации:

double distanceFromOrigin() {
 long stamp = sl.tryOptimisticRead();
 try {
   retryHoldingLock: for (;; stamp = sl.readLock()) {
     if (stamp == 0L)
       continue retryHoldingLock;
     // possibly racy reads
     double currentX = x;
     double currentY = y;
     if (!sl.validate(stamp))
       continue retryHoldingLock;
     return Math.hypot(currentX, currentY);
   }
 } finally {
   if (StampedLock.isReadLockStamp(stamp))
     sl.unlockRead(stamp);
 }
}

Перед возвращением (фиксацией результата) мы проверяем не было ли значение изменено. При необходимости перечитываем и заново вычисляем. Мы стараемся, по возможности, избежать readLock(), которая заблокирует писателей.

А что предлагает автор? Автор предлагает делать validate() сразу за tryOptimisticRead() и ничего не делать, если validate() скажет, что записи не было (между tryOptimisticRead() и validate()). Налицо непонимание того, как работает StampedLock и нежелание разобраться в примере из библиотечной документации.

Практические приёмы — это всякие полезные практические хитрости, которые напрямую не указаны в документации, ну или просто указание на неприметный, но полезный класс в стандартной библиотеке:

  • Преобразование 1000 в 1K и тому подобное.

  • Комбинирование Stream.reduce(),  Function.identity() и Function.andThen() на списке функций для вызова их цепочкой.

  • Генераторы потоков.

Сильно портит книгу перевод:

  • Масса опечаток.

  • Для многих терминов используется транслитерация вместо перевода («состояние терминации», «дерегистрированных участников», «конкурентно запустить», «с несколькими имплементациями этого интерфейс»).

  • И наоборот, некоторые термины переведены крайне странно («обход должен быть жестоко терминирован», «альтернатива подклассированию», «стековая трасса сбоя», «замок на уровне объекта», «будет уменьшать стопор», «семафор может начинаться и умирать», «продвинуть исключение», «приращение», «атомарно наращивает переменную»).

  • Ближе к концу книги, переводчик перестал заморачиваться и так и оставил машинный перевод с тут и там несогласованными окончаниями.

Орфография — это неприятно, но терпимо. Но терминология — это серьезней. Часть просто неприятно читать. Например, все эти многонитиевости. Причем, переводчик не поленился и расписал целую секцию почему именно нити, хотя редактор в сноске указала, что потоки. Но или уговорить не удалось, или (что видно по числу опечаток) ограничилась первым десятком страниц.

Вот научится человек стопорам,  тупикам и запираниям на замок, и попробуй, пойми потом, что речь о CountDownLatch,  deadlock и acquire lock (затвор,  взаимная блокировка и получение блокировки в терминах той же Core Java).

В главе про шаблоны используется термин трафаретный метод. При этом, в тексте даже есть ссылка на GoF. То есть переводчик, даже если он не специалист в данной предметной области, мог просто подглядеть. А ведь шаблоны — это ещё и язык, который призван помочь в объяснении того, как система устроена или должна быть устроена.

Есть места, где корявый перевод меняет смысл написанного. Например:

Блокирующая нить исполнения обычно находится в состоянии BLOCKED…​

Неопытный разработчик может и не понять, что речь о том потоке, который был заблокирован, а не о том, который блокирует.

Резюмируя, можно сказать, что книга крайне неоднозначна. Опытный разработчик может найти что-то новое, если не следил за эволюцией. Может освежить память о том, что давно не использовал. Или даже может найти пару рецептов, о которых ранее не знал. Не очень опытный разработчик может узнать очень много. Это даже может быть лично ему полезно, чтобы «укладываться в сроки». Также как может быть полезен https://stackoverflow.com/, чтобы копипастить оттуда первый попавшийся рецепт. Если новичок «вырастит» на этой книге, то велик шанс того, что его работу придется переделывать. А ведь он ещё и может ссылаться на данную книгу в обоснование своей точки зрения. Тогда и взаимопонимание может быть затруднено. Хоть автор и позиционирует книгу для начального и среднего уровней, я бы рекомендовал её (и то не сильно) только сложившимся разработчикам. Прочитавший же её новичок будет просто опасен.

© Habrahabr.ru