Сравнение Java-записей, Lombok @Data и Kotlin data-классов

Kate

Administrator
Команда форума
Несмотря на то что все три решения позволяют бороться с бойлерплейт кодом, общего между ними довольно мало. У записей более сильная семантика, из которой вытекают их важные преимущества. Что часто делает их лучшим выбором, хотя и не всегда.

Я уверен, что вы уже видели примеры, как с помощью записей превратить обычный POJO ...

class Range {

private final int low;
private final int high;

public Range(int low, int high) {
this.low = low;
this.high = high;
}

public int getLow() {
return low;
}

public int getHigh() {
return high;
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Range range = (Range) o;
return low == range.low &&
high == range.high;
}

@Override
public int hashCode() {
return Objects.hash(low, high);
}

@Override
public String toString() {
return "[" + low + "; " + high + "]";
}

}
… в одну строчку кода:

// это компоненты записи (components)
record Range (int low, int hight) { }
Конечно, аннотации @Data и @Value из Lombok обеспечивают аналогичную функциональность с давних пор, хоть и с чуть большим количеством строк:

@Data
class Range {

private final int low;
private final int high;

}
А если вы знакомы с Kotlin, то знаете, что то же самое можно получить, используя data-класс:

data class Range(val low: Int, val high: Int)
Получается, что это одно и то же? Нет. Уменьшение бойлерплейт кода не является целью записей, это следствие их семантики.

К сожалению, этот момент часто упускается. Об уменьшении бойлерплейт кода говорят много, так как это очевидно и легко демонстрируется, но семантика и вытекающие из нее преимущества остаются незамеченными. Официальная документация не помогает — в ней тоже все описывается под углом бойлерплейта. И хотя JEP 395 лучше объясняет семантику, но из-за своего объема все довольно расплывчато, когда дело доходит до описания преимуществ записей. Поэтому я решил описать их в этой статье.

Семантика записей (records)​

В JEP 395 говорится:

Записи (records) — это классы, которые действуют как прозрачные носители неизменяемых данных.
Таким образом, создавая запись, вы говорите компилятору, своим коллегам, всему миру, что указанный тип хранит данные. А точнее, иммутабельные (поверхностно) данные с прозрачным доступом. Это основная семантика — все остальное вытекает из нее.

Если такая семантика не применима к нужному вам типу, то не используйте записи. А если вы все равно будете их использовать (возможно, соблазнившись отсутствием бойлерплейта или потому что вы думаете, что записи эквивалентны @Data / @Value и data-классам), то только испортите свою архитектуру, и велики шансы, что это обернется против вас. Так что лучше так не делать.

(Извините за резкость, но я должен был это сказать.)

Прозрачность и ограничения​

Давайте подробнее поговорим о прозрачности (transparency). По этому поводу у записей есть даже девиз (перефразированный из Project Amber):

API записей моделирует состояние, только состояние и ничего, кроме состояния.

Для реализации этого необходимы ряд ограничений:

  • для всех компонент должны быть аксессоры (методы доступа) с именем, совпадающим с именем компонента, и возвращающие такой же тип, как у компонента (иначе API не будет моделировать состояние)
  • должен быть конструктор с параметрами, которые соответствуют компонентам записи (так называемый канонический конструктор; иначе API не будет моделировать состояние)
  • не должно быть никаких дополнительных полей (иначе API не будет моделировать состояние)
  • не должно быть наследования классов (иначе API не будет моделировать состояние, так как некоторые данные могут находиться в другом месте за пределами записи)
И Lombok и data-классы Kotlin позволяют создавать дополнительные поля, а также приватные "компоненты" (в терминах записей Java, а Kotlin называет их параметрами первичного конструктора). Так почему же Java относится к этому так строго? Чтобы ответить на этот вопрос, нам понадобится вспомнить немного математики.

Математика​

Множество (set) — это набор некоторых элементов. Например, можно сказать, что C — это множество всех цветов {синий, золотой, ...}, а N — множество всех натуральных чисел {0, 1, ...}. Конечное множество {-2147483648, ..., 0, ..., 2147483647} — это то, что в Java мы называем типом int. А если добавить к этому множеству null, то получим Integer. Аналогично бесконечное множество всех возможных строк (плюс null) — мы называем String.

Итак, как вы поняли, тип — это множество, значения которого допустимы для данного типа. Это также означает, что теория множеств — "раздел математики, в котором изучаются общие свойства множеств" (как говорит Википедия), — связана с теорией типов — "академическим изучением систем типов" (аналогично), — на которую опирается проектирование языков программирования.

