Одним из наибольших преимуществ использования Scala является его типобезопасность. Если подходить к выбору типов в нашем коде осознанно и с разумной степенью осторожности, то компилятор может помочь нам найти более правильное решение или же указать, где мы можем ошибаться. Есть несколько подходов, посредством которых мы можем помочь системе типов и языку в целом дать нам еще больше уверенности в коде, который мы проектируем. Используя наши знания о Scala и типах, мы можем ограничить ID базы данных конкретным специальным типом: Теперь, когда это больше не обычная произвольной строка, у нас появился наглядный индикатор для функций, которым требуется ID базы данных. По ошибке передать в такие функции строку, предназначенную для чего-либо другого, будет труднее. Если мы позволим, Scala также может нам помочь с некоторыми другими вещами для этого DatabaseId, такими как автоматическое создание сериализаторов и десериализаторов JSON и даже автоматическая генерация тестовых данных специально для ID базы данных. Но достаточно ли этого? Можем ли мы получить больше? Что бы ни делало наше приложение, все-таки существует граница, где заканчивается наш код и начинается внешний мир, будь то приложение, которое зависит от аргументов командной строки, или HTTP-сервис, получающий данные через POST-запросы. Это не уникальная особенность Scala, это общая черта каждого приложения: интерфейс взаимодействия с внешним миром будет получать байты и отправлять байты в ответ. Внутри нашего приложения мы бы хотели преобразовать эти байты во что-то вразумительное. Языки высокого уровня уже снимают с нас часть этой задачи, предоставляя примитивные типы разного рода, например, целые числа, числа с плавающей запятой и строки. И естественно, мы хотели бы иметь больше знаний о том, что представляют собой эти значения. Хорошим первым шагом в Scala является объединение этих байтов в case классы. Но откуда мы знаем, что данные внутри этих case классов верны? Когда мы создаем case класс, нам обязательно нужно проверять, что параметры конструктора валидны. Представьте, что мы моделируем количество товаров на складе; его можно было бы, следуя вполне очевидной логике, представить как Int или Long, но мы, скорее всего, не хотели бы, чтобы значения могли быть отрицательными или, возможно, исчислялись миллиардами или даже миллионами. Наивный подход может заключаться в генерации какого-либо исключения для недопустимых данных, когда мы создаем объект: Но по-хорошему мы не хотели бы генерировать исключения во время выполнения! Вместо этого мы могли бы добавить флаг, указывающий, валидно ли это количество: Но теперь мы возлагаем на пользователя этого класса ответственность за проверку того, что они ссылаются на isValid в любое время, когда им нужно его использовать. Если они вдруг забудут про нее или неправильно поймут логику, то компилятор им не поможет. Забавно, но первый подход с проверкой во время выполнения был ближе к хорошему решению, чем подход с флагом: когда мы создаем инстанс, вместо этого мы даем пользователю другой объект, который может представлять невалидную конструкцию. С case классами компилятор сам пишет для нас много полезного шаблонного кода, позволяя нам рассуждать о нашем коде, не распыляясь на рутинные задачи. Компилятор добавляет интуитивно понятный метод equals, полезный toString, а также apply для упрощения создания и unapply для использования в сопоставлениях с шаблоном. На возвращаемый тип apply никаких ограничений нет. Если мы напишем функцию с именем apply, компилятор не создаст ее для нас сам. Мы можем просто предоставить в объекте-компаньоне свой собственный вариант, который будет возвращать другой тип, и создавать объект в нем. Возвращаясь к нашему DatabaseId, предположим, что ID базы данных должен содержать ровно 12 символов: Заметьте, что здесь у нас так и чешутся руки вызвать new DatabaseId, но если опустить new, то выполнение будет передано apply, а это именно та функция, которую мы пишем! Какой бы полезной она не была, эту проверку все же можно обойти; ничто не мешает нам самим создавать case класс с помощью new. Как бы мы ни доверяли нашим коллегам, как бы сильно ни верили, что они будут делать все правильно и всегда будут создавать объект так, как мы задумали, было бы лучше, если бы мы могли защититься от случайного вызова new. Или вы на 100% уверены, что в 100% случаев обнаружите это во время код-ревью? Мы можем сделать конструктор приватным. И пока мы еще находимся здесь, давайте заодно сделаем этот класс final, чтобы никто не мог создавать его производные классы и таким образом обходить нашу проверку: Теперь мы просто не можем не использовать функцию apply, ведь значение, создаваемое с использованием new, больше не будет компилироваться. Мы все еще можем создавать невалидные объекты, используя метод copy: Итак, аналогично тому, что мы сделали с apply, мы предоставим свой собственный метод copy. На этот раз в самом классе, а не в объекте, поскольку мы вызываем copy уже для инстансов. И мы также сделаем его private, так как хотим указать, что просто нет никакой необходимости когда-либо вызывать этот метод: Теперь у нас нет возможности создать DatabaseId в обход нашей проверки. Другие подходы, на которые мы, возможно, захотим обратить внимание, — это сделать приватным и метод apply, а затем предоставить более наглядный API для указания того, что вообще происходит. Полностью код теперь будет выглядеть так: А значения будут создаваться так: У нас все еще остались все преимущества работы с case классом, и при этом мы добавили себе уверенности в том, что все, что содержит наш case класс, будет валидно, а значит и полезно. Теперь, когда мы не можем создать голый DatabaseId, может сложиться впечатление, что это выльется в серьезную головную боль при тестирования с литеральными значениями. Но на самом деле нам будет довольно легко работать, не теряя типобезопасности. Мы всеголишь провалим тест, если сконструируем недопустимое литеральное значение. Учитывая, что мы создаем его вручную, при написании теста это должно стать понятно очень быстро: Даже со Scalacheck мы можем быть уверены в создаваемых нами значениях: Простой спровоцируйте сбой генератора недопустимым состоянием, и он попробует снова. Теперь, когда мы используем это в наших тестах, мы получаем DatabaseId без отвлекающего Option. Вместо Option, используйте Either и укажите, почему проверка пошла не так, как надо. Тип Either имеет полезную функцию, сочетающую логическую проверку с действиями как в истинном, так и в ложном случаях. Это даст пользователям кода наглядный API для того, чтобы создавать объекты и легко понимать, что может пойти не так, при этом сохраняя типобезопасность: Это можно использовать с функционалом validated от Cats для объединения сразу нескольких проверок в функциональном стиле. Когда Scala был представлен широкой публике, case классы рекламировались как хороший отход от шаблонных “POJO” подходов Java, позволяющий разработчику определять объект предметной области в одной строке и при этом получать все те же преимущества, что и их аналог в Java. Здесь нам все-таки нужно вернуть некоторую степень шаблонности, чтобы получить немного больше уверенности в том, что значения, которые мы создаем для конкретного типа, корректны. Я думаю, это оправданная цена: вся логика валидации в одном месте, вместо того, чтобы быть разбросанной по кодовой базе и надежд на то, что пользователи вашего типа используют ее правильно. Недопустимые типы просто невозможно создать — это хорошо, когда не нужно беспокоиться о недопустимых состояниях. Приглашаем всех желающих на demo-занятие «REST API при помощи HTTP4S и ZIO». На примере построения простого веб сервиса с REST API разберем основные компоненты (пути, бизнес логика, доступ к данным, документация), а также посмотрим, как взаимодействуют такие функциональные библиотеки, как http4s, cats, zio в рамках одного приложения. Регистрация здесь. Переводчик, редактор