Меня зовут Саша Терентьев, я из команды ленты ВКонтакте. В этой статье поделюсь мыслями о проблемах кода, где используются enum
и сопоставления типов. Часто встречаю такой код в проектах, ресурсах, примерах. Мы обсуждали это с коллегами на внутреннем событии, и из моего доклада выросла эта статья.
Букв будет много. Но, думаю, материал пригодится широкому кругу разработчиков — и не только iOS. Примеры основаны на псевдо-Swift и написаны по мотивам использования UIKit. Но могут пригодиться для работы с разными платформами и в любой области, где возникают сопоставления с образцом, приведения типов, переборы множеств типов.
О чём на самом деле статья
Название «Почему я против enum» выбрано, чтобы привлечь внимание :)
На самом деле я против переборов вариантов — в коде они обычно встречаются в таком виде:
switch case
;if … else if … else if … else if …
;as?
;isKindOf
.
Об этом своём пунктике и расскажу в статье. Ещё подсвечу проблемы и покажу, что код бывает масштабируемым и поддерживаемым. И разберёмся, как можно не использовать enum
.
Вернёмся к основной мысли. Если ещё конкретнее, я против сопоставления с образцом (pattern matching) более одного раза для одного множества вариантов (Set
, enum
).
Важно: если мы не получаем перебираемое множество извне*, следует вообще избежать переборов.
* Извне — имеем в виду, что получаем множество или его произвольный элемент из другого слоя логики; из сети; из хранилища; из другого модуля, код которого мы не контролируем; и так далее.
Выделим сценарии, когда возникают переборы вариантов или ветвей исполнения.
Сценарии, которые рассмотрим
Гетерогенный список
Как гетерогенный список будем определять коллекцию разнотипных элементов. В любой момент каждый из элементов может иметь один из множества типов. Множество типов может (и скорее всего будет) меняться по мере развития проекта.
Множество событий
Подписка на обработку множества событий или события, имеющего множество вариантов (подтипов).
Множество конфигураций
Множество вариантов конфигурации каких-то объектов: стили текста, стили кнопок и так далее.
Гетерогенный список и множество событий разберём на примере реализации новостной ленты, а затем отдельно рассмотрим проблемы и решения в сценарии с множеством конфигураций.
Код в примерах зачастую будет схематическим, его задача — донести идеи и показать изменения логики.
Если хотите пропустить такие примеры с проблемами, можно перейти сразу к решениям без лишних переборов. Но полезно будет посмотреть все варианты кода — они довольно распространённые и могут встретиться в проектах.
Привычное решение для ленты (и его проблемы)
Гетерогенный список с множеством событий
Чтобы было попроще, рассмотрим ленту из двух типов элементов: «запись» и «блок клипов». Массив элементов в нашем понимании как раз и является гетерогенным списком.
Элементы
Начнём с описания данных. Думаю, многим на ум приходит такой код:
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
(TapableWidget
, TapableComponent
...) с конкретным обработчиком. Если действия нет, мы даже не будем создавать кнопку и в 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 придумать и описать все на свете сценарии внешнего вида. А вместо этого реализовать какое-то общее поведение, которое требуется от кнопок («нажимаемость»), и интерфейс для встраивания любого контента в кнопки: TapableWidjet
, ContentView
или какой-то протокол декорации.
Главная мысль, которую я хочу донести здесь:
У декомпозиции возможности расширения могут быть гораздо сильнее, чем у фиксированного множества вариантов.
Как мы говорили в разделе про нажатия, здесь могут помочь любые декларативные подходы — когда вкладываемое в сущность поведение чётко разграничивается, описывается заранее и передаётся снаружи. Используя шаблон «Состояние» в связке с декларативным 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 в одном конкретном месте вполне может вас устраивать. Главное, чтобы вы знали о возможности сделать код более гибким и безопасным, учитывали её в проектировании и применяли при необходимости.
Надеюсь, информация из статьи будет для вас полезной. Она может помочь:
разделять и изолировать сценарии, разносить их реализацию по соответствующим модулям — и при этом не затрагивать модуль, который описывает общее поведение;
убирать переплетение ветвей исполнения;
уменьшать количество нелогичного кода — когда происходят проверки допустимости каких-то ветвей в коде, куда мы вообще не должны были попадать;
и в результате делать код более расширяемым и поддерживаемым.
Использованные материалы
«Чистая архитектура», Роберт Мартин
Комментарии (77)
amarao
24.01.2022 14:58+18Грандиозный заголовок, микроскопический текст. Быть "против энумов" это всё равно, что "быть против унарного типа" или "протестовать против bottom-type".
at_nil Автор
24.01.2022 15:03-5конечно же в статье речь об использовании инструмента, а не самом инструменте. но лично для меня в целом любое появление перечислений в коде — повод задуматься, уместно ли оно там
chernish2
24.01.2022 15:53+12Любое появление любых конструкций языка в коде - это всегда повод задуматься, уместно ли оно там.
Дочитал до места, где сказано, что название статьи для привлечения внимания, а сама статья о другом, поставил минус.
at_nil Автор
24.01.2022 15:58-2"Любое появление любых конструкций языка в коде - это всегда повод задуматься, уместно ли оно там." — согласен
статья, на мой взгляд, даже о более общей проблеме, частным и частым примером которой является именно
enum
.amarao
24.01.2022 16:11+3Это значит, что вы просто хороших языков программирования не видели. 50% прелести Rust'а в том, что происходит с enum'ами.
at_nil Автор
24.01.2022 16:38Статья не про язык, а про организацию кода без переборов и переплетения сценариев.
Не буду скрывать, действительно rust не изучал и не имел с ним дела. Мне стоит восполнить этот пробел. Я иногда слышу положительные отзывы о его использовании
amarao
24.01.2022 18:09+1Я, в свою очередь, не видел swift, а зашёл по кликбейтному заголовку. В Rust описанная проблема решается с помощью типажей (trait). Каждый тип реализует один и тот же трейт, дальше идёт либо статическая мономорфизация, либо динамическая диспетчеризация (как выберете).
А enum'ы используются для сохранения инвариантов - Result (который либо Ok, либо Err), либо Option, который Some или None, и для подобных же конструкций.
at_nil Автор
24.01.2022 18:22у меня складывается ощущение из такого описания trait, что это аналог протокола или интерфейса, то есть как раз реализация того дизайна, что я пропагандирую
А вот про Result Ok/Error мне ближе описанный в части про сигналы подход с разделением обработки onOk onError. И мой аргумент тут такой: генератор результат уже выполнил ветвление, он выбрал, в какую из ветвей направить исполнение, почему после этого все слушатели (пользователи API) должны выполнять снова это ветвление, если они могут просто зарегистрировать обработчики на ветвление источника?
Но, конечно, такой код возможен только там, где есть возможность зарегистрировать и вызвать конкретный обработчик. При получении ответа из сети или при десериализации хранящихся на диске данных, когда у нас есть только бинарные данные, которые могут либо OK либо Error содержать — в таком случае от ветвления на стороне получателя уже не избавиться, кажется. Но этот случай в статье я рассматриваю как допустимый для какой-то реализации перебора
amarao
24.01.2022 18:30+2Трейты похожи на интерфейсы, да.
Насчёт Result... Основная проблема "ветвления" (читай, exception'ов) состоит в том, что по типам перестаёт сходиться. Если у вас функция возвращает Result, то получатель не сможет проигнорировать ошибку. Он от неё может отмахнуться (unwrap), передать выше ( оператор '?'), но он не может её проигнорировать.
Если вы полагаетесь на обработчики выше, то через несколько слоёв (и несколько раздельных команд) у вас полностью потеряно соглашение кто какие ошибки как обрабатывает. А тут у вас в коде ясно написано, и к этому можно задать вопрос "зачем?" или "а почему так?".
Более того, благодаря
#[must_use]
можно сделать так, чтобы нельзя было даже выкинуть результат (не присвоив его никуда).Я тут с Rust'ом, конечно, но мне кажется, что вопрос жизни с enum'ами (так же как и техника их правильной готовки) совершенно language-indepndent, и к языкам оно относится только в вопросе "насколько удобно" в том или ином языке с таким жить.
at_nil Автор
24.01.2022 18:38"получатель не сможет проигнорировать ошибку" — можно сделать обязательным наличием
onError
обработчика.По сути, я описываю переход от традиционного вызова функций, которые что-то возвращают, к асинхронному стилю с обработчиками и инкапсулированным ветвлением.
Если слушателю обязательно надо прокинуть ошибку выше, и при этом почему-то не преобразовав её в ошибки уровня своей логики — он может обявить для внешнего слушателя (или супервизора из реактивных шаблонов проектирования) обязательность регистрации обработчика onError того же типа, что требует слой ниже.
Или я ошибочно понял сценарий?
0xd34df00d
24.01.2022 19:27+7Так эти обработчики — это же прямой рецепт к callback hell 2.0!
Плюс, про раст не знаю, но, например, если у меня есть список из разных
Either
'ов (представляющих разные возможные варианты вычислений, например), то я могу достаточно просто взять первое вычисление, которое завершилось успехом. Как это сделать для ваших обработчиков?
at_nil Автор
24.01.2022 19:37-1если код аккуратно разделён на слои, когда в одном месте не пытаемся на несколько уровней вниз реализовать асинхронные операции, callback hell не должно возникнуть.
про сценарий с
Either
не понял:( речь про список каких-то Future идёт? можно реализовать callback, где будем записывать значение в future. Или о каком сценарии речь?
0xd34df00d
25.01.2022 06:02+4А это неважно, есть там асинхронность или нет. Если я вас правильно понял, вы просто предлагаете всегда заменить Result/Either/etc двумя continuation'ами.
AnthonyMikh
25.01.2022 22:09Если я вас правильно понял, вы просто предлагаете всегда заменить Result/Either/etc двумя continuation'ами.
С Church encoding так-то разницы и нету...
0xd34df00d
25.01.2022 23:14+5На фундаментальном уровне разницы действительно нету, но мы-то о сахаре и удобстве говорим.
kotovsky_art
24.01.2022 23:50+3Вы просто не разобрались в причинах появления enum как способа выражения возможных состояний. Ваш сфокусированный императиный подход просто не дает возможности раскрыть всю красоту и лаконичность enum. Чтобы понять этот инструмент и как его эргономично использовать, стоит вникнуть в алгебраические типы данных, функциональную композицию и научиться извлекать ассоциированные значения не привнося шаблоны паттерн матчинга непосредственно в бизнес логику. Для проникновения в глубины этого чудесного инструмента вам поможет сайт ребят из point free. Возможно вы о нем слышали. Успехов!
at_nil Автор
25.01.2022 03:02Почитал про алгебраические типы данных. Не буду говорить, что много, возможно, не достаточно информации нашёл, но:
даже в функциональном программировании увидел, что есть абстрактные типы данных: обобщенные алгебраические типы данных. Если я правильно понял, по сути, это как раз аналог интерфейсов. За переход к которым я высказываюсь
Некоторые решения, что я предлагаю не императивные, а отчасти декларативные: декларируются и собираются обработчики возможных допустимых сигналов в каждом сценарии работы системы.
И тут не так важно, будет функциональный стиль или ещё какой-то, я высказываюсь за построение кода так, чтобы в нём находились по минимуму или вообще отсутствовали любые сопоставления и проверки значений. И просто переход к фунцикональному программированию может не избавить от написания кода, который будет постоянно в своих кусочках что-то сопоставлять или проверять, а не просто выполнять то, что задекларировано и собрано заранее
Если вы имеете ввиду под "научиться извлекать ассоциированные значения не привнося шаблоны паттерн матчинга" оперирование сущностями через абстракции (интерфейсы), тогда это как раз то, что я пытаюсь донести)
0xd34df00d
25.01.2022 06:06+5даже в функциональном программировании увидел, что есть абстрактные типы данных: обобщенные алгебраические типы данных. Если я правильно понял, по сути, это как раз аналог интерфейсов. За переход к которым я высказываюсь
Эм, нет, интерфейсы тут не совсем релевантны.
Абстрактных типов данных в ФП нет (по крайней мере, на этом уровне обсуждения). ADT — это именно algebraic, потому что там есть типы-суммы и типы-произведения.
Обобщённые алгебраические типы данных — это алгебраические типы данных, тип которых индексирован ещё какой-то переменной, чтобы можно было в типах выражать ограничения на то, какие конкретно конструкторы этих GADT'ов были использованы. ADT и GADT применимы и весьма полезны даже в тех языках, где интерфейсов (трейтов, тайпклассов) по факту нет.
kotovsky_art
25.01.2022 11:50+2Про алгебраические типы, типы-суммы и типы-значения - тема глубокая с далекоидущими выводами, поверхностный разбор ничего не даст. Надо углубляться.
Действительно, Swift оставил enum немного "ущербными" из-за чего можно сделать выводы наподобие ваших. Есть пара элементарных трюков которые возвращают в enum недостающей эргономики.
Вот микропример, что имею в в иду.struct Post: Equatable {} struct Clip: Equatable {} enum FeedItem { case post(Post) case clip(Clip) // "секретный" ингредиент в виде небольшого шаблонного кода var post: Post? { guard case let .post(post) = self else { return nil } return post } var clip: Clip? { guard case let .clip(clip) = self else { return nil } return clip } } // профит [<<FeedItem>>].compactMap(\.post) // -> [Post] [<<FeedItem>>].compactMap(\.clip) // -> [Clip]
Там где вы описываете Equatable к enum Content - это излишне. достаточно чтобы ассорциированный значения реализовывали этот протокол, "ручная" реализация будет не нужна. Счетчики enum-ов и кастинга декрементируют ))
Вам не надо везде поддерживать FeedItem. Лучший кейс применения Enum с ассоциированными значениями - транспорт этих значений до конечного места использования без внедрения в сами значения их типовых различий. Вам так досадили перечесления потому, как мне показалось, что вы их использование пытаетесь привести к привычному императивному шаблону, подтыкая везде и всюду.// ваш пример из статьи 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] } }
В этой части где появляется
FeedItem.Content.Post/Clip
- ваша абстракция уже поплыла. Уберите Content и всё что вложено. Кажется в них нет никакой необходимости.
И все дальнейшие проблемы, снова решаем проблему тем же образом:// Ваш вариант 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 } } } } // Как это могло бы выглядеть в жизни enum FeedItemAction { case notInterested(FeedItem) case banPost(FeedItem) // FeedItem.Content.Post - главный костыль // из-за которого всё шло наперекосяк // и снова вычислимое свойство сильно упрощающее остальной код. var feedItem: FeedItem? { switch self { case let .notInterested(item): return item case let .banPost(item): return item } } } struct FeedItemActionHandler { func handle(_ action: FeedItemAction, items: [FeedItem]) -> [FeedItem] { return items.filter { $0 == action.feedItem } } }
Генерацию вычислимых свойств можно вынестиSourcery
и не вспоминать об этих шаблонах и о том что надо "во всех местах" пойти и добавить новыйcase
, думайте об этом также как о генерации Codable методов и прочей кодогенерации компилятором.Держите enum простыми, без nested структур или других enum. Прячте паттерн матчинги в кодогенерацию (если религия позволяет, встречал массу ребят на дух не переносящих кодген). Это малая толика того что можно еще сделать для удобства. Надеюсь это поможет посмотреть иначе на enum-ы ))) Удачи!
at_nil Автор
25.01.2022 12:24кажется, вы просто предлагете запрятывание переборов. а в чём плюс? чтобы в коде был соблазн сделать больше переборов списков элементов как в
// профит [<<FeedItem>>].compactMap(\.post) // -> [Post] [<<FeedItem>>].compactMap(\.clip) // -> [Clip]
?
для меня это всё выглядит как "костыли", так как везде распрораняется знание о том, а какие элементы ленты бывают, и какие их детали. из-за того, что везде по коду лезет это знание, отсюда возникают места, которые как раз вычленяют необходимые типы, как вы предложили:
// "секретный" ингредиент в виде небольшого шаблонного кода var post: Post? { guard case let .post(post) = self else { return nil } return post } var clip: Clip? { guard case let .clip(clip) = self else { return nil } return clip }
Для меня это всё кажется, как фиксы или костыли, чтобы прикрыть симптомы неуместного использования enum
И ещё вы, кажется, предлагаете код, который я в статье везде помечаю, как нелогичный:
case banPost(FeedItem) // почему здесь закладывается хранение всех элементов, а не постов?
Вот только я действительно не понимаю ценности этих финтов, которые, как по мне, только упрощают написание неэффективного негибкого переплетённого между сценариями кода. И статья как раз о том, как можно избавиться от этого кода и оперировать только абстракциями, а знание деталей инкапсулировать в код детелаей, не размазывая по проекту
kotovsky_art
25.01.2022 13:16Вы очень буквально восприняли мои примеры. Не понимая как правильно развивать этот подход, он естественно вам кажется полумерой и вы не видите что с ним делать дальшей. Не удивительно, почему вам не кажутся костылями протоколы, рендеры и контексты из тех примеров, которые вы сочли "отличным" решением. Лично я не вижу профита в том, что теперь у вас десяток новых сущностей.
PostUpdateActionBus
ставит точку в дальшейм обсуждении где тут костыли. В масштабе большого приложения - вы получите на порядок больше бойлерплейта, который невозможно автоматизировать. Ваш подход не плох, но и не настолько хорош, чтобы клеймить перечисления и людей которые умеют их использовать. За сим, прошу меня извинить.at_nil Автор
25.01.2022 13:33Ваш подход не плох, но и не настолько хорош, чтобы клеймить перечисления и людей которые умеют их использовать.
Простите, если мой ответ показался грубым, но я действительно хотел бы увидеть те плюсы, которые enum и другие перечисления могут принести в систему, поэтому и спрашиваю так, в чем их ценности. Да и во всём докладе я делаю упор именно на избавлении от переборов и проверок, а не просто на борьбе против enum. Если в использовании enum есть преимущества, я хотел о них узнать. А пока enum, к сожалению, мне кажется инструментом, который легко ведёт к коду, как к перебору вариантов, и этой статьёй я пытаюсь подтолкнуть коллег к более осмысленному использованию перечислений и переборов:(
mayorovp
25.01.2022 17:33+1везде распрораняется знание о том, а какие элементы ленты бывают, и какие их детали
Ну, собственно, основное назначение enum — распространить везде знание.
Если задача прямо противоположная — enum не подходит, было бы тут про что статью писать...
ysadyev
24.01.2022 16:13Вы правы, хотя скорее автор выразил мнение о причинах нежелания использовать в коде. При этом, это ведь не единственный условные "самоограничения", которые принимаются сперва группой лиц, а потом и всем сообществом. Очень кстати то, что в статье приведены конкретные кейсы использоапния, хотя конечно далеко не факт, что у вас на практике встречается аналогичная задача и даже если встречается, не факт что enum так уж и вредно (='неудобно') было бы использовать.
at_nil Автор
24.01.2022 16:16ну именно с лентой Вконтакте в iOS приложении реализация описанного механизма с протоколами и контекстом элементов помогла начать писать изолированный код новых элементов ленты, при этом даже в разных модулях, и на Swift, хотя основной код старый код ленты остаётся на objc и его логика не меняется. Так что в основном модуле клиента (в котором исторически находится реализация основной логики ленты) даже нет смешения языков ("Mix and Match" Swift и Objc)
zorn-v
24.01.2022 15:00+7Энумы всяко лучше 1,2,3 в базе и где то в коде куски коментов вырастающие в тхт типа
```
1 => Заказ,
2 => Оплачено,
-
3 => Возврат,
...
14 - ошибка
```
Ага, живем не в идеальном мире.
at_nil Автор
24.01.2022 15:05для экспортирования API, каких-то констант, вполне возможно. но если речь о конкретной системе, в которой это не просто константы, а реальные состояния системы, возможно, стоит задуматься о введении сущностей под эти состояния. надо смотреть каждый конкретный случай
mayorovp
24.01.2022 15:30+2Если это реальные состояния системы, то вынесение их из перечисления в сущности приведёт к тому, что система перестанет реализовывать конкретный бизнес-процесс и начнёт реализовывать произвольный бизнес-процесс. Вроде неплохо, но есть подводные камни, и наиболее очевидный из них — возросшая на пустом месте сложность задачи.
Менее очевидным подводным камнем становится то, что часть реализации конкретного бизнес-процесса перестаёт быть кодом и становится данными, из-за чего "уплывает" из системы контроля версий, и для возвращения её обратно придётся предпринимать усилия.
Также становится непонятно кто ответственен за то, чтобы именно нужный бизнес-процесс был реализован. В лучшем случае появляются две команды разработки: одна для ядра, вторая для конкретной реализации. В худшем случае сам бизнес-процесс, ради которого всё и затевалось, становится сиротой без ответственных за него.
В общем, при замене подобного перечисления на сущность либо замена происходит совершенно формально (сущность есть, но код её использует по самому минимуму и ломается при любых изменениях в БД), либо гарантированно срываются сроки.
at_nil Автор
24.01.2022 15:41я скорее вот про что:
если эти состояния системы не просто флаги, а режимы работы системы, логика которых хоть чем-то различается, вомзожно, имеет смысл в рамках слоя, для которого это важно, реализовывать состояние как сущность, ведь, по сути, состояние является сценарием бизнес логики.
если при этом есть необходимость передавать информацию о состоянии наружу — тут два варианта, которые я тоже рассмотрел в части статьи про сигналы:
1) можно сделать интерфейс с передачей констант (1=>Заказ и т д)
2) можно сделать интерфейс с регистрацией обработчиков на событие перехода в каждое отдельное состояние, и в обработчики передавать строго типизированные данные по конкретному состоянию.
И вот про выбор между этими двумя вариантыми как раз мой открытй вопрос про реактивные библиотеки
at_nil Автор
24.01.2022 15:46-1ну и если не вынести состояния в сущности, то тут получается, что система описывает как раз не разные состояния бизнес-процесса, то есть не бизнес-процесс по возможным этапам-шагам и явными переходами между шагами, а переборы всех возможных состояний с описанными в статье проблемами поддержки такой системы.
если для какого-то проекта проще/безопаснее/нагляднее явно перебирать состояния как значения — тогда, возможно, не стоит ничего трогать. но при росте системы стоит всё же учитывать альтернативные варианты реализации
alexeishch
24.01.2022 16:18+8Enum c switch идут в паре не просто так. Конструкция switch реализуется большинством компиляторов на низком уровне с помощью механизма Jump Table. Это позволяет в хорошем случае (когда все enum пронумерованы маленькими числами) все ветвление выполнить за три инструкции процессора как на x86 так и на ARM архитектуре.
Языки более высокого уровня также используют это "под капотом" у себя. Например виртуальная машина .NET имеет специальный опкод(!) для инструкции switch чтобы в дальнейшем оптимизировать подобное, а все новомодные switch по строкам, pattern matching и прочее используют примитивы вроде словарей, чтобы в конечном итоге прийти к такому свитчуat_nil Автор
24.01.2022 16:24-2я описываю в статье не отказ от switch в пользу перехода на какой-то другой менее эффективный способ ветвления. я описываю дизайн кода, который вообще избегает лишних ветвлений и проверок в пользу вызова интерфейсных методов. так же в обработке событий ещё и приведён пример избавления от необходимости перебирать весь массив элементов для поиска тех, что затронуты возникшим событием. Так что, возможно, описанное даже эффективнее по времени исполнения, чем множество последовательных супер оптимизированных switch. Но тут не берусь утверждать.
alexeishch
24.01.2022 17:09+4На фронте вообще без разницы как оно будет реализовано. Главное чтобы не было нигде квадратичной сложности. Разница несущественна.
У вас выглядит так, будто объектной модели не хватает базового класса, в производных же должно быть поведение. Зачем там вообще изначально enum непонятно - может это какие-то фичи Apple
staticmain
24.01.2022 16:18+9я из команды ленты ВКонтакте
А можно мне приложение, которое не будет минуту грузиться и лагать при открытии чатов? Enum'ы готов потерпеть.at_nil Автор
24.01.2022 17:47+1к теме статьи не относится, но:
мы следим за производительностью, постоянно стараемся её улучшить. к сожалению, не все проблемы пока удалось исправить.
если это не разовая проблема, а стабильное воспроизведение — будем очень признательны, если сможете передать детали проблемы нашей техподдержке
Psychosynthesis
25.01.2022 17:54Лол. Не знаю уж что вы там исправляете, но на протяжении последних лет пяти каждый раз когда я заходил в приложение - находил какой-нибудь новый баг. И какую-нибудь новую бесполезную фентифлюшку.
В итоге просто перестал заходить в данную соцсеть.
Gargo
24.01.2022 16:24+6Я против сложных enum - когда в перечислениях появляются параметры. Кроме swift в других языках таких конструкций наверное и нет. В остальном я только за.
Да, они противоречат SOLID и паттернам проектирования. Но посмотрите на это с другой стороны - например, в swift вы везде расставляете конструкцию switch-case без "default" пункта. Когда у вас изменяется enum, то вы сразу видите, в каких местах нужно поправить код, работающий с enum. Напротив, при слепом следовании SOLID в коде будет куча взаимозаменяемых абстракций, и додумывать, как очередная абстракция повлияла на остальной код, нужно будет самостоятельно. Отсюда следует, например, большее количество ошибок при правках кода без enum.
at_nil Автор
24.01.2022 16:35Когда у вас изменяется enum, то вы сразу видите, в каких местах нужно поправить код, работающий с enum.
Этот аргумент я слышал довольно часто, но у меня есть ответ, что в случае с выбором протокола IDE тоже будет ругаться на отстувие определния какого-то метода в какой-то из реализаций протокола
Про множество абстракций — в общем случае я предлагаю лишь одну под один enum: протокол, который описывает, что мы хотим в общем от каждого элемента
mayorovp
24.01.2022 17:11+7Я против сложных enum — когда в перечислениях появляются параметры. Кроме swift в других языках таких конструкций наверное и нет.
Когда в перечислениях появляются параметры, перечисление перестаёт быть перечислимым типом и становится типом-суммой. Эта штука существует в куче языков, включая Visual Prolog, Haskell, Scala и Rust.
И да, почему вы против таких enum?
Gargo
27.01.2022 08:44встречный вопрос - может я чего-то не знаю, и существует способ узнать содержимое таких enum через один if без пачки вложенных switch (учитывая, что enum могут содержать в том числе другие enum)?
mayorovp
27.01.2022 10:41Если вас интересует лишь один вариант из всех — то пожалуйста:
if let Foo(Bar(Baz(i))) = x
Если же нужно разобрать несколько вариантов — не вижу причин отказываться от match/switch.
zaitsevyan
24.01.2022 23:05+2Очень хорошая статья. Последний год в голове как раз крутилось все что здесь описано. Сам тоже не перевариваю enum'ы и перечисления когда для добавления нужного значения нужно отсмотреть/поправить еще десяток мест в коде где оно используется. Особенно не нравится, когда пытаются использовать enum для задания стиля компонента и во всех методах перечисляют все значения.
Все описаное также укладывается под O из SOLID (open-closed principle) - когда для внесения изменений нужно создать новый код (например новый класс которые будет реализововать протокол) а не изменять существуюущий код (те все swift перечисления)mayorovp
25.01.2022 01:21+6Тут вопрос в том, какие изменения ожидаются чаще — появление новых разновидностей или появление новых операций. В первом случае надо использовать классы и наследование (либо интерфейсы и реализацию, трейты и реализацию и т.п.), во втором случае лучше использовать enum.
at_nil Автор
25.01.2022 01:30+1Справедливое замечание. У меня как-то возник даже такой пример: в компании есть анкеты. И можно сделать так, что по отдельным вопросам будут журналы для подписей или какой-то другой инфы от команд (для меня это аналог перебора вариантов enum в функции), а можно так, что будут анкеты со всеми вопросами для разных команд. Тут уже ближе к реализации интерфейса/протокола/trait.
Кажется, что при добавлении команд использовать второе проще.
Также кажется, что для добавления вопросов проще использовать первое. Но! Это невозможно легко распараллелить без конфликтов, так как есть необходимость изменения общего журнала разными командами
funca
26.01.2022 00:10Кажется это называется https://wiki.c2.com/?ExpressionProblem
upd: эх, не дочитал - ниже про это уже написали.
aegoroff
25.01.2022 08:14+2Максимально холиварный и кликбейтный (как модно теперь говорить) заголовок и все же наверно, в подобной ситуации, стоит смягчить формулировки - текст могут читать, в том числе, и начинающие, а подобные заявления могут в их голове поставить ненужный блок - enum и все такое - плохо, плохо.
Как всегда, у любой вещи, есть 2 стороны и всегда можно микроскопом гвозди забивать - все зависит от опыта и знаний.
Собственно вся соль статьи написана в последних нескольких абзацах (с ними я согласен), а остальное это просто примеры - наверно стоило построить все наоборот - сначала тезисы, а потом примеры
funca
26.01.2022 00:17Как бы то ни было, а
срадебаты в комментах не затухают уже несколько дней, что вообще говоря теперь редкость на нынешнем космическо-новостном Хабре. Каждый пытается поговорить за какую-то свою любимую тему. Может просто все соскучились по профильному контенту и готовы хвататься за хоть что-то?at_nil Автор
26.01.2022 00:25Я ожидал чего-то подобного, публикуя доклад на тему про проектирование с громким заголовком и мыслями и болями, которые в голове крутились давно :)
ws233
25.01.2022 09:42На моменте, когда вы стали дублировать системный код уже стоило задуматься, откатиться назад и разобраться, как же не терять возможность использования системного кода. Ведь таким образом, чем дальше Вы будете развивать проект, тем больше системного поведения Вам придется дублировать. Вы точно этого хотите? Вы точно представляете весь масштаб кода, написанного эплом?
Вот тут мы как раз и показываем вариант, как подружить Вашу идею с типами вьюмоделей с системным кодом. Вводится простая карта соответствий типа вьюмодельки (ваш Item) на идентификатор ячейки для реюза (та самая строка, которую по дефолту делают именем класса и сразу стреляют себе в ногу). Вам больше не надо дублировать системный код. Ваша реализация упростится и потеряет еще несколько счетчиков, от которых Вы так хотите избавиться. А также Вы не будете опускать вью слой аж до уровня парсера и нарушать тем самым принципы построения надежных приложений, типа SOLID или слоистой архитектуры.
Да, в цикле статей мы пока еще не описали, как аналогичный механизм применяется и для обработки действий пользователя. Но статья уже в процессе подготовки и скоро появится. А пока в качестве домашнего задания :) Вы вполне можете подумать, как применить данный подход и к обработке действий пользователя по ячейкам. Ваш код еще сильнее похудеет.
at_nil Автор
25.01.2022 10:46Пример с Reuse Pool я привёл, чтобы показать, что можно по-другому реализовывать dequeue и не приводить никакие типы)
Не обязательно прокидывать через парсер - можно сделать аргументом render (такой пример есть в статье)
Если удобнее использовать стандартное как есть - можно использовать его, я не заставляю отказываться от этого) для меня главное поделиться идеей, что бывает и по-другому
AnthonyMikh
25.01.2022 22:16+3Александр, пожалуйста, прочитайте про expression problem. А то вы описали одну из её половин, только очень путано и многословно.
at_nil Автор
25.01.2022 22:22Спасибо! Почитаю
Старался идти по примерам. Досадно, что кажется путано:(
Arlekcangp
25.01.2022 22:17+1Почему то в конце статьи в паттернах не упомянуты другие паттерны двойной (динамической) диспетчеризации помимо паттерна "состояние". А мне, когда я увидел код, сразу вспомнился " Посетитель (visitor) ". Очень часто проблема "убрать enum" требует мульти-диспетчеризации вызовов. И тут без посетиля часто не обойтись. Однако это сложный для понимания и реализации паттерн. И использовать его везде - плохая идея. Таким образом не нужно торопиться избавляться от enum, т к можно сильно усложнить код на пустом месте. Если в проекте в это перечисление значения добавляются редко и не на систематической основе, то я бы 10 раз подумал. Особенно если есть вероятность, что завтра требования изменятся и этот enum вообще нужно выпилить будет вместе с кодом, который его использует.
artemvkepke
26.01.2022 00:36Спасибо за статью. Интересный материал. Хорошо прям проработан подход, но все-таки думается, что могут существовать ситуации, в которых для реализации бизнес-требований будет выгоднее использовать enum.
Предлагаю рассмотреть такой случай:
пусть в ленте 100500 итемов (ячеек) 15 разных типов (посты, клипы и т.д.) и нужно дать пользователю возможность выбирать из них два итема, но с обязательным условием, что итемы должны быть разных типов.
Например, если первой выбрана ячейка постов, то вторую можно выбрать "только-не-посты" (а например клипы) и наоборот.
Кажется, что тут будет выгоднее оставить пришедший из сети enum и паттерн-матчить по нему.at_nil Автор
26.01.2022 00:56Спасибо:)
Интересный сценарий. Так как это новая логика: новое бизнес-требование, здесь можно подумать над какой-то регистрацией идентификаторов контента. Причём, в таком случае, идентификаторы могут даже не полностью совпадать с множеством типов, если какие-то сущности отвечают за контент разного происхождения, которое хочется разделять, и тогда лучше, наверно, отделить реализацию идентификаторов фильтрации от "мета-информации" - сущностей элементов
Но и паттерн-матчинг множества типов тоже может подойти. Тут, как и везде, уже надо плясать от задачи и проекта
GeorgeNordic
26.01.2022 21:28+1Enum и switch норм. Пока не пришло время передавать их в другую систему ????
napa3um
Кажется, нужно прост отличать перечисления (это бизнес-логика, расширение требует перекомпиляции ПО) от словарей (это пользовательские данные, расширение требует добавление новой записи в БД, бизнес-логика универсальна и не требует при этом перекомпиляции ПО). Вне зависимсоти от языка программирования.
at_nil Автор
безусловно. но тут в статье другое противопоставление: не enum против каких-то конкретных экземпляров (значений свойств объекта из хранилища); а enum против сущности (объекта, класса, структуры), на которую накладывается контракт для поддержки работы в каком-то общем механизме (в статье это новостная лента)
napa3um
Ну так да, сущность - это и есть "бизнес-специфичный словарь", нужно создавать под каждую требуемую модель данных, а не один универсальный Dictonary "под любой энум", конечно :). У каждого словаря свой цикл жизни, свой своя логика, свой собственный CRUD со своими ограничениями (где-то дубликаты надо фильтровать, где-то размер словаря ограничить, а где-то и подгрузить этот словарь лениво из другой системы). Но это вопрос конкретной архитектуры конкретного приложения, а не абстрактного преимущества словарей перед энумами (или наоборот). (Возможно, не так уловил посыл статьи, но как минимум заголовок готовит именно к такому прочтению.)
at_nil Автор
в статье приведены примеры, когда одним словарём под один `enum` не обойдёшься. Здесь специально для примера взяты свифтовые перечисления с ассоциированными значениями. При этом у разных case разнотипные значения. У Post это Text, у блока клипов — это массив клипов. То есть, по сути, это абсолютно разные сущности, жёстко собранные в один enum. Эти сущности объединяет только то, что они должны быть показаны в ленте (а в ведь в реальном проекте они могут потребоваться не только в ленте). Если бы в качестве enum в таком примере был бы использован только FeedType с перечислением строковых констант типов ленты, и перебор значений был только в парсинге — это было бы лучше, и такой сценарий с перебором типов элементов гетерогенного списка, полученного из другого слоя, я в статье описываю, как допустимый. Но и тут может хватить просто констант, даже не обязательно жёстко зафиксированных одним списком, особенно если есть желание или необходимость не перекомпилировать модуль ленты при добавлении новых типов элементов ленты, если при этом сама логика ленты не меняется.
napa3um
Всё, понял, прошу прощения за невнимательность (Swift-ом только со стороны интересуюсь, пока ещё не практикую), вы в своей статье показали реализацию std::variant из C++ на Swift :).
Но заголовок плохой совершенно точно, как уже отметили ниже, выглядит так, будто речь идёт о бесполезности enum, а не об ошибке программиста, неправильно декомпозирующего бизнеслогику. Энумы полезны в нужных местах, как и все остальные конструкции языка :).
at_nil Автор
всё хорошо, если я правильно понял, вы подсветили другую проблему, которая тоже встречается в коде: выделение разных значений одной сущности (одного сценария) в разные сущности.
at_nil Автор
про std::variant из C++ — да, кажется, это похоже. Мне на ум из аналогий ещё приходит конструкция Sealed classes
at_nil Автор
но, кажется, std::variant является более честным и гибким инструментом, так как принадлежность какому-то набору вариантов не является частью объявления класса и почти что его namespace, а определяется по месту реального использования.
Gordon01
Да он там наколенный, без статических проверок и присущеми этому способы падениями в рантайме с bad_variant_access. match (хотя его вообще в с++ нет) по нему тоже не сделаешь.
Проще свой, наколенный сделать (еще и в яндекс пригласят).
Короче, лучше бы std::variant вообще не было в с++.
napa3um
Ну, в данном случае это прост способ сослаться на стандартный паттерн с понятной семантикой, качество реализации не имеет значения :). Вообще, прежде чем что-то сочинять самостоятельно, полезно заглядывать в "жирные" (энтерпрайзные) платформы / языки программирования типа C++ или Java, в которых уже наверняка подобные проблемы решались, как миниум посмотреть, как оно бывает и какие потанцевальные проблемы в реализации ожидаются :).
at_nil Автор
про бесполезность enum — я считаю этот инстурмент скорее ненужным и бесполезным, чем полезным. поэтому в конце статьи и написал, что по умолчанию против него :) во всяком случае, теперь сам стараюсь не использовать этот инструмент никогда.
napa3um
Этот вывод (в отрыве от решаемой задачи) кажется немношк неправильным. Наверняка энуму найдётся место в программах, прост в других ситуациях :).
at_nil Автор
возможно:) я допускаю enum как допустимый (не лучший или самый правильный, но допустимый) инструмент для быстрого описания набора вариантов. но если же, как я написал в заключении, это множество вариантов начнёт развиваться — очень стоит задуматься над выделением абстракции и неизменяемого общего поведения хотя бы для безопасности кода общего поведения и простоты понимания отдельных сценариев
BeMySlaveDarlin
Не понимаю за что минуса.
В комментарии выводы/размышления, которые уже как больше полусотни лет считаются классическими аксиомами.
at_nil Автор
наверно, из-за того, что я набрасываю на довольно популярный стиль кода. ну или действительно заблуждаюсь в аргументах:)