
Салют, Хабр!
Я Марк, Android-разработчик, работаю над мобильным приложением для управления умным домом Салют. Для мира Android-разработки вопросы архитектуры, её надёжности и качества актуальны, но… на самом деле не так уж интересны. Интересно, чтобы приложения были надёжными, устойчивыми к ошибкам, поддерживаемыми и легко масштабируемыми. Самый популярный подход — по-прежнему архитектурные паттерны (MV* паттерны) и разделение архитектуры по слоям. Что никак не избавляет от ошибок.
При этом существует множество подходов, которые делают архитектуру надёжнее, а в перспективе исключают целый класс ошибок, как введение в Котлине Null Safety избавило от класса ошибок NPE. Это проектирование на основе состояний (state-oriented programming), логика Хоара, программирование по контракту Бертрана Мейера. Возможно, и более серьёзные — например, формальные методы верификации. Отмечу, что в целом это общие принципы computer science, независимые от платформы. Но мой фокус — Android-разработка клиент-серверных приложений.
Сейчас хотел бы поговорить, как создание своей системы типов в проекте исключает популярный класс логических ошибок — semantic type error. Поехали!
Почему не примитивный тип?
Возьмем популярную ситуацию: ответ от бэкенда не по оговоренному контракту (опустим, что спецификации API, оформленные в каком-нибудь Swagger, на самом деле не являются контрактом — это тема для отдельной статьи). Причина всегда одна и та же: кто-то позволил проскочить невалидному с точки зрения бизнес-требований параметру. Так в программе появляется баг, который может спровоцировать крэш или привести UI в невалидное состояние. Либо будет долгие годы скрываться в системе, прежде чем «выстрелит».
Доменная система типов позволяет создать свои типы данных для доменных сущностей вместо примитивных: сузить область допустимых значений до границ, которых требует бизнес-логика. Бонусом — возможность задокументировать тип и описать его смысл. Ответственность за систему в этом подходе возлагается уже не на создателей языка программирования, а на самих разработчиков.
Для меня доменная система типов — не просто обёртки над примитивами, а этап естественной эволюции типов, который позволяет точнее декларировать наши намерения по типам данных. Ниже небольшой экскурс в историю, который покажет, как в языках развивались типы.
Эволюция типов
В начале было число. То есть один-единственный тип значений — числовой. Убедиться, что операции над этими значениями имеют нужный смысл, должны были сами программисты.
В 1954 году разработчики языка Fortran ввели явное различие между целыми и числами с плавающей запятой. Оно обозначалось первой буквой имени переменных. Это простое на первый взгляд решение оказало огромное влияние на развитие языков программирования. Несколько лет спустя в Algol 60 появились объявления идентификаторов для целых, вещественных и нулевых чисел. Этот язык стал первым, где система типов была формализована в рамках строгой языковой спецификации.
В течение 1960-х годов концепция типов появлялась во многих языках: PL/I, Pascal, Simula; к концу десятилетия статические системы типов завоевали прочные позиции в языках программирования. Правда, Algol 68 имел настолько сложную систему типов (процедуры как значения первого класса, примитивные типы, конструкты типов, правила эквивалентности…), что его считали непригодным для использования. В целом Algol сильно повлиял на разработку почти всех появившихся позже основных языков программирования. В рамках этого влияния в них появилась и статическая проверка типов.
Параллельно с этим своим путем развивался LISP. Он как раз содержал очень простую систему типов: только списки и примитивные типы данных. Эта заманчивая простота опиралась на железную теоретическую базу — лямбда-исчисление. С годами система типов LISP становилась сложнее, но не менялось главное: значения имеют типы, а переменные — нет. Этот подход стал основой для динамической типизации.
В конце 1960-х годов первый объектно-ориентированный язык Simula расширил понятие типа, включив в него классы. Экземпляры классов могли «храниться» в переменных, имеющих тип класса. Интерфейс, предоставляемый этими типами классов, состоял из объявленных процедур и связанных с ними данных. Все появившиеся далее объектно-ориентированные языки основывались на этой концепции. При этом считается, что это не объектно-ориентированный язык в исходном понимании термина, который закладывал Алан Кей. Позже он сам отмечал, что скорее стоило назвать этот подход ориентированным на сообщения, message-oriented.
В 1970-х годах под влиянием типизированной версии лямбда-исчисления развивался функциональный язык программирования ML. Это породило систему типов, которые способны статически выводить типы выражений, не требуя явных аннотаций типов. К этой категории относятся Haskell и F#. А язык Mesa ввел типизированные интерфейсы отдельно от реализации модулей — сейчас мы видим эту концепцию, например, в Java и C#.

