Как стать автором
Обновить
VK
Технологии, которые объединяют

Почему я против enum

Время на прочтение24 мин
Количество просмотров44K

Меня зовут Саша Терентьев, я из команды ленты ВКонтакте. В этой статье поделюсь мыслями о проблемах кода, где используются enum и сопоставления типов. Часто встречаю такой код в проектах, ресурсах, примерах. Мы обсуждали это с коллегами на внутреннем событии, и из моего доклада выросла эта статья.

Букв будет много. Но, думаю, материал пригодится широкому кругу разработчиков — и не только iOS. Примеры основаны на псевдо-Swift и написаны по мотивам использования UIKit. Но могут пригодиться для работы с разными платформами и в любой области, где возникают сопоставления с образцом, приведения типов, переборы множеств типов.

О чём на самом деле статья

Название «Почему я против enum» выбрано, чтобы привлечь внимание :) 

На самом деле я против переборов вариантов — в коде они обычно встречаются в таком виде:

  • switch case;

  • if … else if … else if … else if …;

  • as?;

  • isKindOf.

Об этом своём пунктике и расскажу в статье. Ещё подсвечу проблемы и покажу, что код бывает масштабируемым и поддерживаемым. И разберёмся, как можно не использовать enum.

Вернёмся к основной мысли. Если ещё конкретнее, я против сопоставления с образцом (pattern matching) более одного раза для одного множества вариантов (Setenum). 

Важно: если мы не получаем перебираемое множество извне*, следует вообще избежать переборов. 

* Извне — имеем в виду, что получаем множество или его произвольный элемент из другого слоя логики; из сети; из хранилища; из другого модуля, код которого мы не контролируем; и так далее.

Выделим сценарии, когда возникают переборы вариантов или ветвей исполнения.

Сценарии, которые рассмотрим

Гетерогенный список

Как гетерогенный список будем определять коллекцию разнотипных элементов. В любой момент каждый из элементов может иметь один из множества типов. Множество типов может (и скорее всего будет) меняться по мере развития проекта.

Множество событий

Подписка на обработку множества событий или события, имеющего множество вариантов (подтипов).

Множество конфигураций

Множество вариантов конфигурации каких-то объектов: стили текста, стили кнопок и так далее.

Гетерогенный список и множество событий разберём на примере реализации новостной ленты, а затем отдельно рассмотрим проблемы и решения в сценарии с множеством конфигураций.

Код в примерах зачастую будет схематическим, его задача — донести идеи и показать изменения логики.

Если хотите пропустить такие примеры с проблемами, можно перейти сразу к решениям без лишних переборов. Но полезно будет посмотреть все варианты кода — они довольно распространённые и могут встретиться в проектах.

Привычное решение для ленты (и его проблемы)

Гетерогенный список с множеством событий

Чтобы было попроще, рассмотрим ленту из двух типов элементов: «запись» и «блок клипов». Массив элементов в нашем понимании как раз и является гетерогенным списком.

Элементы

Начнём с описания данных. Думаю, многим на ум приходит такой код:

enum Feed {
    case post(Post)
    case clips(Clips)
}

extension Feed {
    struct Post {
        let text: String
    }
}

extension Feed {
    struct Clips {
        struct Clip {
        }
        let clips: [Clip]
    }
}

Конкретную структуру клипа не будем рассматривать.

Загрузка данных

Теперь следует добавить десериализацию элементов, чтобы мы могли формировать сущности из данных от сервера, с диска или ещё откуда-то. Иначе говоря, из другого слоя.

struct FeedItem: Decodable, Equatable {
    enum FeedType: String, Decodable {
        case post = "post"
        case clips = "clips"
    }
    let type: FeedType
    
    let content: Content
    
    enum CodingKeys: String, CodingKey {
            case type
            case post
            case clips
        }
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        type = try values.decode(FeedType.self, forKey: .type)
        switch type {
        case .post:
            content = .post(try values.decode(Content.Post.self, forKey: .post))
        case .clips:
            content = .clips(try values.decode(Content.Clips.self, forKey: .clips))
        }
    }
}

extension FeedItem {
    enum Content {
        case post(Post)
        case clips(Clips)
    }
}

extension FeedItem.Content {
    struct Post: Decodable {
        static var feedType: String { return "post" }
        let text: String
    }
}

extension FeedItem.Content {
    struct Clips: Decodable {
        static var feedType: String { return "clips" }
        struct Clip: Decodable {
        }
        let clips: [Clip]
    }
}

Кое-что уже хочется заметить в плане сопоставлений с образцом. В таком простом коде уже есть один перебор типов элементов, а также приведения типов (в декодировании полей элементов).

