Каждый раз, когда нужно добавить новую модель в проект, приходится писать буквально одинаковый код: с одинаковыми проверками, с одинаковыми корректировками, с одинаковыми Codable, с одинаковыми тестами.
Полагаю, вы тоже постоянно с этим сталкиваетесь, особенно при работе с текстом, например, вот типичный код:
struct User { let name: String // Должно быть непустым и без пробелов let awards: [Award] // Должны быть отсортированы по убыванию let progress: Double // Не может быть отрицательным init?(name: String, awards: [Award], progress: Double) { let name = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, progress >= 0 else { return nil } self.name = name self.awards = awards.sorted(by: >) self.progress = progress }}
Всего три поля — и уже куча логики в инициализаторе. Причем пример-то еще упрощенный.
И не забываем добавить Codable, ведь данные приходят с сервера:
extension User: Codable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) guard let user = User( name: try container.decode(String.self, forKey: .name), awards: try container.decode([Award].self, forKey: .awards), progress: try container.decode(Double.self, forKey: .progress) ) else { throw DecodingError.dataCorrupted(.init( codingPath: decoder.codingPath, debugDescription: "Invalid Data" )) } self = user }}
Если этого не сделать, декодер пропустит ваш init? и запишет сырые данные прямо в поля. Валидации не будет.
Этот бойлерплейт приходится писать для каждой модели. И тестировать каждый инициализатор тоже нужно: пустая строка, отрицательное число, неотсортированный массив. И вот мы уже тестируем не бизнес-логику, а то, что guard не пропустит кривые данные.
Решение
Однажды я наткнулся на композицию объектов у Егора Бугаенко cactoos и, вдохновившись, попытался перенести все в Swift. Но в итоге пошел даже дальше: перенес валидацию из рантайма в типы.
Получилась библиотека Primity. Тот же User теперь выглядит так:
struct User: Codable { let name: Name let awards: Awards let progress: Progress}extension User { typealias Name = NonEmpty<Trimmed<String>> typealias Awards = Descended<Array<Award>> typealias Progress = NonNegative<Double>}
Никаких guard, никакого ручного Codable — нет ничего лишнего и все понятно без комментариев.
Врапперы сериализуют свое содержимое напрямую. При декодировании сами вызывают свой init(_:) или init?(_:), и если данные не проходят валидацию — бросают ошибку. Вам не нужно писать init(from decoder:) руками.
Почему через typealias?
Я специально прячу все внутрь модели, чтобы не привязывать клиентский код к конкретной цепочке оберток. Клиенту не важно, что под капотом у User.Name. Если завтра я решу, что User.Name должен быть NonEmpty<Collapsed<Trimmed<String>>> вместо NonEmpty<Trimmed<String>>, все вызовы User.Name(expressing:) останутся прежними. Клиентский код не сломается, да и читается намного легче и понятнее.
// Хорошо: не важно, какие врапперы внутриif let name = User.Name(expressing: input) { ... }// Плохо: привязка к конкретной цепочке, сломается при измененияхif let name = NonEmpty(Trimmed(input)) { ... }
Как это работает
Враппер — это структура с одним полем. Она дублирует поведение вложенного значения.
Врапперы бывают двух видов:
Валидаторы проверяют входное значение и отбрасывают плохое. Возвращают опционал:
— NonEmpty — непустая строка или коллекция
— NonNegative / Positive — числовые ограничения
— Within — значение в заданных границах
Корректоры всегда принимают значение и приводят его к нужному виду:
— Trimmed, Collapsed, Ragged, Stripped— чистят пробелы, декоративные символы
— Capitalized, Lowercased, Uppercased — меняют регистр
— Sorted — сортирует
— Truncated — обрезает по длине
— Clamped — ограничивает в заданном диапазоне
Обратите внимание на названия. Корректоры — это причастия с окончанием -ed: они описывают, что уже сделано со значением (обрезано, отсортировано, ограничено). Валидаторы — прилагательные: они описывают состояние значения (непустое, положительное, внутри диапазона). Такое разделение по частям речи помогает с первого взгляда отличить «всегда пропускает, но меняет» от «проверяет и либо принимает, либо отбрасывает».
Композиция вместо конфигурации
Сложные правила собираются из простых блоков:
typealias Tag = NonEmpty<Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>>let tag: Tag? = " Swift 💙 DEVelopment 💻 "// "swift development"
Каждый слой делает одно дело. Результат — строка, которая гарантированно чистая, непустая и в нижнем регистре.
Вот еще пример, опции с вариантами ответов можно организовать так:
typealias TwoThroughNine<Value: Withinable> = Within<`2`, `9`, Value> where Value.Bound == Inttypealias AnswerOption = NonEmpty<Truncated<`32`,Collapsed<Trimmed<String>>>>typealias AnswerOptions = TwoThroughNine<OrderedSet<AnswerOption>>
Или вот еще примеры:
typealias Rating = Clamped<`1`, `5`, Int>typealias Percentage = Clamped<`0`, `100`, Int>typealias Progress = Clamped<`0.0`, `1.0`, Double>
Работа с текстом
Вот строковые комбинации, которыми я постоянно пользуюсь, меняя только названия:
typealias Title = NonEmpty<Truncated<`256`,Collapsed<Trimmed<String>>>>typealias Paragraph = NonEmpty<Truncated<`1024`,Collapsed<Trimmed<String>>>typealias Name = NonEmpty<Truncated<`64`,Collapsed<Trimmed<Stripped<String>>>>>typealias Tag = NonEmpty<Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>>
let title: Title? = " Swift Development " // "Swift Development"let name: Name? = "👋 Mia 🌍" // "Mia"let tag: Tag? = " SWIFT DeV " // "swift dev"
Порядок применения
Врапперы применяются справа налево:
typealias Tag = Lowercased<Truncated<`32`,Collapsed<Trimmed<Stripped<String>>>>>// Эквивалентная цепочкаlet tag = string .stripped() // чистим от декоративных символов .trimmed() // убираем пробелы по краям .collapsed() // схлопываем множественные пробелы в один .truncated(to: 32) // обрезаем по длине .lowercased() // приводим к нижнему регистру
Порядок иногда важен. Если сначала обрезать по длине, а потом схлопнуть пробелы, результат получится короче, чем задумано.
Удобства
Врапперы понимают литералы
let string: Trimmed<Stripped<String>> = "hello world"let array: NonEmpty<Ascended<Array<Int>>>? = [5, 1, 3, 2, 4]let double: NonNegative<Double>? = 95.97
Все это построено на базовом протоколе Expressible с init(expressing:), который вызывает вложенные инициализаторы автоматически:
typealias Paragraph = NonEmpty<Collapsed<Trimmed<RichText>>>let paragraph = Paragraph(expressing: richText)// Без этого пришлось бы писать что-то такоеlet paragraph = Paragraph(Collapsed(Trimmed(richText)))
Достать вложенное значение просто
Оператор * раскрывает любой враппер и возвращает его сырое значение. Без цепочки .value через слои:
let string = *Trimmed(Stripped("swift")) // Stringlet array = *NonEmpty(Ascended([5, 1, 3, 2, 4])) // [Int]?let double = *NonNegative(95.97) // Double?
Если предпочитаете явные методы — они по-прежнему доступны:
let string = Trimmed(Stripped("swift")).asString()let array = NonEmpty(Ascended([5, 1, 3, 2, 4]))?.asArray()let double = NonNegative(95.97)?.asDouble()// Базовый метод для всех типовlet text = Truncated(Collapsed(richText)).expressed()
Изменения без мутаций
Врапперы иммутабельны, но для базовых типов есть методы, которые возвращают новый враппер с измененными данными:
Ascended([5, 2, 1, 4, 3]) .appending(6)NonEmpty(["en": "Hello", "fr": "Bonjour"])? .setting("Привет", for: "ru")Trimmed("Jobs") .prepending("Steve ")NonNegative(16.7)? .multiplying(by: 9.4)
Методы есть для массивов, строк, словарей, сетов и чисел. Но вы также можете их добавить и для своих типов.
Проверка один раз
Главная сила — не только в том, что модель выглядит чище. Но и в том, что значение, однажды созданное враппером, можно спокойно передавать внутри системы.
Раньше метод получал значение и сразу требовал повторной проверки: пустая ли строка? Есть ли пробелы по краям? Отсортирован ли массив?
Сейчас метод принимает User.Name — и тип уже гарантирует корректность. Никаких guard внутри метода, никаких корректировок. Проверка произошла один раз, при создании, и больше не нужна нигде.
Такие гарантии сильно снижают кодовую базу. Валидация исчезает не только из моделей, но и из соответствующих методов, сервисов, контроллеров и всех тестов.
Рутинные тесты больше не нужны
Раньше каждый инициализатор требовал отдельных тестов: пустая строка, отрицательное число, неотсортированный массив. Мы тестировали не бизнес-логику, а то, что guard отработал правильно.
С врапперами эти проверки встроены в тип. NonEmpty не может содержать пустую строку, потому что компилятор не позволит создать такой экземпляр. NonNegative не может быть меньше нуля по определению типа.
Это как c Hashable — мы не пишем тесты, что словарь правильно хеширует ключи, потому что это гарантия языка. Валидация перешла из рантайма в типовую систему. Тестировать ее в своих моделях бессмысленно.
Тесты остаются только для бизнес-логики. А проверка «не пусто», «не отрицательно», «отсортировано» — это больше не ваша забота.
Codable из коробки
Враппер кодирует внутреннее значение напрямую — без оберток, без поля value. JSON остается плоским и обратно совместимым.
// Значение кодируется и декодируется напрямую"swift dev"// Без этого было бы вот так{"value":{"value":{"value":{"value":{"value":"swift dev"}}}}}
То есть врапперы продолжают работать с теми же стерилизованными данными, что были до этого. Не нужно нигде ничего менять или подстраивать.
При декодировании враппер сам вызывает свой init(_:) или init?(_:). Если данные не проходят валидацию — враппер бросает понятную ошибку: «Value must not be empty», «Value ‘6’ must be within bounds 1…5» и тому подобное.
Минусы тоже есть
Миграция существующего проекта требует времени. Нужно заменить типы в моделях, подружить код из разных частей системы, удалить ставшие лишними проверки и тесты. Да, новый функционал можно вводить поэтапно, но лучше всего сделать все разом — по модулю или по всему проекту.
На границах с другими библиотеками придется гонять типы туда-обратно: доставать значение из враппера для передачи во внешний API, оборачивать обратно на выходе. Это местами не так удобно, но терпимо — цена за гарантии внутри своего кода.
Но после перехода скорость добавления новых моделей и методов растет существенно. Меньше бойлерплейта, меньше тестов на валидацию, меньше мысленной нагрузки при чтении кода.
А что с производительностью?
Враппер — структура с одним полем. В Swift это value-type, она лежит на стеке или встроена в родителя без дополнительных аллокаций. NonEmpty<Trimmed<String>> занимает ровно столько же памяти, сколько String.
И еще, typealias — это псевдоним, а не новый тип. Если User.Name и Player.Name указывают на одну и ту же композицию, компилятор считает их одним типом и не дублирует метаданные.
На практике уникальных комбинаций мало: для строковых полей в проекте от силы три-четыре паттерна, и все модели их переиспользуют. Так что и тут оверхед копеечный.
Под капотом
Вся библиотека построена на Protocol-Oriented Programming и четырех базовых протоколах.
Все врапперы подчиняются протоколу AnyWrapping. От него разветвляются два:
— Wrapping — корректоры, инициализация всегда успешна
— MaybeWrapping — валидаторы, инициализация падает на плохих данных
То же самое с созданием из «сырого» значения: базовый AnyExpressible дает два пути:
— Expressible — для корректоров, создание всегда успешно
— MaybeExpressible — для валидаторов, создание может вернуть nil
Каждый враппер требует от вложенного значения ровно один протокол: Trimmed просит Trimmable, Capitalized — Capitalizable, Sorted — Sortable. Враппер сам ничего не делает — он лишь дублирует поведение вложенного значения.
В цепочке Capitalized<Trimmed<String>> внутренний String должен быть и Capitalizable, и Trimmable. Каждый слой добавляет одно ограничение, а компилятор собирает их вместе. NonEmpty<String> ведет себя как String, Ascended<Array<Int>> — как Array. Никакого сгенерированного кода, только протоколы и пустые расширения.
Если коротко, то каждый враппер создается по следующему шаблону:
protocol Capitalizable { func capitalized() -> Self}struct Capitalized<Wrapped>: Wrapping where Wrapped: Capitalizable { let value: Wrapped init(_ value: Wrapped) { self.value = value.capitalized() }}
Больше подробностей в README файле.
Философия дизайна
-
Один инвариант на тип — каждый примитив делает ровно одно дело
-
Ноль знаний о предметной области — никакой бизнес-логики, никаких внешних зависимостей
-
Композиция вместо конфигурации — собирайте типы в стек вместо передачи правил валидации
-
Ломаться сразу, а не потом — невалидные значения отклоняются в момент создания
Где найти
Primity выложен в открытый доступ. Внутри подробные комментарии в README и прямо в исходниках — загляните в любой файл, чтобы понять, как все устроено под капотом.
Если есть вопросы или предложения — пишите в комментариях или в issues, буду рад обсудить.
П.С. Я действительно вырезал тысячи строк валидации и тестов, так что заголовок не был кликбейтом. Из проекта на ~100 000 строк кода было вырезано ~5000: хелперы и их вызовы, проверки в инициализаторах и методах, ручные Codable-расширения, часть тестов для моделей и функций.
Модели теперь собираются просто и без какой-либо головной боли, а новый враппер, если он нужен, пишется в девять строк кода (ладно, с расширениями чуть больше). Код читается как обычный английский текст, поддерживается без усилий, и описывает сам себя.
ссылка на оригинал статьи https://habr.com/ru/articles/1043520/