Развитие систем типов не закончено. И даже наоборот. Некоторые исследователи убеждены, что все виды проблем в программировании можно решить с помощью продвинутых, сильных статических систем типов. А значит, появляются новые способы работы с ними. Так, как упоминалось ранее, Kotlin привнес нам Null Safety-типы, и теперь на уровне компилятора мы избавлены от целого класса ошибок — NPE (если, конечно, не работаем на границе с Java-кодом).
Главная цель этой эволюции — научиться максимально точно выражать намерения автора кода и бизнес-семантику через систему типов. А это ровно то, что даёт разработчику доменная система типов.
Проектирование по контракту (Design by contract, он же DbC)
Перед переходом непосредственно к доменным типам нужно вспомнить о таком методе проектирования, как DbC. Для целей статьи достаточно понимать, что контракты помогают выявлять ошибки на ранних стадиях. Допустим, в приложении Салют тип «Яркость лампы» может принимать значения от 0 до 1. Яркостью можно управлять через приложение. Тогда перед отправкой на бекенд мы зададим инвариант: яркость находится в ожидаемых границах, через UI-слой не пришли невалидные данные. Выглядеть это будет примерно так:
/** * Обновить температуру у устройства * @param deviceId идентификатор устройства * @param targetBrightness яркость устройства. Принимает значения от 0 до 1 */ fun updateDeviceBrightness( deviceId: String, targetBrightness: Float, ): Result { require(targetBrightness in 0f..1f) { "Значение яркости невалидно" } // делаем запрос... }
Это пример парадигмы защитного программирования. В ней сразу две проблемы:
Мы вынуждены писать неформальную спецификацию к каждому подобному методу и делать формальную верификацию спецификаций. Очевидно, яркость используется не только в нём, а значит, мы либо делаем проверки в каждом таком методе, либо только на границах системы. Это размывает ответственность по всем компонентам системы.
Клиенты такого API должны лезть в исходники и смотреть спецификации к методам.
Есть и третья проблема, внешняя: если бизнес-требования изменятся, нам предстоит большой-большой рефакторинг.
Система доменных типов наносит ответный удар
С помощью доменных типов мы переносим контракт на уровень типа. То есть вместо того, чтобы вручную проверять валидность значений у примитивного типа через ассерты, конструкции if-else или как-то ещё, создаём тип, в принципе не способный нарушить инвариант. В этом помогает паттерн из функционального программирования — smart constructor. По сути это фабрика, содержащая логику предикатов, которые предотвращают создание типа с некорректным значением и возвращают тип безопасный: Result, Maybe, Either…
Пример:
/** * Тип данных Яркость * * Область значений: [0..1] */ @JvmInline value class Brightness private constructor(val value: Float) { init { require(value in 0f..1f) } companion object SmartConstructor { fun create(brightness: Float): Result<Brightness> { if (brightness !in 0f..1f) { return Result.failure(InvalidBrightness(brightness)) } return Result.success(Brightness(brightness)) } } } class InvalidBrightness( invalidValue: Float ) : IllegalStateException("Попытка создания яркости значением: $invalidValue")
Здесь шесть важных преимуществ одновременно:
Документируемость. Система доменных типов позволяет описать специфику этого типа, связи с другими сущностями и область допустимых значений. Словом, задокументировать каждый тип.
Инвариант в блоке init. Централизует runtime-проверку при создании типа. После успешного создания типа (например, Brightness) остальной код может работать с допущением, что значение прошло валидацию (в случае яркости это область значения 0...1).
Smart constructor. Обеспечивает валидацию значений, позволяет сделать конструктор приватным и таким образом дать гарантии валидности. Есть только один способ создать тип. Однако использование рефлексии, конечно, разрушает такие гарантии.
Безопасный возвращаемый тип. Гарантирует безопасность и обяжет потребителя обработать не только позитивный сценарий создания типа, но и вариант, когда данные с точки зрения этого типа невалидны.
Доменный тип для ошибки. Обеспечивает информативность и различимость ошибок. Кроме этого, можно прямо реализовать автоматическую отправку метрик и логирование, если в системе проявилась ошибка.
Compile-time check. Исключает логическую ошибку, в которой типы перепутали местами (например, вместо temperature передали brightness).
О типах и данных
Почти все Android-приложения так или иначе работают с внешним миром: сетью, базой данных, пользовательским вводом, данными из операционной системы и так далее. Правильный подход тут — проверять данные на границах и там же создавать типы, а далее в проекте уже работать с доменными типами.
С внедрением своей системы типов в проекте мы получаем также абстрактный пойнт акцента на данных. Идея в том, что во многих случаях, рассуждая о программе, полезно думать не в терминах поведения, а в терминах данных. Например, сосредоточиться на семантических, смысловых единицах… то есть на тщательно проработанной системе типов. Такой подход открывает новый стиль разработки — через типы, type-driven development. Он тесно связан с принципом «Делайте невалидные состояния невозможными», который популяризировал Ричард Фелдман.
Так, используя строгие типы, мы можем описывать множество операций, валидных для этого типа. Допустим, у нас на UI есть показатель температуры и влажности датчика. По бизнес-требованиям нам нужно обновлять значения, только если разница между ними достигла значимого для нас различия. Логику сравнения мы могли бы описать на уровне типа.
Акцент на данных позволяет рассуждать о программе на более высоком уровне, не скатываясь без необходимости на уровень кода. Это позволяет легче и полноценнее понимать компоненты системы, следить за её сложностью. В идеале сложность системы с развитием кодовой базы должна расти линейно, а не экспоненциально. Первый шаг к этой цели — снижение цикломатической сложности за счёт строгой типизации в тех случаях, когда нам необходимо проверять значения типов.
О технических нюансах value-классов
Для создания бизнесовых сущностей в проекте мы использовали такую фичу Kotlin, как value-классы, ранее inline-классы. Но по сути мы оборачиваем в класс каждый примитив, поэтому сразу же практический вопрос: как это влияет на перфоманс? Особенно на потребляемую память за счёт дополнительных аллокаций объектов.
Рассмотрим некоторые особенности value-классов, чтобы оценить их влияние на перфоманс-метрики. Value-классы стремятся следовать концепции zero-cost abstraction за счёт того, что в рантайме происходит unboxing и подставляется значение, которое value-тип оборачивает. Возьмём для примера доменный тип и функцию, принимающую этот тип.
@JvmInline value class BannerId private constructor(val value: String) { init { require(value.isNotBlank()) } companion object Factory { fun create(id: String) = BannerId(id) } } fun doSomething(id: BannerId) { // ... }
Генерируется вот такой байткод; BannerId заменяется на обычный String. Если заменить BannerId на String, байткод останется неизменным.
public final static doSomething-XrwngDI(Ljava/lang/String;)V // annotable parameter count: 1 (invisible) @Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0 L0 ALOAD 0 LDC "id" INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V L1 LINENUMBER 16 L1 RETURN L2 LOCALVARIABLE id Ljava/lang/String; L0 L2 0 MAXSTACK = 2 MAXLOCALS = 1 }
Из интересного — к названию функции добавились символы. Это нужно для предотвращения ошибок компиляции при перегрузке функции. Мы можем сделать две функции, и благодаря манглингу код скомпилируется. Однако манглинг же может создать проблемы в некоторых DI-фреймворках и сериализаторах.
fun doSomething(id: BannerId) { // ... } fun doSomething(id: String) { // ... }
Когда unboxing не случился
В начале я сказал, что value-классы стремятся к zero-cost abstraction, однако гарантий, что в рантайме произойдет unboxing, нет. Например, если мы используем value-тип как generic в коллекциях или если наш доменный тип реализует интерфейс. Тогда объект всё-таки создастся.
Вместе с тем мы подключали к нашим доменным типам smart конструкторы, которые возвращают результат создания объекта — например, если это Either тип из Arrow. В этом случае аллокаций будет достаточно много. Однако важно учитывать: это маложивущие объекты, нельзя сохранять ссылки на них за пределами мапперов, размазывая по всей системе.
Заключение
Внедрение доменной системы типов — это не просто шаг к созданию более безопасной архитектуры проекта, но и другой уровень размышление о программе: в терминах данных.
Понимание данных и связей между ними позволяет лучше понимать проект и рассуждать о нем на качественно другом уровне. Даёт возможность переложить проверку различимости типов на компилятор, а значит, снимает эту когнитивную нагрузку с разработчиков. И помогает комфортнее использовать генеративный искусственный интеллект. Некоторые команды скармливают ИИ целый проект или части системы, чтобы тот вник в контекст и мог целенаправленно решать задачи с учетом его специфики. Доменная система типов способна помочь ему разобраться в проекте и бизнес-требованиях. По сути, доменные типы служат своего рода автоматическим промптом для ИИ, описывающим предметную область и ее сущности. Вдобавок ИИ не сможет неверно трактовать типы, путать их и менять местами — в этом случае программа просто не скомпилируется. Он лучше понимает проект и меньше галлюцинирует. Словом, мы имеем дело с простым эффективным подходом, который сейчас ещё и особенно актуален.