Чтобы оценивать прогресс развития проекта, заведём счётчики:

item enumerations: 1
casts: 4

Отображение

Данные не будут существовать только в памяти устройства. Мы хотим, чтобы пользователь их увидел.

Для отображения будем использовать привычную коллекцию из UIKit. Опустим реализацию ячеек для элементов, рассмотрим вариант реализации источника данных для коллекции.

class FeedListDataSource: NSObject, UICollectionViewDataSource {
    var items: [FeedItem] = []

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 1
    }

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return items.count
    }

    func item(at indexPath: IndexPath) -> FeedItem {
        return items[indexPath.section]
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        switch item(at: indexPath).content {
        case .post(let post):
            return PostCell(post)
        case .clips(let clips):
            return ClipListCell(clips)
        }
    }
}

Что с нашими счётчиками развития проекта?

item enumerations: 1 -> 2
casts: 4

Другой вариант реализации — общая ячейка, которая уже в себе проверяет тип поступающего элемента. Такой вариант не меняет характеристики проекта, которые мы отслеживаем, идентично перебору вариантов в контейнере: код просто «уезжает» из FeedListDataSource в ячейку. 

Reuse

Можно заметить, что в нашем примере ячейки создаются каждый раз, когда нужно показать какой-то элемент. Обычно отображение списков реализуется не так, а с переиспользованием ячеек. 

Детали того, как работают коллекции, переиспользование, layout и UIKit, можно узнать в моей статье «Сложные отображения коллекций в iOS: проблемы и решения на примере ленты ВКонтакте».

Сейчас сфокусируемся на более абстрактных вещах, не будем лезть в детали UIKit. Вот возможный код переиспользования ячеек:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    switch item(at: indexPath).content {
    case .post(let post):
        let cell =  collectionView.dequeueReusableCell(withReuseIdentifier: "post", for: indexPath) as! PostCell
        cell.post = post
        return cell
    case .clips(let clips):
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "clips", for: indexPath) as! ClipListCell
        cell.clips = clips
        return cell
    }
}

Здесь код очень привычный, но всё же в нём есть приведение типов ячеек:

item enumerations: 2
casts: 4 -> 6

Нажатия

Это последний аспект отображения, который рассмотрим. Возможны разные варианты — мы разберём реализацию с отдельной сущностью, которая будет открывать экраны постов и клипов в зависимости от типов данных:

protocol FeedRouter {
    func route(_ item: FeedItem)
}

class FeedRouterBase: FeedRouter {
    func route(_ item: FeedItem) {
        switch item.content {
        case .post(let post):
            routePost(post)
        case .clips(let clips):
            routeClips(clips)
        }
    }

    func routePost(_ post: FeedItem.Content.Post) {
        // открыть с частичными данными из ленты
    }

    func routeClips(_ clips: FeedItem.Content.Clips) {
        // перебросить в другую вкладку
    }
}

В коде коллекции можно добавить:

var router: FeedRouter

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    router.route(item(at: indexPath))
}

Вернёмся к счётчикам:

item enumerations: 2 -> 3
casts: 6

На этом пока отвлечёмся от отображения и перейдём к обработке событий.

Обработка событий

Представим, что наша задача — дать пользователю возможность взаимодействовать с записями в ленте. При этом один элемент у нас может отображаться в разных экранах приложения единовременно. Например, экран записи можно открыть из ленты — и в этот момент в памяти будет находиться и экран ленты с постом, и отдельный экран этой публикации. В более сложных приложениях может быть ещё больше экранов, содержащих одинаковые данные. Необходимо сделать так, чтобы изменения отображались на всех таких экранах. 

Поэтому пропустим обработку действий в рамках одного экземпляра элемента и сразу перейдём к решению распространения информации (Broadcast) «один ко многим». Пример такого подхода — использование Notification.

Для нашего кода введём сущность «Действие» («Событие»). Здесь может появиться желание заложить множество действий и написать такой код:

enum FeedItemAction {
}

Например, дадим пользователю возможность помечать элемент ленты как неинтересный и скрывать его.

enum FeedItemAction {
    case notInterested(FeedItem)
}

struct FeedItemActionHandler {
    func handle(_ action: FeedItemAction, items: [FeedItem]) -> [FeedItem] {
        return items.compactMap { item in
            switch action {
            case .notInterested(let feedItem):
                return feedItem == item ? nil : item
            }
        }
    }
}

Здесь всё просто: выбрасываем из списка элементы, к которым применено такое действие.

Обновляем счётчики:

item enumerations: 3
casts: 6
action enumerations: 1

