Ключевое слово data
Если у класса указать ключевое слово data, то автоматически будут созданы и переопределены методы toString(), equals(), hashCode(), copy(). Скорее всего вы будете использовать этот вариант для создания полноценного класса-модели с геттерами и сеттерами.
В конструкторе класса у параметров следует указывать val или var.
Подобные классы часто используются при работе с JSON.
Классы данных не могут объявляться абстрактными или открытыми, так что класс данных не может использоваться в качестве суперкласса. Однако классы данных могут реализовать интерфейсы, а также могут наследоваться от других классов.
toString()
Если нужно получить информацию о классе, то достаточно вызвать имя переменной класса. Вы получите строку со всеми значениями всех свойств на основе конструктора вместо непонятных символов @Cat5edea как в Java. Такой подход удобен при тестировании и отладке. Сразу понятно, о чём идёт речь.
Можно сразу определить значение по умолчанию у поля класса. При инициализации объекта можно не указывать поле, но оно будет доступно для вычислений.
equals()
При определении класса данных функция equals() (и оператор ==) по-прежнему возвращает true, если ссылки указывают на один объект. Но она также возвращает true, если объекты имеют одинаковые значения свойств, определённых в конструкторе:
Если вы переопределяете функцию equals(), также необходимо переопределить функцию hashCode().
Кстати, если вам нужно проверить, что две переменные ссылаются на один объект, то используйте оператор ===. В отличие от оператора ==, поведение оператора === не зависит от функции equals(), которое в разных классах может вести себя по разному. Оператор === всегда ведёт себя одинаково независимо от разновидности класса.
hashCode()
Если два объекта данных считаются равными (имеют одинаковые значения свойств), функция hashCode() возвращает для этих объектов одно и то же значение:
Если вам потребуется создать копию объекта данных, изменяя некоторые из его свойств, но оставить другие свойства в исходном состоянии, воспользуйтесь функцией copy(). Для этого функция вызывается для того объекта, который нужно скопировать, и ей передаются имена всех изменяемых свойств с новыми значениями.
Фактически мы создаём копию объекта, меняем значение нужного свойства и присваиваем новый объект переменной с новым именем. При этом исходный объект остаётся без изменений.
Деструктурирующее присваивание
Мы могли бы обратиться и привычным способом.
Деструктуризация позволяет разбить объект на несколько переменных.
Можно пропустить через цикл.
Можно пропустить какую-то переменную через символ подчёркивания.
Несколько конструкторов
Добавить второй конструктор к классу можно через ключевое слово constructor.
Класс данных – 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:
Такой класс называется классом данных. Компилятор автоматически формирует следующие члены данного класса из свойств, объявленных в основном конструкторе:
Если какая-либо из этих функций явно определена в теле класса (или унаследована от родительского класса), то генерироваться она не будет.
Для обеспечения согласованности и осмысленного поведения сгенерированного кода классы данных должны удовлетворять следующим требованиям:
Дополнительно, генерация членов классов данных при наследовании подчиняется следующим правилам:
Начиная с версии 1.1, классы данных могут расширять другие классы (см. примеры в статье Изолированные классы)
Для того, чтобы у сгенерированного в JVM класса был конструктор без параметров, значения всех свойств должны быть заданы по умолчанию (см. Конструкторы)
Свойства, объявленные в теле класса
Обратите внимание, что компилятор использует только свойства, определенные в основном конструкторе для автоматически созданных функций. Чтобы исключить свойство из автоматически созданной реализации, объявите его в теле класса:
Копирование
Это позволяет нам писать:
Классы данных и мульти-декларации
Сгенерированные для классов данных компонентные функции позволяют использовать их в мульти-декларациях:
Стандартные классы данных
Сравнение 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» приглашаю всех желающих на бесплатный демоурок по теме: «Система получения курсов валют ЦБ РФ».
Влияние Kotlin data-классов на вес приложения
Kotlin имеет много классных особенностей: null safety, smart casts, интерполяция строк и другие. Но одной из самых любимых разработчиками, по моим наблюдениям, являются data-классы. Настолько любимой, что их часто используют даже там, где никакой функциональности data-класса не требуется.
В этой статье я с помощью эксперимента постараюсь понять, какова реальная цена использования большого количества data-классов в приложении. Я попробую удалить все data-классы, не сломав компиляцию, но сломав приложение, а потом расскажу о результатах и выводах этого эксперимента.
Data-классы и их функциональность
В процессе разработки часто создаются классы, основное назначение которых — хранение данных. В Kotlin их можно пометить как data-классы, чтобы получить дополнительную функциональность:
Но мы платим далеко не за всю эту функциональность. Для релизных сборок используются оптимизаторы R8, ProGuard, DexGuard и другие. Они могут удалять неиспользуемые методы, а значит, могут и оптимизировать data-классы.
Масштаб изменений
Но вручную такое поведение реализовать невозможно. Единственный способ удалить эти функции из кода — переопределить их в следующем виде для каждого data-класса в проекте:
Вручную для 7749 data-классов в проекте.
Ситуацию усугубляет использование монорепозитория для приложений. Это означает, что я не знаю точно, сколько из этих 7749 классов мне нужно изменить, чтобы измерить влияние data-классов только на одно приложение. Поэтому придётся менять все!
Плагин компилятора
Вручную такой объём изменений сделать невозможно, поэтому самое время вспомнить о такой прекрасной незадокументированной вещи, как плагины компилятора. Мы уже рассказывали про наш опыт создания плагина компилятора в статье «Чиним сериализацию объектов в Kotlin раз и навсегда». Но там мы генерировали новые методы, а здесь нам нужно их удалять.
В открытом доступе на GitHub есть плагин Sekret, который позволяет скрывать в toString() указанные аннотацией поля в data-классах. Его я и взял за основу своего плагина.
С точки зрения создания структуры проекта практически ничего не поменялось. Нам понадобятся:
ClassBuilderInterceptorExtension позволяет изменять процесс генерации классов, а значит, с его помощью мы сможем избежать создания ненужных методов.
Весь код доступен в репозитории, там же есть пример интеграции плагина.
Результаты
Для сравнения мы будем использовать релизные сборки Bumble и Badoo. Результаты были получены с помощью утилиты Diffuse, которая выводит детальную информацию о разнице между двумя APK-файлами: размеры DEX-файлов и ресурсов, количество строк, методов, классов в DEX-файле.
| Приложение | Bumble | Bumble (после) | Разница | Badoo | Badoo (после) | Разница |
|---|---|---|---|---|---|---|
| Data-классы | 4026 | — | — | 2894 | — | — |
| Размер DEX (zipped) | 12.4 MiB | 11.9 MiB | -510.1 KiB | 15.3 MiB | 14.9 MiB | -454.1 KiB |
| Размер DEX (unzipped) | 31.7 MiB | 30 MiB | -1.6 MiB | 38.9 MiB | 37.6 MiB | -1.4 MiB |
| Строки в DEX | 188969 | 179197 | -9772 | 244116 | 232114 | -12002 |
| Методы | 292465 | 277475 | -14990 | 354218 | 341779 | -12439 |
Количество data-классов было определено эвристическим путём с помощью анализа удалённых из DEX-файла строк.
Реализация toString() у data-классов всегда начинается с короткого имени класса, открывающей скобки и первого поля data-класса. Data-классов без полей не существует.
Исходя из результатов, можно сказать, что в среднем каждый data-класс обходится в 120 байт в сжатом и 400 байт — в несжатом виде. На первый взгляд, не много, поэтому я решил проверить, сколько получается в масштабе целого приложения. Выяснилось, что все data-классы в проекте обходятся нам в
4% размера DEX-файла.
Также стоит уточнить, что из-за MVI-архитектуры мы можем использовать больше data-классов, чем приложения на других архитектурах, а значит, их влияние на ваше приложение может быть меньше.
Использование data-классов
Я ни в коем случае не призываю вас отказываться от data-классов, но, принимая решение об их использовании, нужно тщательно всё взвесить. Вот несколько вопросов, которые стоит задать себе перед объявлением data-класса:
Мы не можем совсем отказаться от использования data-классов в проекте, и приведённый выше плагин ломает работоспособность приложения. Удаление методов было сделано ради оценки влияния большого количества data-классов. В нашем случае это
4% от размера DEX-файла приложения.
Если вы хотите оценить, сколько места занимают data-классы в вашем приложении, то можете сделать это самостоятельно с помощью моего плагина.