class Pair {
private final int first;
private final int second;
}
Можно назвать соответствующее множество Pair, и это будет работать. Давайте немного углубимся, так как теперь мы знаем о множествах. В частности, мы видим, что у нас получилось сочетание всех целых чисел со всеми целыми числами. В теории множеств это называется произведением и записывается как int × int (каждый тип в произведении называется операндом).

Это здорово, потому что теория множеств может многое сказать о применении функций к произведениям. Одним из аспектов этого является то, как функции, работающие с одним операндом, могут комбинироваться с функциями, работающими с несколькими операндами, и какие свойства функций (инъективные, биективные и т. д.) остаются нетронутыми.

// given: bijective function from int to int
IntUnaryOperator increment =
i -> i == Integer.MAX_VALUE ? Integer.MIN_VALUE : ++i;

// then: combining two `increment`s yields a bijective function
// (this requires no additional proof or consideration)
UnaryOperator<Pair> incrementPair =
pair -> new Pair(
increment.applyAsInt(pair.first()),
increment.applyAsInt(pair.second()));
Вы обратили внимание на аксессоры Pair::first и Pair::second? Их не было в классе выше, поэтому их пришлось добавить. Иначе нельзя было бы применить функции к отдельным компонентам / операндам, и использовать Pair в качестве пары целых чисел. Аналогично, но с другой стороны, мне нужен конструктор, принимающий в качестве аргументов оба целых числа, чтобы можно было воспроизвести pair.

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

На самом деле записи лучше кортежей. В JEP 395 говорится:

Записи можно рассматривать как номинативные кортежи.

Где "номинативность" означает, что записи идентифицируются по их именам, а не по их структуре. Таким образом, два типа записей, которые моделируют int × int, например, Pair(int first, int second) и Range(int low, int high) будут разными типами. А также обращение к компонентам записи идет не по индексу (range.get1()), а по имени (record.low()).

Следствия​

Я хочу донести до вас следующую мысль: записи стремяться стать типом-произведением и, чтобы это работало, все их компоненты должны быть доступны. То есть не может быть скрытого состояния, и должен быть конструктор, принимающий все компоненты. Именно поэтому записи являются прозрачными носителями неизменяемых данных.

Итак, если подытожить:

  • Аксессоры (методы доступа) генерируются компилятором.
  • Мы не можем изменять их имена или возвращаемый тип.
  • Мы должны быть очень осторожны с их переопределением.
  • Компилятор генерирует канонический конструктор.
  • Наследование отсутствует.

Преимущества записей​

Большинство преимуществ, которые мы получаем от алгебраической структуры, связаны с тем, что аксессоры вместе с каноническим конструктором позволяют разбирать и пересоздавать экземпляры записей структурированным образом без потери информации.

Деструктурирующие паттерны​

if (range instanceof Range(int low, int high) && high < low)

return new Range(high, low);
Благодаря полной прозрачности записей мы можем быть уверены, что не пропустим скрытое состояние. Это означает, что разница между range и возвращаемым экземпляром — это именно то, что вы видите: low и high меняются местами — не более того.

Блок with​

Range range = new Range(5, 10);
// SYNTAX IS MADE UP!

Range newRange = range with { low = 0; }
// range: [5; 10]
// newRange: [0; 10]
И, как и раньше, мы можем рассчитывать на то, что newRange будет точно таким же, как и range за исключением low: нет скрытого состояния, которое мы не перенесли. И синтаксически здесь все просто:

  • объявить переменные для компонент (например, low, high) и присвоить значения с помощью аксессоров
  • выполнить блок with
  • передать переменные в канонический конструктор
(Обратите внимание, что этот функционал далек от реальности и может быть не реализован или быть значительно изменен.)

Сериализация​

Для представления объекта в виде потока байт, JSON / XML-документа или в виде любого другого внешнего представления и обратной конвертации, требуется механизм разбивки объекта на его значения, а затем сборки этих значений снова вместе. И вы сразу же можете увидеть, как это просто и хорошо работает с записями. Они не только раскрывают все свое состояние и предлагают канонический конструктор, но и делают это структурированным образом, что делает использование Reflection API очень простым.

Более подробно том, как записи изменили сериализацию, слушайте в подкасте Inside Java Podcast, episode 14 (также в Spotify). Если вы предпочитаете короткие тексты, то читайте твит.