Кажется, что в коде обработки события не появилась явная проверка типа элемента, поэтому и счётчик item enumerations (счётчик переборов элементов) не увеличился. Но на самом деле это не так. Ведь от нас теперь требуется прямое сравнение элементов ленты:

extension FeedItem {
    enum Content: Equatable {
        static func == (lhs: FeedItem.Content, rhs: FeedItem.Content) -> Bool {
            switch (lhs, rhs) {
            case (.post(let post1), .post(let post2)):
                return post1 == post2
            case (.clips(let clips1), .clips(let clips2)):
                return clips1 == clips2
            default:
                return false
            }
        }

        case post(Post)
        case clips(Clips)
    }
}

item enumerations: 3 -> 4
casts: 6
action enumerations: 1

Добавим ещё возможность жаловаться на записи, если пользователя смущает текст. Но только на них, а не на блок с рекомендациями клипов — логически к нему это действие неприменимо.

enum FeedItemAction {
    case notInterested(FeedItem)
    case banPost(FeedItem.Content.Post)
}

struct FeedItemActionHandler {
    func handle(_ action: FeedItemAction, items: [FeedItem]) -> [FeedItem] {
        return items.compactMap { item in
            switch action {
            case .notInterested(let feedItem):
                return feedItem == item ? nil : item
            case .banPost(let post):
                return item.content == .post(post) ? nil : item
            }
        }
    }
}

Что со счётчиками?

item enumerations: 4
casts: 6 
action enumerations: 1

Кажется, всё ок, счётчики не увеличились. Но здесь у нас проявилась новая особенность кода:

case .banPost(let post):
    return item.content == .post(post) ? nil : item
}

Учитывая сравнение элементов, этот код можно представить по-другому:

switch action {
case .banPost(let post):
    switch item.content {
    case .post(let itemPost):
        return post == itemPost ? nil : item
    case .clips(_):
        return item
    }
}

Здесь в обработке скрытия записи, грубо говоря, прячется обработка события скрытия записи для блока клипов. Так что можно добавить ещё один счётчик:

item enumerations: 4 -> 5
casts: 6 
action enumerations: 1
сравнение логически не связанных объектов: 1

Итог текущего решения

Взглянем на счётчики:

item enumerations: 5
casts: 6 
action enumerations: 1
сравнение логически не связанных объектов: 1

Даже для такого мелкого примера мы уже видим пять переборов типов элементов. А при развитии проекта велика вероятность, что множество этих типов изменится: при добавлении, удалении, обновлении существующих элементов ленты. Всё это приведёт к изменению кода минимум в пяти местах, притом что логика таких общих мест в действительности не меняется. Также неизбежно увеличится число приведений типов (например, для отображений).

Ещё есть вопрос, который подсветит другую проблему решения с enum:

А что, если мы хотим дать коллегам возможность реализовывать свои продуктовые элементы ленты в других модулях?

Взглянем на реализацию наших сценариев по-другому.

Реализация ленты без лишних переборов

Попробуем решить поставленные задачи, избегая переборов, а также сопоставления, приведения и проверки типов.

Гетерогенный список

Сначала нужно ответить на вопрос: что мы вообще хотим от элементов ленты?

Допустим, ответом будет такой список действий элементов:

  • загрузка,

  • отображение,

  • открытие на отдельном экране.

Этот список будет контрактом-протоколом для всех элементов ленты:

protocol FeedItem {
}

Загрузка

Может возникнуть желание добавить какой-нибудь метод parse в объявленный протокол. Но у нас ещё нет экземпляра элемента. Сделать метод статичным? Есть другое решение — переложить ответственность за создание элементов на отдельную сущность:

struct FeedItemParser {
    enum CodingKeys: String, CodingKey {
        case type
        case post
        case clips
    }

    func parse(decoder: Decoder) throws -> FeedItem {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decode(String.self, forKey: .type)
        switch type {
        case CodingKeys.post.rawValue:
            return try values.decode(FeedPost.self, forKey: .post)
        case CodingKeys.clips.rawValue:
            return try values.decode(FeedClips.self, forKey: .clips)
        default:
            throw
        }
    }
}

Заметим, что у нас нет необходимости накладывать общие ограничения на десериализацию элементов — так что можем реализовать создание типов элементов по-разному, не только через Decodable. Также можно сделать парсер-агрегатор, в который по ключам типов элементов будут регистрироваться парсеры элементов.

Но не будем вдаваться в эти подробности и просто используем Decodable.

Начнём снова вести счётчики:

enumerations: 1
Возможность явной обработки кривого ключа в парсере

От текущего перебора множества не будем избавляться, так как мы попали в случай, когда гетерогенный список приходит извне. Приведения типов в рамках парсинга тоже опустим, так как на них сложно влиять. Идём дальше.

