JavaRush /Курсы /Swift SELF /Value objects: BookID, Year и “ISBN‑похожий” тип

Value objects: BookID, Year и “ISBN‑похожий” тип

Swift SELF
28 уровень , 2 лекция
Открыта

1. Зачем нужны value objects

Если честно, String и Int — как универсальный скотч: ими можно заклеить всё. И именно поэтому они опасны. Когда вы храните идентификатор книги как String, год как Int, а “что-то похожее на ISBN” тоже как String, компилятор смотрит на это и говорит: “Окей, это всё просто строки и числа, я не вижу разницы”. А вы разницу видите — но компилятор, увы, не телепат.

Представьте, что вы пишете мини‑приложение “библиотека” (консольное, без UI‑магии). У вас есть книга, и вы хотите гарантировать простые вещи: что id не пустой, что год не отрицательный, что “ISBN‑похожая строка” действительно похожа на ISBN. Если вы храните всё это примитивами, вы каждый раз обязаны помнить: “ага, тут надо проверить”, “ага, тут надо нормализовать”, “ага, тут дефисы убрать”. Один раз забудете — и в коллекции появится “книга” с id " " и годом -500. И формально программа будет работать… пока не начнёт загадочно ломаться в самом неудобном месте.

Давайте зафиксируем идею в маленькой табличке:

Что мы храним Как выглядит “просто” Чем это плохо Как выглядит “надёжно”
ID книги
String
может быть пустым/с пробелами/в другом формате
BookID
Год
Int
может быть отрицательным/слишком большим
Year
ISBN‑похожая строка
String
может содержать буквы/не ту длину
ISBN13Like

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 хорош именно тем, что он маленький: он гарантирует инвариант и хранит значение в каноническом виде. Всё остальное лучше держать в других местах программы.

1
Задача
Swift SELF, 28 уровень, 2 лекция
Недоступна
Год издания
Год издания
1
Задача
Swift SELF, 28 уровень, 2 лекция
Недоступна
ID книги
ID книги
1
Задача
Swift SELF, 28 уровень, 2 лекция
Недоступна
Почти ISBN
Почти ISBN
1
Задача
Swift SELF, 28 уровень, 2 лекция
Недоступна
Сборка книги
Сборка книги
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