1. Зачем нужны value objects
Если честно, String и Int — как универсальный скотч: ими можно заклеить всё. И именно поэтому они опасны. Когда вы храните идентификатор книги как String, год как Int, а “что-то похожее на ISBN” тоже как String, компилятор смотрит на это и говорит: “Окей, это всё просто строки и числа, я не вижу разницы”. А вы разницу видите — но компилятор, увы, не телепат.
Представьте, что вы пишете мини‑приложение “библиотека” (консольное, без UI‑магии). У вас есть книга, и вы хотите гарантировать простые вещи: что id не пустой, что год не отрицательный, что “ISBN‑похожая строка” действительно похожа на ISBN. Если вы храните всё это примитивами, вы каждый раз обязаны помнить: “ага, тут надо проверить”, “ага, тут надо нормализовать”, “ага, тут дефисы убрать”. Один раз забудете — и в коллекции появится “книга” с id " " и годом -500. И формально программа будет работать… пока не начнёт загадочно ломаться в самом неудобном месте.
Давайте зафиксируем идею в маленькой табличке:
| Что мы храним | Как выглядит “просто” | Чем это плохо | Как выглядит “надёжно” |
|---|---|---|---|
| ID книги | |
может быть пустым/с пробелами/в другом формате | |
| Год | |
может быть отрицательным/слишком большим | |
| ISBN‑похожая строка | |
может содержать буквы/не ту длину | |
Value object — это “маленький тип”, который оборачивает один примитив и не позволяет существовать некорректным значениям. То есть вы не “вспоминаете проверить” каждый раз — проверка становится частью создания значения.
2. Value object в Swift: три правила
Value object звучит серьёзно, но на практике это очень приземлённая техника. Это маленький struct, который хранит одно значение (обычно String или Int) и гарантирует инварианты: набор правил, без которых объект не имеет смысла. Например, “BookID начинается с "B-" и дальше только цифры”. Или “год находится в адекватном диапазоне”.
Чтобы value object реально работал, полезно держаться трёх правил: нормализуем ввод, проверяем правила, сохраняем канонический вид. “Канонический вид” — это просто единый стандарт хранения: если на входе допускаются дефисы или лишние пробелы, внутри мы храним уже очищенную версию, чтобы потом не сравнивать “одно и то же” в разных написаниях.
Ниже — схема типичного init? для value object. Здесь нет магии: просто дисциплина.
flowchart TD
A[Сырой ввод] --> B[Нормализация]
B --> C{Проверки инвариантов}
C -->|не прошли| D[return nil]
C -->|прошли| E[Сохраняем канонический вид]
И ещё один важный принцип: value object почти всегда должен быть неизменяемым снаружи. То есть stored property чаще всего будет let, а не var. Иначе вы создадите “валидный” объект, а потом кто‑то сделает obj.value = "что угодно" — и инвариант улетит в отпуск.
3. BookID: ID книги как отдельный тип
ID книги — отличный кандидат для value object: это маленькая вещь, которая нужна везде, и если она сломается, всё вокруг начнёт вести себя странно. Поэтому мы делаем BookID таким, чтобы “плохой” ID физически нельзя было создать.
Придумываем простой формат
Чтобы не уходить в фанатизм, договоримся о формате:
- BookID должен начинаться с "B-";
- дальше идут только цифры;
- пробелы по краям разрешаем во вводе, но не храним.
Пример валидных ID: "B-1", "B-42", "B-0007".
Пример невалидных: "", " ", "C-1", "B-", "B-12A".
Реализация BookID через init?
Внутри init? удобно использовать guard, потому что он читается как “если не ок — сразу выходим”.
import Foundation
struct BookID {
let value: String
init?(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("B-") else { return nil }
let suffix = trimmed.dropFirst(2) // после "B-"
guard !suffix.isEmpty else { return nil }
for ch in suffix {
guard ch.isNumber else { return nil }
}
self.value = trimmed
}
}
Обратите внимание на важную мелочь: suffix — это Substring. Мы его не сохраняем, мы его только проверяем. В stored property мы кладём нормальный String, и это хорошо (про Substring ещё поговорим ближе к концу).
Быстрая проверка в “песочнице”
let ok = BookID(" B-42 ")
let bad = BookID("B-12A")
print(ok?.value ?? "nil") // B-42
print(bad?.value ?? "nil") // nil
Смысл этой проверки не в том, что мы “вывели nil”. Смысл в том, что теперь во всей программе BookID либо существует корректный, либо не существует вообще. И это снимает кучу проверок в других местах.
4. Year: год — это не “любое число”
Год в приложениях часто хранится как Int, а потом внезапно кто‑то добавляет книгу с годом -1 (или 1000000, потому что “я случайно лишний нолик ввёл”). У вас может быть вопрос: “да ладно, я же могу проверить один раз перед сохранением”. Можете. Но практика показывает, что “один раз” превращается в “везде”.
Value object Year позволяет сделать так: если год создан — он адекватный.
Реализация Year
Договоримся о простом правиле: год от 0 до 9999. Это не “исторически идеально”, зато практично и понятно для новичков.
struct Year {
let value: Int
init?(_ value: Int) {
guard (0...9999).contains(value) else { return nil }
self.value = value
}
}
Создаём Year из ввода пользователя
Раз мы в консольном мире, почти всегда год приходит строкой. Мы делаем цепочку: readLine() → Int(...) → Year(...).
print("Введите год издания:")
let line = readLine() ?? ""
let yearNumber = Int(line) ?? -1
let year = Year(yearNumber)
print(year?.value ?? -1) // если ввели "2020" -> 2020, если "abc" -> -1
Тут есть “учебный” момент: Int(line) может вернуть nil, а Year(...) может вернуть nil по своему правилу. И это нормально: мы не заставляем программу притворяться, что ввод всегда хороший.
5. ISBN13Like: “ISBN‑похожий” тип без фанатизма
ISBN — это отдельная вселенная (контрольные суммы, группы, издатели…). Но нам сегодня не нужно строить настоящую систему проверки ISBN. Наша задача проще: сделать тип, который гарантирует хотя бы базовую форму, чтобы мы не хранили в “ISBN” строку "котик-123".
Мы сделаем “ISBN‑похожий” формат:
- допускаем дефисы во вводе (потому что люди любят дефисы);
- внутри храним только цифры;
- длина должна быть ровно 13 цифр.
Реализация ISBN13Like
import Foundation
struct ISBN13Like {
let digits: String // канонический вид: только 13 цифр
init?(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let cleaned = trimmed.replacingOccurrences(of: "-", with: "")
guard cleaned.count == 13 else { return nil }
for ch in cleaned {
guard ch.isNumber else { return nil }
}
self.digits = cleaned
}
}
Проверяем на примерах
let a = ISBN13Like("978-1-4028-9462-6")
let b = ISBN13Like("9781402894626")
let c = ISBN13Like("978-ABC-894626")
print(a?.digits ?? "nil") // 9781402894626
print(b?.digits ?? "nil") // 9781402894626
print(c?.digits ?? "nil") // nil
Обратите внимание, как красиво работает канонизация: два разных ввода (с дефисами и без дефисов) превращаются в одно и то же значение digits. Это потом очень упрощает сравнение и поиск.
6. Модель Book с value objects
Очень важно не просто создать три отдельных типа и любоваться ими, как котиками в интернете, а реально встроить их в нашу модель. Иначе value objects останутся “демо‑кодом”.
Представим, что у нас была модель книги примерно такая:
struct Book {
let id: String
let title: String
let year: Int
}
Проблема здесь в том, что Book(id: "", title: "…", year: -5) компилируется и создаётся без вопросов. Давайте улучшим дизайн.
Новая модель Book
struct Book {
let id: BookID
let title: String
let year: Year
let isbn: ISBN13Like?
}
Теперь “плохой” Book гораздо сложнее создать случайно, потому что вы сначала должны создать корректные BookID и Year.
Удобный init? для Book
Мы не хотим заставлять вызывающий код собирать книгу как конструктор LEGO из пяти коробок. Пусть Book сам попробует “собраться” из сырых данных.
import Foundation
struct Book {
let id: BookID
let title: String
let year: Year
let isbn: ISBN13Like?
init?(idRaw: String, titleRaw: String, yearRaw: String, isbnRaw: String) {
guard let id = BookID(idRaw) else { return nil }
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return nil }
guard let yearInt = Int(yearRaw), let year = Year(yearInt) else { return nil }
let isbn = ISBN13Like(isbnRaw) // может быть nil — и это ок
self.id = id
self.title = title
self.year = year
self.isbn = isbn
}
}
Здесь мы делаем важную вещь: isbn допускается как опциональное поле. Это реалистично: не у всех книг есть ISBN (или пользователь его не ввёл). Value object не обязан превращать жизнь в бюрократию.
Мини‑сценарий: читаем книгу из консоли
print("id:")
let idRaw = readLine() ?? ""
print("title:")
let titleRaw = readLine() ?? ""
print("year:")
let yearRaw = readLine() ?? ""
print("isbn (можно пусто):")
let isbnRaw = readLine() ?? ""
if let book = Book(idRaw: idRaw, titleRaw: titleRaw, yearRaw: yearRaw, isbnRaw: isbnRaw) {
print("OK: \(book.title), id=\(book.id.value), year=\(book.year.value)")
} else {
print("Книга не создана: проверьте ввод")
}
Да, это пока простая логика. Но обратите внимание: валидация размазана не по всей программе, а сосредоточена в конструкторах value objects и в одном init? у Book.
7. Полезные нюансы
Почему не храним Substring в stored properties
На этом месте хочется сказать: “Да какая разница, Substring же тоже строка”. Разница есть — и она иногда неприятная.
Substring в Swift часто является “окном” в исходную строку. Если вы вырезали маленький кусочек из огромной строки и сохранили его как Substring, вы потенциально можете удерживать в памяти всю исходную строку. Поэтому практическое правило для начинающих простое: в stored properties держим String, а Substring используем только временно.
Ещё одна подсказка, почему Swift относится к строкам внимательно: даже операции низкоуровневого доступа вроде withUTF8 отдельно предупреждают, что переданный буфер нельзя “утащить” и хранить где-то дальше, потому что строка может быть представлена как small-string или временное пространство, которое потом станет невалидным.
Именно поэтому в BookID мы делали let suffix = trimmed.dropFirst(2) (это Substring), проверяли символы, а сохраняли итог как String (self.value = trimmed), то есть в “взрослом” виде.
Удобная печать: CustomStringConvertible
Когда вы начинаете активно использовать value objects, вам быстро захочется удобно их печатать в print(), чтобы отлаживаться проще. В Swift для этого часто делают конформанс к CustomStringConvertible: добавляют computed property description.
Формально это “про протоколы”, но вы уже встречали эту идею ранее, а сама техника очень практичная. Кстати, конформанс можно оформить как прямо в типе, так и через extension — и это считается нормальным стилем.
Пример для BookID:
extension BookID: CustomStringConvertible {
var description: String { value }
}
И тогда:
let id = BookID("B-42")
print(id ?? "nil") // B-42
Главное — не превращать description в источник логики. Это лишь удобное представление для вывода.
8. Типичные ошибки при проектировании value objects
Ошибка №1: оставить var и дать внешнему коду ломать инвариант.
Иногда новичок делает value object, но поле оставляет изменяемым: var value: String. В итоге можно создать корректный BookID("B-1"), а потом спокойно присвоить value = "ой" и разрушить весь смысл типа. Если у типа есть инвариант, хранение почти всегда должно быть через let.
Ошибка №2: хранить “как ввели”, а не “как договорились хранить”.
Если вы разрешили дефисы во входе для ISBN13Like, но храните строку с дефисами как есть, у вас появятся два “одинаковых” ISBN, которые на самом деле разные строки. Потом вы начнёте сравнивать, искать, делать contains — и внезапно ничего не находится. Канонический вид (например, только цифры) предотвращает эту боль.
Ошибка №3: перемешать нормализацию и проверки так, что правила становятся неочевидными.
Частая картина: часть guard до trimmingCharacters, часть после, где-то дефисы убрали, где-то забыли. Через неделю вы сами будете читать этот init? как чужой код (а это самое страшное). Спасает простое правило: сначала нормализация, потом проверки, потом присваивание stored properties.
Ошибка №4: сохранять Substring в stored property “потому что так получилось”.
Даже если код компилируется, Substring может вести к лишнему удержанию памяти, потому что часто ссылается на исходную строку. Плюс, в Swift у строк есть тонкости представления в памяти, и многие API явно предупреждают “не уносите указатели/буферы наружу”. Поэтому для состояния выбирайте String, а Substring держите как временный инструмент.
Ошибка №5: делать value object “комбайном на все случаи жизни”.
Иногда хочется добавить в BookID и парсинг команд, и печать в разных форматах, и генерацию новых ID, и поиск в базе… В итоге маленький тип превращается в монстра. Value object хорош именно тем, что он маленький: он гарантирует инвариант и хранит значение в каноническом виде. Всё остальное лучше держать в других местах программы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