Отображение

После загрузки и десериализации у нас есть конкретные экземпляры элементов ленты. Так что можем добавлять требующееся поведение в протокол элементов:

protocol FeedItem {
    func render() -> UIView
}

И реализуем:

struct FeedPost: FeedItem {
    func render() -> UIView {
        return PostView(self)
    }
}

struct FeedClips: FeedItem {
    func render() -> UIView {
        ClipListView(self)
    }
}

Реализацию конкретных отображений элементов опустим. Теперь можем вернуться к заполнению ячейки коллекции:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "item", for: indexPath)
    let itemView = item(at: indexPath).render()
    itemView.tag = 0xEB0B0
    cell.viewWithTag(0xEB0B0)?.removeFromSuperview()
    cell.addSubview(itemView)
    return cell
}

Можно заметить, что здесь переиспользуются только ячейки, но при этом экземпляры UIView элементов постоянно создаются заново.

Чтобы реализовать переиспользование отображений и избежать переборов их типов, нужно немного отвлечься и подумать над более общим вопросом:

Почему вообще возникает необходимость что-то сопоставлять или перебирать?

Причины сопоставлений

Попробуем обобщить случаи, в которых возникает код сопоставления типов элементов или другой перебор сценариев.

  • Объекты с разными жизненными циклами

Связывание объектов, имеющих несвязанные (различные) жизненные циклы.

→ Пример: сопоставление переиспользуемой ячейки и данных для неё.

Жизненный цикл ячейки управляется ReusePool. Чтобы упростить, жизненный цикл данных можно ограничить состоянием между изменениями коллекции-контейнера. Ячейки и данные нужно соединять между собой чаще, чем все эти объекты создаются.

  • Реакция на действие в момент, когда оно происходит

Имеем в виду процедурную обработку сигналов: не декларативную, когда заранее регистрируется обработчик, а именно когда в момент происхождения какого-то UI-события мы выбираем, какой обработчик (метод) бизнес-логики использовать.

  • Отсутствие у объекта сценария (и зависимостей) действия при возникновении сигнала

Это обобщение предыдущего пункта для любых событий: если в момент возникновения события у нас нет готового конкретного сценария обработки (ссылки на конкретный код сценария) — нам приходится перебирать обработчики.

  • Вездесущие контейнеры с несамодостаточными элементами

Часто в коде можно встретить очень сложный контейнер (какой-нибудь MassiveListViewController или MassiveListModel). И он содержит элементы, которые почти ничего не умеют делать (обычно только хранят данные). Massive-контейнер пытается обработать практически все действия, которые могут произойти с любым из элементов. В таком контейнере могут возникнуть проверки вложенных элементов, поиск элементов, их сравнение и так далее.

  • Вычисление состояния из чужого кода

Речь идёт о вычислении состояния по чужому инструменту, код которого вам не принадлежит и вами не контролируется. Чужой инструмент может быть не рассчитан на хранение данных, которые нужны только вам и зависят только от вашей предметной области.

→ Пример: вычисление бизнес-данных по иерархии UIViewController или UIView.

Симптомом может быть появление в коде поиска конкретного UIView или UIViewController в текущей иерархии с возможной последующей проверкой типа (привет, topViewController() as? …).

  • Получение гетерогенных данных из другого слоя или модуля

Этот сценарий мы уже рассмотрели на примере загрузки элементов ленты.

Теперь вернёмся к переиспользованию View.

Переиспользование элементов

Выше мы объявили PostView и ClipListView — и теперь пробуем добавить переиспользование экземпляров (во время исполнения, скролла).

protocol FeedItem {
    func render(reusePool: ReusePool) -> UIView
}

struct FeedPost: FeedItem, Decodable {
    let text: String

    func render(reusePool: ReusePool) -> UIView {
        guard let view = reusePool.view(for: "post") as? PostView else {
            return PostView(self)
        }
        view.post = self
        return view
    }
}

class FeedListDataSource: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
    let viewReusePool: ReusePool
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "item", for: indexPath)
        let itemView = item(at: indexPath).render(reusePool: viewReusePool)
        itemView.tag = 0xEB0B0
        cell.viewWithTag(0xEB0B0)?.removeFromSuperview()
        cell.addSubview(itemView)
        return cell
    }
}

Это решение аналогично ReusePool у UICollectionView или UITableView. Идейно похожий вариант:

protocol FeedItem {
    func cell(in container: UICollectionView, path: IndexPath) -> UICollectionViewCell
}

struct FeedPost: FeedItem {
    let text: String

    func cell(in container: UICollectionView, path: IndexPath) -> UICollectionViewCell {
        let cell = container.dequeueReusableCell(withReuseIdentifier: "FeedPostCell", for: path) as! PostCell
        fill(cell)
        return cell
    }
}

