Недавно я реализовал шаблон репозитория, когда заметил потенциальную ошибку. Для тех, кому нужно краткое объяснение паттерна репозитория, вы в основном берете то, как вы получаете свои данные, и кладете реализацию за интерфейс. Это полезно для имитации баз данных в тестах и ​​четко отделяет вашу логику доступа к данным от остального кода.

Например, это интерфейс для моего PlaylistRepository, интерфейс, который я сейчас использую для взаимодействия со списками воспроизведения в Auracle Music Player.

Потенциальная ошибка этого метода немного тонкая, но она может иметь действительно странные последствия для пользователя ...

Реализация PlaylistRepository, которую я использую в производстве, - это RealmPlaylistRepository, которая поддерживается базой данных Realm, и я храню как идентификатор списка воспроизведения, так и идентификатор песни по мере необходимости.

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

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

Логически в этом нет никакого смысла, но компилятор вполне доволен этим ошибочным кодом. Он будет успешно построен, он предаст вас и позволит вам отправить этот ошибочный код.

Как я могу очистить свой код, чтобы компилятор мог отлавливать подобные глупые ошибки?

Я рада, что вы спросили!

Безопасность времени компиляции

То, что мы здесь пытаемся сделать, также известно как безопасность времени компиляции. Мы хотим прервать сборку всякий раз, когда передаем неправильный тип идентификатора. Это будет похоже на то, как Kotlin имеет возможность использования NULL, встроенную в систему типов, чтобы вы получали ошибки времени компиляции вместо ошибок времени выполнения для разыменования null.

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

Мы начнем со следующих занятий:

Затем мы изменим параметры нашей функции в PlaylistRepository, чтобы она имела правильные логические типы:

Поскольку этот метод принимает строго типизированные аргументы: SongId и PlaylistId, если вы попытаетесь сделать следующее, компилятор завершит сборку с ошибкой.

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

Это позволяет нам делать следующее на месте звонка:

Теперь мы можем быть уверены, что компилятор не выполнит сборку, если мы когда-нибудь попытаемся передать SongId в качестве PlaylistId!

Но можем ли мы расширить это, чтобы в дальнейшем использовать преимущества типов? Конечно!

В базе кода Auracle Песня может иметь недопустимый идентификатор, если она еще не добавлена ​​в базу данных. Раньше я использовал идентификатор -1 для представления этого состояния, но мы можем добиться большего с типами.

Вот обновленная версия SongId, в которой система типов учитывает, что SongId может быть недопустимым:

Первое отличие состоит в том, что мы превратили SongId в запечатанный класс.

Документация Kotlin дает следующее определение закрытых классов:

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

Как мы определили SongId.kt, SongId может быть либо ValidSongId со свойством ID, представляющим фактическое длинное значение идентификатора, либо это может быть InvalidSongId, у которого нет свойства ID.

Ключевое преимущество использования запечатанных классов проявляется, когда вы используете их в выражении когда. Если можно убедиться, что оператор охватывает все случаи, вам не нужно добавлять в него предложение else. Однако это работает только в том случае, если вы используете when как выражение (используя результат), а не как оператор.

Это позволяет нам написать следующий код в RealmSongRepository:

В качестве бонуса IDE теперь будет автоматически завершать все случаи, когда мы включаем SongId или любой другой запечатанный класс, если на то пошло.

Далее я хотел бы указать, что InvalidSongId объявлен как объект, а не как класс. Ключевое слово объекта Kotlin позволяет нам делать объявление объекта, встроенный способ создания одиночных объектов. Поскольку мы объявляем объект, а не класс, может быть только один экземпляр InvalidSongId, что логично.

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

Спасибо за прочтение :)

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

Стоит упомянуть о недостатках этого процесса во время выполнения, а именно о расходах на выделение этих оболочек и постоянных косвенных обращениях, которые вы должны разворачивать. Если бы мы сделали это за все, то был бы серьезный штраф. Однако, если мы сделаем это напрасно, вероятность появления ошибок чрезвычайно высока (см. Javascript, python и т. Д.). Где это имеет смысл использовать, для каждого приложения разное.

Но, поскольку примеры находятся в Kotlin, это приводит меня к чему-то новому в Kotlin 1.3, а именно к встроенным классам. По сути, безопасность типов для классов-оболочек со всеми накладными расходами, связанными с типами (также известными как none). На них пока нет документации, вам нужно поискать в компиляторе и его тестах, чтобы взглянуть на них, но они в основном позволяют создавать эти классы-оболочки, которые существуют только в системе типов, а затем стираются во время компиляции. Любые методы или свойства, определенные в этих классах, становятся статическими методами, принимающими завернутый тип в качестве первого параметра.

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

И для любопытных: вот - начальная фиксация встроенных классов.