понедельник, 20 июля 2015 г.

Ковариантность/контравариантность в Java без wildcard'ов

Все вы знаете, что полиморфный тип может быть либо ковариантен, либо контравариантен, либо инвариантен.

Например:

  • Тип Optional<A> ковариантен. То есть, например, всегда можно привести Optional<Integer> к Optional<Number> и это будет безопасно.
  • Тип Consumer<A> контравариантен. Это значит, что всегда можно безопасно привести Consumer<Number> к Consumer<Integer>.
  • Наконец, тип List<A> инвариантен, так как он не является ни ковариантным, ни контравариантным . То есть нельзя безопасно привести не от List<Integer> к List<Number> и не от List<Number> к List<Integer>
Так получилось, что в Java ввели полиморфные типы не сразу, а лишь в пятой версии. Поэтому, когда в то время типы снабжали параметрами, оказалось, что 99% типов оказались инвариантными. Но что делать в тех случаях, когда программист имеет переменную типа List<Integer>, а функция принимает аргумент типа List<Number>? Как привести List<Integer> к List<Number>, если List не является ковариантным? Для этого в Java ввели костыль под названием usage-site variance: каждый раз объявляя переменную, вы решаете, является ли тип этой переменной ковариантным, контравариантным или инвариантным. Например:
  • Если вам нужно только читать из списка, то вы объявляете тип переменной как ковариантный: List<? extends Number>.
  • Если вам нужно только писать в список, то вы объявляете тип переменной как контравариантный: List<? super Number>.
  • Наконец, если вам нужно и читать из списка, и писать в него, то вы пишете просто List<Number>, то есть объявляете тип переменной как инвариантный.
Печально то, что люди используют usage-site variance даже в тех случаях, когда тип является ковариантным или контравариантным, усложняя сигнатуры функций и таская по всему проекту типы с этими дурацкими символами вопроса (wildcard'ами).

В этом посте я предлагаю альтернативу wildcard'ам. Конечно, лучше бы если бы в Java, наконец, ввели declaration site variance на уровне языка, т.е. возможность указать ковариантность/контравариантность типа во время его объявления (Optional<out A> / Consumer<in A>), и я не понимаю, почему это до сих пор не сделали.

Итак, правило следующее. Если вы объявляете ковариантный тип, то вы добавляете метод widen() со следующей сигнатурой:

public class Optional<A> {
  ...

  public static <B, A extends B> Optional<B> widen(Optional<A> opt) {
    return (Optional) opt;
  }
}

Теперь всегда, когда вам нужно привести Optional<Integer> Optional<Number> вы пишите:

Optional<Integer> opt = ...;
Optional<Number> opt2 = Optional.widen(opt);

И всё, никаких wildcard'ов.

Аналогично для контравариантных типов вы добавляете метод narrow():

public interface Consumer<A> {
  ...

  public static <A, B extends A> Consumer<B> narrow(Consumer<A> con) {
    return (Consumer) con;
  }
}

Теперь если нужно привести Consumer<Number> к Consumer<Integer>, вы пишите:

Consumer<Number> con = ...;
Consumer<Integer> con2 = Consumer.narrow(con);
По-моему, такой подход делает код намного чище. Я понимаю, что мы заменили одно уродство на другое, но я думаю, что моё уродство является менее страшным.

четверг, 18 июня 2015 г.

Про дублирование кода

Я не понимаю людей, которые всерьёз оправдывают дублирование кода. Ну не понимаю и всё. Причём, говорят они вовсе не о мелких часто повторяемых конструкциях, а о вполне больших и сложных кусках логики на несколько сотен строк кода!
В свою защиту они приводят следующие аргументы:
  • "Вот когда мне понадобится третий раз скопировать код, тогда и вынесу общую часть в отдельный модуль".
  • "Эти куски кода лежат в разных проектах, поэтому чтобы выделить общую часть нужно создавать общую зависимость".
По-моему, всё это полнейший бред. Единственное логичное объяснение, почему люди не хотят бороться с дублированием кода, – это элементарная лень и непонимание последствий. А последствия дублирования кода часто бывают катастрофическими.
Обычно проявляется это следующим образом: в программе обнаруживается критический баг, причиной которого является допущенная ошибка в этом самом куске кода. Это баг назначают не на автора кода, а на другого члена команды. Этот член команды понятия не имеет, что где-то в другом месте проекта есть точно такой же кусок кода, и соответственно исправляет ошибку только в одном месте, но не исправляет в другом. Итог: баг закрыт, но только для определённых сценариев использования программы, когда вызывается именно этот участок кода. Но другие сценарии, когда вызывается другой, скопированный участок кода, по-прежнему будут работать ошибочно.
Другой вариант развития событий – член команды (который не в теме копипасты кода) оказался более проницательным и всё-таки смог обнаружить, что у этого кода есть дубликат в другой части проекта. Соответственно, он выбирает одно из двух: либо исправляет ошибку в обоих местах (по-сути, оставляет дублирование), либо героически берётся выносить дублирующийся код в общий модуль. Но выносить дублирующийся код – это часто задача не из простых, потому что ты должен отлично понимать логику, которую писал автор, чтобы ничего не поломать. А если логика сложная, то ты потратишь уйму драгоценного времени для её понимания. Хорошо если автор этого кода ещё работает в проекте, и у него можно спросить, что же он там имел в виду. А если он уже давно уволился? Вот если бы автор кода сразу вынес код в общий модуль, то он бы сделал это гораздо быстрее, так как тогда был в теме и держал все детали в голове.
Дублирование кода – это способ выиграть полчаса времени при написании программы засчёт траты в будущем от одного часа до целого рабочего дня. Это может быть приемлемо для какого-нибудь прототипа на Питоне, который вы точно знаете, что он будет гарантированно выброшен, но совершенно неприемлемо для больших и сложных программ, которые будут жить, развиваться и поддерживаться следующие несколько лет.

среда, 17 июня 2015 г.

Методы, принимающие на вход Object

Возможно я буду уже 1000-ным (или 10000-ным) человеком, который это говорит, но я всё же повторю ещё раз.

Методы, у которых есть аргументы типа Object – это полнейший пиздец.

Примеры таких методов:
  • Object.equals(Object)
  • Objects.equals(Object, Object)
  • Collection.contains(Object)
  • Collection.remove(Object)
  • Map.containsKey(Object)
  • Map.containsValue(Object)
Почему это лютейший пиздец, я поясню на примере. Допустим, у вас в программе есть переменная типа Set<Integer> и где-то в коде есть проверка:
Integer x = ...;
if (set.contains(x)) {
  // Do something
}
Теперь в один прекрасный день вам нужно поменять тип множества, скажем, на Set<String>. Вы меняете, и... код компилируется! Но проверка теперь работает некорректно, т.к. вы проверяете, содержит ли ваше множество число, что всегда будет возвращать false. Вы сделали фатальную ошибку в программе, но компилятор вас об этом не предупредил!
Так жить нельзя. Java – это пример статического языка, который не является типобезопасным. Статически типизированный и нетипобезопасный язык – что это если не пиздец?
Есть ли решение у этой проблемы? Ну не знаю, equals(Object) навсегда вшит во все объекты. Так будет в Java 9, 10, 11 и в 20. Поменять сигнатуру метода невозможно, т.к. это нарушит обратную совместимость. Можно использовать typeclass-подход, который используется в functionaljava, но разве кто-нибудь так будет делать?
Остаётся надеяться на IDE и статические анализаторы, хотя это частичное решение, не полное.