Проверим счётчик после любого из этих решений:

item enumerations: 1
casts: n

Здесь n — количество типов ячеек для элементов ленты.

Почему возникают сопоставления в коде выше? Отметим причины из тех, что рассмотрели в предыдущем разделе. Видим в примере:

  • объекты с разными жизненными циклами;

  • хранение и последующее извлечение из reuse pool гетерогенного списка view;

  • подразумевается и вычисляется тип ячейки, хранящейся в reuse pool (то есть в другом коде, главная цель которого — reuse, а не безопасное хранение типов).

Когда у нас не было переиспользования — мы синхронизировали жизненный цикл view с обращениями к элементам данных. А в случае с переиспользованием (и, соответственно, самостоятельными жизненными циклами данных и view) нам достаточно избавиться от извлечения гетерогенного списка view из reuse pool — тогда приводить типы не потребуется.

Поделюсь своей идеей: если бы у UICollectionView был reuse pool под каждый класс ячеек, не нужно было бы приводить типы.

При этом от UICollectionViewCell требовался бы только протокол для общения с reuse pool коллекции, чтобы reuse pool мог оптимизировать количество ячеек в общей очереди. Но такие оптимизации оставим за рамками этого обсуждения.

Определим ReusePool под каждый тип View:

protocol ReusePool {
    associatedtype ViewType: UIView
    func dequeue() -> ViewType?
}

protocol FeedItem {
    func render() -> UIView
}

struct FeedPost <ViewPool: ReusePool> : FeedItem where ViewPool.ViewType == PostView {
    let text: String
    let reusePool: ViewPool

    func render() -> UIView {
        guard let view = reusePool.dequeue() else {
            return PostView(self)
        }
        view.post = self
        return view
    }
}

Теперь нам потребуется:

  • либо передать коллекцию всех возможных ViewPool через какой-то контекст в метод render:

struct RenderContext {
    let postViewPool: ReusePool<PostView>
    let clipViewPool: ReusePool<ClipView>
    let clipBlockViewPool: ReusePool<ClipBlockView>
}

protocol FeedItem {
    func render(in context: RenderContext) -> UIView
}

…и расширять RenderContext при развитии проекта;

  • либо внедрить ViewPool как зависимость в элемент через парсер и конструктор:

struct FeedParsingContext <PostViewPool: ReusePool> where PostViewPool.ViewType == PostView {
    let postViewPool: PostViewPool
}

struct FeedItemParser {
    enum CodingKeys: String, CodingKey {
        case type
        case post
        case clips
    }

    let context: FeedParsingContext
    func parse(decoder: Decoder) throws -> FeedItem {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let type = try values.decode(String.self, forKey: .type)
        switch type {
        case CodingKeys.post.rawValue:
            let text = "parse post text"
            return FeedPost(text: text, reusePool: context.postViewPool)
        case CodingKeys.clips.rawValue:
            return try values.decode(FeedClips.self, forKey: .clips)
        default:
            throw
        }
    }
}

Выбор будет зависеть от того, что вам удобнее в конкретном случае.

Навигация

Реализовать навигацию без проверок типов элементов предлагаю вам самостоятельно — это будет небольшой практикум :)

Идея такая: каждый элемент должен сам описать нажатие на себя, если оно применимо. Может возникнутьсоблазн сделать общий для всех элементов метод tap() или didSelect(). Но это может привести к логическим проблемам: не для всех элементов возможно нажатие, и тогда придётся оставлять пустую реализацию методов. Вконце статьи чуть подробнее рассмотрим тему допустимости и обработки нажатий.

Отсекать логически невозможные отображения и UI-действия помогают декларативные подходы. Если какое-то действие доступно — верстаем для него соответствующее отображение TapView(TapableWidgetTapableComponent...) с конкретным обработчиком. Если действия нет, мы даже не будем создавать кнопку и в runtime не будет существовать callback для неё.

Обобщим нынешние успехи

item enumerations: 1
casts: 0 (кроме десериализации)

→ Видим здесь только одно сопоставление — при преобразовании сырых данных.

→ Избавились от приведений типов (cast), кроме десериализации.

→ Общий код (методы работы с обобщённым элементом) не меняется при изменениях элементов.

→ Элементы самодостаточны — реализуют сами всё, что происходит с каждым из них. Окружение предоставляет лишь зависимости.

→ Код каждого элемента не размазан по massive-сущностям (роутер, контейнер и другое).

Как итог: код можно поддерживать, расширять, понимать и рассматривать на разных уровнях детализации. Код каждого продуктового сценария (элемента) максимально изолирован и сгруппирован.

