Data classes
It is not unusual to create classes whose main purpose is to hold data. In such classes, some standard functionality and some utility functions are often mechanically derivable from the data. In Kotlin, these are called data classes and are marked with data :
The compiler automatically derives the following members from all properties declared in the primary constructor:
equals() / hashCode() pair
toString() of the form «User(name=John, age=42)»
componentN() functions corresponding to the properties in their order of declaration.
copy() function (see below).
To ensure consistency and meaningful behavior of the generated code, data classes have to fulfill the following requirements:
The primary constructor needs to have at least one parameter.
Data classes cannot be abstract, open, sealed, or inner.
Additionally, the generation of data class members follows these rules with regard to the members’ inheritance:
If a supertype has componentN() functions that are open and return compatible types, the corresponding functions are generated for the data class and override those of the supertype. If the functions of the supertype cannot be overridden due to incompatible signatures or due to their being final, an error is reported.
Providing explicit implementations for the componentN() and copy() functions is not allowed.
Data classes may extend other classes (see Sealed classes for examples).
On the JVM, if the generated class needs to have a parameterless constructor, default values for the properties have to be specified (see Constructors).
Properties declared in the class body
The compiler only uses the properties defined inside the primary constructor for the automatically generated functions. To exclude a property from the generated implementations, declare it inside the class body:
Copying
Use the copy() function to copy an object, allowing you to alter some of its properties while keeping the rest unchanged. The implementation of this function for the User class above would be as follows:
You can then write the following:
Data classes and destructuring declarations
Component functions generated for data classes make it possible to use them in destructuring declarations:
Standard data classes
The standard library provides the Pair and Triple classes. In most cases, though, named data classes are a better design choice because they make the code more readable by providing meaningful names for the properties.
Классы данных
Нередко мы создаём классы, единственным назначением которых является хранение данных. Функционал таких классов зависит от самих данных, которые в них хранятся. В Kotlin класс может быть отмечен словом data:
Такой класс называется классом данных. Компилятор автоматически формирует следующие члены данного класса из свойств, объявленных в основном конструкторе:
Если какая-либо из этих функций явно определена в теле класса (или унаследована от родительского класса), то генерироваться она не будет.
Для обеспечения согласованности и осмысленного поведения сгенерированного кода классы данных должны удовлетворять следующим требованиям:
Дополнительно, генерация членов классов данных при наследовании подчиняется следующим правилам:
Начиная с версии 1.1, классы данных могут расширять другие классы (см. примеры в статье Изолированные классы)
Для того, чтобы у сгенерированного в JVM класса был конструктор без параметров, значения всех свойств должны быть заданы по умолчанию (см. Конструкторы)
Свойства, объявленные в теле класса
Обратите внимание, что компилятор использует только свойства, определенные в основном конструкторе для автоматически созданных функций. Чтобы исключить свойство из автоматически созданной реализации, объявите его в теле класса:
Копирование
Это позволяет нам писать:
Классы данных и мульти-декларации
Сгенерированные для классов данных компонентные функции позволяют использовать их в мульти-декларациях:
Стандартные классы данных
Класс данных – data class
Нередко в программах требуются объекты, предназначенные во многом для хранения данных. Например, для книг надо описывать их автора, название, год издания и т. д. В более старых языках программирования, таких как Паскаль и Си, для подобных целей существует такой тип данных как «запись». В более современных языках обычно для этих целей используют обычные классы, в которых методов может и не быть. Вот как мог бы выглядеть подобный класс и его объекты на языке Kotlin:
С объектами таких классов данных часто выполняются стандартные действия. Например, вывод значений свойств объекта, создание другого объекта с почти такими же значениями свойств, как у существующего. Поэтому Kotlin идет дальше и вводит в язык особый вариант класса – класс данных. Его объявление начинается со слова data.
Разница между этим вариантом и предыдущем в том, что к таким классам компилятор добавляет методы toString(), equals() и hashCode(), которые переопределяют эти методы, наследуемые по умолчанию всеми классами от Any. В результате в дата-классах эти функции-члены работают по-другому, они адаптированы под задачи, которые выполняют дата-классы. Также компилятор добавляет несколько других функций-членов, например, copy().
Метод toString() data-класса создает строку, содержащую перечень свойств и их значений.
Не забываем, что функция println() сама вызывает toString(). Конечно, мы можем переопределить метод в дата-классе, если нам не нравится его реализация по-умолчанию.
При этом мы переопределяем не тот toString(), который будет добавлен компилятором в связи с модификатором data. Мы переопределяем toString() класса Any. Поэтому если реализация метода toString() будет выглядеть как ниже, то это возврат к тому, что делает Any, несмотря на то, что класс data.
Функция-член equals() (перегружает оператор ==), которую добавляет компилятор к data-классам, сравнивает поля и на этом основании выносит суждение о том, равны ли объекты.
Если бы класс Book был объявлен без модификатора data, то результат обоих сравнений был бы false, потому что переменные a и c указывают на разные объекты. То есть сравнивались бы ссылки на объекты, а не значения полей объекта.
Функция copy() дата-класса, позволяет не просто создавать копию объекта, также на ходу изменять данные при необходимости:
Мультидекларация – это «распаковка» объекта таким образом, что значения его свойств присваиваются сразу нескольким переменным. В случае data-класса это выглядит так:
Чтобы подобное было возможно, компилятор добавляет в дата-класс функции, перегружающие операцию мультидекларации. Обычные классы по-умолчанию не поддерживают такую распаковку, но программист может добавить эту возможность в любой класс. Так бы выглядел обычный класс, но с поддержкой мультидекларации:
Операция мультидекларации также часто используется в цикле for. Если имеется список книг, можно легко пройтись по их свойствам:
Все вышеперечисленные функции по-умолчанию обрабатывают только свойства перечисленные в первичном конструкторе. Однако класс данных может содержать и другие.
В данном случае поле pages будет игнорироваться как при строковом представлении объекта, сравнении объектов, копировании, так и в мультидекларации.
В Котлин есть встроенные дата-классы – Triple и Pair, предназначенные для создания объектов с тремя или двумя свойствами. Тип свойств может быть любым.
Объекты класса Pair нередко используются при обработке коллекций в цикле for.
Kotlin. Классы данных (Data classes)
Не каждый класс можно отметить ключевым словом data. Для этого он должен соответствовать определённым требованиям:
Переопределяемые функции
toString()
Часто, особенно при отладке, возникает необходимость вывести в лог информацию об экземпляре класса. Если метод toString() не переопределён, то при обращении к экземпляру в лог будет выведена ссылка на него.
Это не очень информативно и вряд ли чем-то поможет. Чтобы исправить ситуацию достаточно переопределить метод toString() и указать в нём, что именно нужно выводить в лог при обращении к экземпляру класса.
Если же мы отметим класс ключевым словом data, метод toString() будет переопределён автоматически. При этом в лог будут выводиться все поля, указанные в конструкторе, в порядке их добавления.
equals()
Иногда нам может потребоваться сравнить между собой два объекта таким образом, чтобы они считались равными, если содержат одни и те же данные.
Если же мы отметим класс ключевым словом data, метод equals() будет переопределён автоматически. При этом работать будет точно также, как и в примере выше: будет проверять на равенство все значения, указанные в основном конструкторе.
hashCode()
Экземпляр класса можно использовать как ключ в структурах данных на основе хэш-функций. Это возможно благодаря тому, что каждому объекту присваивается уникальный хэш-код, даже если значения этих объектов идентичны.
Опять же, чтобы обо всём этом не думать, достаточно отметить класс ключевым словом data и метод hashCode() (и все остальные) будет переопределён автоматически. При этом работать будет точно также, как и в примере выше: будет возвращать значение, зависящее от хэш-кодов всех свойств, объявленных в основном конструкторе.
Ещё один метод, который генерируется автоматически для всех классов данных. Он позволяет копировать экземпляры класса, изменяя значения некоторых свойств.
На практике это может выглядеть примерно следующим образом:
Можно реализовать самостоятельно следующим образом:
Мультидекларации
Стандартные классы данных
Влияние data-классов на вес приложения
Мне встречались споры о том, стоит ли в принципе использовать data-классы в своём приложении. Ведь порой мы хоть и создаём data-класс, но не используем его функциональность, потому что, например, нам нужно просто получить ответ от сервера и передать данные в другое место. И в этом случае выходит так, что все эти автоматически сгенерированные методы впустую занимают место. К тому же, если действительно понадобится любой из вышеперечисленных методов, то его можно самостоятельно реализовать.
И вот перед разработчиками встал вопрос: стоит ли при создании класса задаваться вопросом “а нужен ли мне здесь data-класс”?
Чтобы ответить на этот вопрос, сначала нужно ответить на другой: насколько велико влияние data-классов на вес приложения?
И вот я наткнулась на статью, где автор провёл исследование, чтобы ответить на этот вопрос:
Влияние Kotlin data-классов на вес приложения.
В этой статье автор описал, как он создал плагин для удаления всех автогенерируемых методов с целью сравнить вес приложения с этими методами и без них. Разница составила 4%, подробнее о деталях читайте в статье.
Так каков в результате ответ? Использовать везде или использовать с осторожностью?
Тут всё достаточно просто. При создании класса для хранения данных подумайте для чего он будет использоваться. Понадобится ли функциональность data-класса? Если этот класс нужен только как переходник для данных, то подойдёт и обычный класс.
Хотя на мой взгляд оптимизация в 4% слишком мала, чтобы тратить время на раздумья. Но в любом случае проведённое исследование похвально.
Полезные ссылки
Сравнение Java-записей, Lombok @Data и Kotlin data-классов
Несмотря на то что все три решения позволяют бороться с бойлерплейт кодом, общего между ними довольно мало. У записей более сильная семантика, из которой вытекают их важные преимущества. Что часто делает их лучшим выбором, хотя и не всегда.
… в одну строчку кода:
Конечно, аннотации @Data и @Value из Lombok обеспечивают аналогичную функциональность с давних пор, хоть и с чуть большим количеством строк:
А если вы знакомы с Kotlin, то знаете, что то же самое можно получить, используя data-класс:
Получается, что это одно и то же? Нет. Уменьшение бойлерплейт кода не является целью записей, это следствие их семантики.
К сожалению, этот момент часто упускается. Об уменьшении бойлерплейт кода говорят много, так как это очевидно и легко демонстрируется, но семантика и вытекающие из нее преимущества остаются незамеченными. Официальная документация не помогает — в ней тоже все описывается под углом бойлерплейта. И хотя JEP 395 лучше объясняет семантику, но из-за своего объема все довольно расплывчато, когда дело доходит до описания преимуществ записей. Поэтому я решил описать их в этой статье.
Семантика записей (records)
В JEP 395 говорится:
Записи (records) — это классы, которые действуют как прозрачные носители неизменяемых данных.
Таким образом, создавая запись, вы говорите компилятору, своим коллегам, всему миру, что указанный тип хранит данные. А точнее, иммутабельные (поверхностно) данные с прозрачным доступом. Это основная семантика — все остальное вытекает из нее.
Если такая семантика не применима к нужному вам типу, то не используйте записи. А если вы все равно будете их использовать (возможно, соблазнившись отсутствием бойлерплейта или потому что вы думаете, что записи эквивалентны @Data / @Value и data-классам), то только испортите свою архитектуру, и велики шансы, что это обернется против вас. Так что лучше так не делать.
(Извините за резкость, но я должен был это сказать.)
Прозрачность и ограничения
Давайте подробнее поговорим о прозрачности (transparency). По этому поводу у записей есть даже девиз (перефразированный из Project Amber):
API записей моделирует состояние, только состояние и ничего, кроме состояния.
Для реализации этого необходимы ряд ограничений:
для всех компонент должны быть аксессоры (методы доступа) с именем, совпадающим с именем компонента, и возвращающие такой же тип, как у компонента (иначе API не будет моделировать состояние)
должен быть конструктор с параметрами, которые соответствуют компонентам записи (так называемый канонический конструктор; иначе API не будет моделировать состояние)
не должно быть никаких дополнительных полей (иначе API не будет моделировать состояние)
не должно быть наследования классов (иначе API не будет моделировать состояние, так как некоторые данные могут находиться в другом месте за пределами записи)
И Lombok и data-классы Kotlin позволяют создавать дополнительные поля, а также приватные «компоненты» (в терминах записей Java, а Kotlin называет их параметрами первичного конструктора). Так почему же Java относится к этому так строго? Чтобы ответить на этот вопрос, нам понадобится вспомнить немного математики.
Математика
Итак, как вы поняли, тип — это множество, значения которого допустимы для данного типа. Это также означает, что теория множеств — «раздел математики, в котором изучаются общие свойства множеств» (как говорит Википедия), — связана с теорией типов — «академическим изучением систем типов» (аналогично), — на которую опирается проектирование языков программирования.
Это здорово, потому что теория множеств может многое сказать о применении функций к произведениям. Одним из аспектов этого является то, как функции, работающие с одним операндом, могут комбинироваться с функциями, работающими с несколькими операндами, и какие свойства функций (инъективные, биективные и т. д.) остаются нетронутыми.
В общем случае, чтобы применить теорию множеств к типу так, как я упоминал выше, ко всем его операндам должен быть доступ и должен существовать способ превратить кортеж операндов в экземпляр. Если верно и то и другое, то теория типов называет такой тип «тип-произведение» (а его экземпляры кортежами), и с ними можно делать несколько интересных вещей.
На самом деле записи лучше кортежей. В JEP 395 говорится:
Записи можно рассматривать как номинативные кортежи.
Следствия
Я хочу донести до вас следующую мысль: записи стремяться стать типом-произведением и, чтобы это работало, все их компоненты должны быть доступны. То есть не может быть скрытого состояния, и должен быть конструктор, принимающий все компоненты. Именно поэтому записи являются прозрачными носителями неизменяемых данных.
Итак, если подытожить:
Аксессоры (методы доступа) генерируются компилятором.
Мы не можем изменять их имена или возвращаемый тип.
Мы должны быть очень осторожны с их переопределением.
Компилятор генерирует канонический конструктор.
Преимущества записей
Большинство преимуществ, которые мы получаем от алгебраической структуры, связаны с тем, что аксессоры вместе с каноническим конструктором позволяют разбирать и пересоздавать экземпляры записей структурированным образом без потери информации.
Деструктурирующие паттерны
Благодаря полной прозрачности записей мы можем быть уверены, что не пропустим скрытое состояние. Это означает, что разница между range и возвращаемым экземпляром — это именно то, что вы видите: low и high меняются местами — не более того.
Блок with
И, как и раньше, мы можем рассчитывать на то, что newRange будет точно таким же, как и range за исключением low : нет скрытого состояния, которое мы не перенесли. И синтаксически здесь все просто:
выполнить блок with
передать переменные в канонический конструктор
(Обратите внимание, что этот функционал далек от реальности и может быть не реализован или быть значительно изменен.)
Сериализация
Для представления объекта в виде потока байт, JSON / XML-документа или в виде любого другого внешнего представления и обратной конвертации, требуется механизм разбивки объекта на его значения, а затем сборки этих значений снова вместе. И вы сразу же можете увидеть, как это просто и хорошо работает с записями. Они не только раскрывают все свое состояние и предлагают канонический конструктор, но и делают это структурированным образом, что делает использование Reflection API очень простым.
Более подробно том, как записи изменили сериализацию, слушайте в подкасте Inside Java Podcast, episode 14 (также в Spotify). Если вы предпочитаете короткие тексты, то читайте твит.
Бойлерплейт код
Вернемся на секунду к бойлерплейту. Как говорилось ранее, чтобы запись была типом-произведением, должны выполняться следующие условия:
аксессоры (методы доступа)
И все это генерируется компилятором (а также еще toString ) не столько для того, чтобы избавить нас от написания этого кода, сколько потому, что это естественное следствие алгебраической структуры.
Недостатки записей
Так что же делать, если вам все это нужно? Тогда записи вам не подходят и вместо них следует использовать обычный класс. Даже если изменив только 10% функциональности, вы получите 90% бойлерплейта, от которого вы бы избавились с помощью записей.
Преимущества Lombok @Data/@Value
Lombok просто генерирует код. У него нет семантики, поэтому у вас есть полная свобода в изменении класса. Конечно, вы не получите преимуществ более строгих гарантий, хотя в будущем Lombok, возможно, сможет генерировать деструктурные методы.
(При этом я не рекламирую Lombok. Он в значительной степени полагается на внутренние API компилятора, которые могут измениться в любой момент, а это означает, что проекты, использующие его, могут сломаться при любом незначительном обновлении Java. То, что он много делает для скрытия технического долга от своих пользователей, тоже не очень хорошо.)
Преимущества data-классов Kotlin
Вы часто создаете классы, основной целью которых является хранение данных. Обычно в таких классах некоторый стандартный и дополнительный функционал можно автоматически получить из данных.
Некоторые указывали на @JvmRecord в Kotlin как на большую ошибку: «Видите, data-классы могут быть записями — шах и мат ответ» (я перефразировал, но смысл был такой). Если у вас возникли такие же мысли, то я прошу вас остановиться и подумать на секунду. Что именно это дает вам?
Data-класс должен соблюдать все правила записи, а это значит, что он не может делать больше, чем запись. Но Kotlin все еще не понимает концепции прозрачных кортежей и не может сделать с @JvmRecord data-классом больше, чем с обычным data-классом. Таким образом, у вас есть свобода записей и гарантии data-классов данных — худшее из обоих миров.
В Kotlin нет большого смысла использовать JVM-записи, за исключением двух случаев:
перенос существующей Java-записи на Kotlin с сохранением ее ABI;
генерация атрибута класса записи с информацией о компоненте записи для класса Kotlin для последующего чтения каким-либо фреймворком, использующим Java reflection для анализа записей.
Рефлексия
Записи не лучше и не хуже рассмотренных альтернатив или других вариантов с аналогичным подходом, таких как case-классы Scala. У них действительно сильная семантика с твердым математическим фундаментом, которая хотя и ограничивает возможности по проектированию классов, но приносит мощные возможности, которые, в противном, случае были бы невозможны или, по крайней мере, не столь надежны.
Это компромисс между свободой разработчика и мощью языка. И я доволен этим компромиссом и с нетерпением жду, когда он полностью раскроет свой потенциал в будущем.
В преддверии старта курса «Java Developer. Professional» приглашаю всех желающих на бесплатный демоурок по теме: «Система получения курсов валют ЦБ РФ».