Бойлерплейт код​

Вернемся на секунду к бойлерплейту. Как говорилось ранее, чтобы запись была типом-произведением, должны выполняться следующие условия:

  • канонический конструктор
  • аксессоры (методы доступа)
  • отсутствие наследования
Я не сказал об этом явно, но было бы неплохо, если (0, 0) = (0, 0), то есть должна быть правильная реализация equals, которая сразу же требует реализации hashCode.

И все это генерируется компилятором (а также еще toString) не столько для того, чтобы избавить нас от написания этого кода, сколько потому, что это естественное следствие алгебраической структуры.

Недостатки записей​

Семантика записей ограничивает возможности по работе с классами. Как уже говорилось, вы не можете добавлять скрытое состояние через добавление полей, не можете переименовывать аксессоры и изменять тип возвращаемого значения и, вероятно, не должны менять возвращаемое ими значение. Записи также не позволяют изменять значения компонент, так как соответствующие им поля объявлены final. И отсутствует наследование классов (хотя вы можете реализовать интерфейсы).

Так что же делать, если вам все это нужно? Тогда записи вам не подходят и вместо них следует использовать обычный класс. Даже если изменив только 10% функциональности, вы получите 90% бойлерплейта, от которого вы бы избавились с помощью записей.

Преимущества Lombok @Data/@Value​

Lombok просто генерирует код. У него нет семантики, поэтому у вас есть полная свобода в изменении класса. Конечно, вы не получите преимуществ более строгих гарантий, хотя в будущем Lombok, возможно, сможет генерировать деструктурные методы.

(При этом я не рекламирую Lombok. Он в значительной степени полагается на внутренние API компилятора, которые могут измениться в любой момент, а это означает, что проекты, использующие его, могут сломаться при любом незначительном обновлении Java. То, что он много делает для скрытия технического долга от своих пользователей, тоже не очень хорошо.)

Преимущества data-классов Kotlin​

Вот что говорится в документации о data-классах:

Вы часто создаете классы, основной целью которых является хранение данных. Обычно в таких классах некоторый стандартный и дополнительный функционал можно автоматически получить из данных.

Вы можете видеть, что здесь также присутствует семантика хранения данных, но она довольно слабая, и основное внимание уделяется получению функциональности, то есть генерации кода. Действительно, data-классы предлагают больше возможностей по работе с классами, чем записи (мутабельные "компоненты", скрытое состояние, ...), но в отличие от Lombok не все (не могут расширяться, нельзя создавать свой метод copy, ...). С другой стороны, data-классы не дают сильных гарантий как записи, поэтому Kotlin не может построить на их основе аналогичную функциональность. Это разные подходы с разной ценой и выгодой.

Некоторые указывали на @JvmRecord в Kotlin как на большую ошибку: "Видите, data-классы могут быть записями — шах и мат ответ" (я перефразировал, но смысл был такой). Если у вас возникли такие же мысли, то я прошу вас остановиться и подумать на секунду. Что именно это дает вам?

Data-класс должен соблюдать все правила записи, а это значит, что он не может делать больше, чем запись. Но Kotlin все еще не понимает концепции прозрачных кортежей и не может сделать с @JvmRecord data-классом больше, чем с обычным data-классом. Таким образом, у вас есть свобода записей и гарантии data-классов данных — худшее из обоих миров.

Для чего тогда нужен @JvmRecord? Просто для совместимости. Как говорится в proposal:

В Kotlin нет большого смысла использовать JVM-записи, за исключением двух случаев:

  • перенос существующей Java-записи на Kotlin с сохранением ее ABI;
  • генерация атрибута класса записи с информацией о компоненте записи для класса Kotlin для последующего чтения каким-либо фреймворком, использующим Java reflection для анализа записей.

Рефлексия​

Записи не лучше и не хуже рассмотренных альтернатив или других вариантов с аналогичным подходом, таких как case-классы Scala. У них действительно сильная семантика с твердым математическим фундаментом, которая хотя и ограничивает возможности по проектированию классов, но приносит мощные возможности, которые, в противном, случае были бы невозможны или, по крайней мере, не столь надежны.

Это компромисс между свободой разработчика и мощью языка. И я доволен этим компромиссом и с нетерпением жду, когда он полностью раскроет свой потенциал в будущем.

Источник статьи: https://habr.com/ru/company/otus/blog/563154/
 
Сверху