Множество событий

Сначала зададим себе важный вопрос:

Почему вообще контейнер думает, что должен сам обрабатывать все на свете события вложенных элементов?

Обратимся к старым достижениям компьютерных наук и посмотрим, можно ли реализовать обработку событий без вездесущего Massive Container.

Шаблон «Состояние»

Описание шаблона:

protocol State {
    var context: Context
    
    // общие методы всех состояний:
    ...
}

protocol Context {
    func updateState(_ state: State)
}

class State1: State {
    var context: Context
    
    private func someState1Action() {
        context.updateState(State2())
    }
}

class State2: State {
    ...
}

Состояние само описывает переходы!

Не контейнер и не кто-то снаружи, а каждое состояние само должно описывать переходы, замену себя на новоесостояние. Только такая реализация позволит создать для каждого применимого сигнала простую функцию перехода из текущего состояния в новое: 1 -> M, где M — множество состояний, возможных после данного сигнала. При этом для недопустимых сигналов даже не будет кода.

Если бы переходы описывал какой-то общий контейнер, ему пришлось бы реализовать функцию N -> N (N обычно намного больше, чем M из предыдущего абзаца) из множества всех возможных состояний во все возможные состояния. При этом через assert или ещё как-то отсекать логически невозможные сигналы для каждого состояния.

Контекст состояния

Контекст реализует одну простую вещь: получение от текущего состояния нового и его применение.

И как это использовать? По аналогии с ViewPool нужно подписывать каждый элемент на конкретные сигналы, интересующие его:

protocol FeedItemContext {
    func setItem(_ item: FeedItem?)
}

protocol FeedItem {
    func setContext(_ context: FeedItemContext)
}

struct PostUpdateAction {
    let post: FeedPost
}

typealias PostUpdatedActionHandler = (PostUpdateAction) -> ()
protocol PostUpdateActionBus {
    func addHandler(_ postID: FeedPost.ID, handler: @escaping PostUpdatedActionHandler)
}

struct FeedPostContext {
    let feedItemContext: FeedItemContext
    let updateBus: PostUpdateActionBus
}

class FeedPost : FeedItem {
    typealias ID = String
    let postID: ID
    var context: FeedItemContext
    func setContext(_ context: FeedItemContext) {
        self.context = context
    }
    
    init(postID: ID, context: FeedPostContext) {
        self.postID = postID
        self.context = context.feedItemContext
        context.updateBus.addHandler(postID) { [self] action in
            let post = action.post
            post.context = self.context
            self.context.setItem(post)
        }
    }
  
    func hide() { // для иллюстрации локального удаления одного элемента
        context.setItem(nil)
    }
  
    func updateText(_ text: String) {
        self.text = text
        context.setItem(self) // либо копию себя с новым текстом
    }
}

И реализация контекста в дополнение к предыдущей реализации коллекции элементов:

struct FeedItemUICollectionViewContext: FeedItemContext {
    let index: Int
    weak var view: UICollectionView?
    weak var container: FeedListDataSource?
    
    func setItem(_ item: FeedItem?) {
        guard let container = container else {
            return
        }

        view?.performBatchUpdates({
            if let item = item {
                container.items[index] = item
                let context = FeedItemUICollectionViewContext(index: index, view: container.collectionVIew, container: container)
                item.setContext(context)
                view?.reloadItems(at: [container.indexPath(forItem: index)])
            } else {
                container.items.remove(at: index)
                view?.deleteItems(at: [container.indexPath(forItem: index)])
            }
        }, completion: nil)
    }
}

class FeedListDataSource: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
    fileprivate var items: [FeedItem] = []
    func updateItems(_ items: [FeedItem]) {
        self.items = items.enumerated().map({ index, item in
            let context = FeedItemUICollectionViewContext(index: index, view: self.collectionVIew, container: self)
            item.setContext(context)
            return item
        })
    }
}

Из этого кода следует мощное преимущество: контейнер (контекст) не меняется при изменении, добавлении и удалении конкретных событий элементов. Контейнер реализует только общие абстрактные действия с элементами: замену и удаление.

Что получили?

→ Каждый элемент описывает свои сигналы и обработчики.

→ Контейнер знает не о событиях, а только о том, что элементы могут потребовать заменить себя.

→ Строго типизированные события без сопоставлений.

→ Нет обхода контейнера на все события (чтобы искать затронутые элементы) — обновления происходят только при действительных изменениях.

Приятный и не заметный сразу плюс: контекстом может являться и фоновый контейнер до отображения (состояние, которое готово к показу, но пока отложено). Из коробки поддерживаются действия для ещё даже не отображённых (фоновых) элементов.

Мои вопросы без ответа

Часто в реактивных системах и фреймворках можно встретить такой подход: все сигналы сделаны через гетерогенные элементы, объединённые в одно множество, а не через самодостаточные несвязанные типы сигналов. Рассмотрим оба подхода по очереди.

Пример решения с множеством гетерогенных элементов:

public struct Publisher : Publisher {

    /// The kind of value published by this publisher.
    ///
    /// This publisher produces the type wrapped by the optional.
    public typealias Output = Wrapped

    /// The kind of error this publisher might publish.
    ///
    /// The optional publisher never produces errors.
    public typealias Failure = Never

    /// The output to deliver to each subscriber.
    public let output: Optional<Wrapped>.Publisher.Output?

    /// Creates a publisher to emit the value of the optional, or to finish immediately if the optional doesn't have a value.
    ///
    /// - Parameter output: The result to deliver to each subscriber.
    public init(_ output: Optional<Wrapped>.Publisher.Output?)

    /// Implements the Publisher protocol by accepting the subscriber and immediately publishing the optional’s value if it has one, or finishing normally if it doesn’t.
    ///
    /// - Parameter subscriber: The subscriber to add.
    public func receive<S>(subscriber: S) where Wrapped == S.Input, S : Subscriber, S.Failure == Never
}

Здесь не смешан Failure и все остальные сценарии (Output), но меня смущает другое:

Почему закладывается сценарий ошибки вообще для всех использований Publisher?

Ведь продуктовая логика конкретного использования может в принципе не предусматривать сценария с ошибкой. Симптомом этой проблемы я считаю наличие костыля Failure = Never

По поводу этого решения у меня возникает ещё один вопрос: если уж отделили сценарий ошибки от всех остальных, то почему эти другие сценарии не различимы, а предусматривают enum или другой переборный аналог в Publisher.Output и Subscriber.Input:

public protocol Subscriber : CustomCombineIdentifierConvertible {

    /// The kind of values this subscriber receives.
    associatedtype Input

    /// The kind of errors this subscriber might receive.
    ///
    /// Use `Never` if this `Subscriber` cannot receive errors.
    associatedtype Failure : Error

    /// Tells the subscriber that it has successfully subscribed to the publisher and may request items.
    ///
    /// Use the received ``Subscription`` to request items from the publisher.
    /// - Parameter subscription: A subscription that represents the connection between publisher and subscriber.
    func receive(subscription: Subscription)

    /// Tells the subscriber that the publisher has produced an element.
    ///
    /// - Parameter input: The published element.
    /// - Returns: A `Subscribers.Demand` instance indicating how many more elements the subscriber expects to receive.
    func receive(_ input: Self.Input) -> Subscribers.Demand

    /// Tells the subscriber that the publisher has completed publishing, either normally or with an error.
    ///
    /// - Parameter completion: A ``Subscribers/Completion`` case indicating whether publishing completed normally or with an error.
    func receive(completion: Subscribers.Completion<Self.Failure>)
}

Мы здесь видим чёткое отделение Failure от всего остального, но при этом абсолютно не заложено разделение других сценариев: они все смешаны в один Input/Output

Что в этом плохого? 

Такой интерфейс принуждает всех слушателей самостоятельно перебирать ветвление сценариев. И это вместо того, чтобы было всего лишь одно ветвление, инкапсулированное внутри источника сигналов:

typealias MyError = Error
typealias MySuccess = String
typealias MyThirdScenario = String

protocol MyErorReceiver {
    func receiveError(_ error: MyError)
}

protocol MySuccessReceiver {
    func receiveSuccess(_ success: MySuccess)
}

protocol MyThirdScenarioReceiver {
    func receiveThirdScenario(_ scenario: MyThirdScenario)
}

protocol MyPublisher {
    func addOnSuccess(_ receiver: MySuccessReceiver)
    func addOnError(_ receiver: MyErorReceiver)
    func addOnThirdScenario(_ receiver: MyThirdScenarioReceiver)
}

У меня есть предположения, почему это было сделано. При ином подходе нет нужды в каком-то «общем» реактивном фреймворке: всё общение описывается только требованиями бизнес-логики с отделением ветвей логики (и сценариев по заветам «Чистой архитектуры»), а также Interface segregation. Буду рад узнать ваше мнение в комментариях. Возможно, в каких-то сценариях критично «пропускать все сигналы через одну общую трубу», а не «заводить конкретную трубу под каждый уникальный сигнал».

Множество конфигураций

Рассмотрим ещё один сценарий, приносящий перебор вариантов в коде, — это реализация конфигураций каких-то объектов. Для примера возьмём кнопку. Может прийти мысль реализовать вариативность стилей через enum или другой способ определения множества. Пример:

public enum ButtonType : Int {
    case custom = 0
    case system = 1
    case detailDisclosure = 2
    case infoLight = 3
    case infoDark = 4
    case contactAdd = 5
    case close = 7
}

Что здесь не так? Если бы это были просто конструкторы по умолчанию, всё было бы ок. Но такой подход с множеством стилей при кажущейся гибкости (а точнее, вариативности) на самом деле жёстко ограничен. И выйтиза рамки стилей может оказаться трудно: писать свою кнопку или делать костыли, пытаясь подпилить кнопку по умолчанию.

Что делать? Не пытаться смешивать кнопку (как общую реализацию нажатий) и её отображение, не стараться за пользователя API придумать и описать все на свете сценарии внешнего вида. А вместо этого реализовать какое-то общее поведение, которое требуется от кнопок («нажимаемость»), и интерфейс для встраивания любого контента в кнопки: TapableWidjetContentView или какой-то протокол декорации.

Главная мысль, которую я хочу донести здесь:

У декомпозиции возможности расширения могут быть гораздо сильнее, чем у фиксированного множества вариантов.

Как мы говорили в разделе про нажатия, здесь могут помочь любые декларативные подходы — когда вкладываемое в сущность поведение чётко разграничивается, описывается заранее и передаётся снаружи. Используя шаблон «Состояние» в связке с декларативным render(), вы можете заполнять отображение текущего состояния только логически применимыми к нему элементами (включая кнопки и их действия). 

Эта идея, как мне кажется, нарушается здесь: элементы отображения и их сигналы обобщаются на все возможные состояния, а не очерчиваются для каждого конкретного сценария. И это выглядит как распространённая проблема проектирования:

struct PlayerView {
    var state: State
    
    func didTapPlay() {
        state.clickPlay()
    }
}

protocol State {
    func clickPlay()
}

struct Locked: State {
    func clickPlay() {
        assert("Как мы тут оказались?")
    }
}

struct Normal: State {
    func clickPlay() {
        playOrPause()
    }
}

Чтобы сделать идеальное разделение и избежать логических ловушек с недопустимыми сигналами, можно реализовать более декларативное решение:

class ContainerView {
    var state: State {
        didSet {
            self.contentView = state.render()
        }
    }
}

protocol State {
    func render() -> UIView
}

struct Locked: State {
    func render() -> UIView {
        return LockedPlaceholderView()
    }
}

struct Normal: State {
    func render() -> UIView {
        return PlayerView(onPlayTap: {
            self.playOrPause()
        })
    }
}

Общий итог

Несмотря на проблемы, которые мы разобрали в статье, необязательно совсем уходить от использований enum, приведения типов и других сопоставлений и переборов. Если вы хотите быстро написать код, который почти гарантированно не будет расширяться и обрастать вариантами, и вам проще это сделать через явный перебор любых сценариев — это не проблема. 

Проблема — если код всё же начнёт обрастать вариантами, а выделение общего поведения (абстракций, контрактов, интерфейсов, протоколов) будет откладываться.

Я по умолчанию против появления в коде переборов и сопоставлений. Считаю это поводом ещё раз взглянуть на код и задуматься, точно ли в нём всё хорошо. Потому что при использовании перечислений и сопоставлений:

  • не инкапсулируется всё множество вариантов: все сценарии переплетаются и становятся везде видны;

  • авторы не пытаются выделить абстракции (общие требования и сценарии всех реализаций);

  • молча отваливаются необработанные сценарии при наличии default;

  • для as? незаметно отваливается код при смене типов;

  • вариативность и гибкость иллюзорные — а в реальности имеем жёсткую ограниченность и переплетённость.

При этом не стоит слепо избавляться от приведения типов ячеек коллекции и переписывать его на строго типизированное множество ViewReusePool<ConcreteViewType:UIView>. Текущее решение с привычным cast в одном конкретном месте вполне может вас устраивать. Главное, чтобы вы знали о возможности сделать код более гибким и безопасным, учитывали её в проектировании и применяли при необходимости.

Надеюсь, информация из статьи будет для вас полезной. Она может помочь:

  • разделять и изолировать сценарии, разносить их реализацию по соответствующим модулям — и при этом не затрагивать модуль, который описывает общее поведение;

  • убирать переплетение ветвей исполнения;

  • уменьшать количество нелогичного кода — когда происходят проверки допустимости каких-то ветвей в коде, куда мы вообще не должны были попадать;

  • и в результате делать код более расширяемым и поддерживаемым.

Использованные материалы

Теги:
Хабы:
+21
Комментарии77

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Руслан Дзасохов