Как реализовать логику фильтрации с помощью шаблонов проектирования
В этом уроке мы узнаем, как реализовать фильтр множественного выбора, используя 2 шаблона проектирования: стратегию и декоратор.
Учебник будет разделен на 2 части:
- В первой части мы сосредоточимся на реализации логики фильтрации.
- Во второй части мы займемся пользовательским интерфейсом (скоро).
Давайте начнем!
Предисловие
Представьте, что мы создаем приложение для интернет-магазина смартфонов, и нашему владельцу продукта пришла в голову идея отфильтровать смартфоны в каталоге.
Мы должны создать надежное и масштабируемое решение. Это означает, что если мы добавим еще один параметр в фильтр, это не будет проблемой.
Кроме того, фильтр должен быть фильтром с множественным выбором, предполагая, что пользователь может выбрать несколько вариантов одновременно.
Учитывая эти требования, мы начинаем вырабатывать решение.
Модели
Во-первых, мы начнем с определения модели Телефон:
struct Phone { let model: String let price: Int let screenSize: Double let processor: String let memory: Int let diskSpace: Int let color: String }
Во-вторых, мы должны определить модель Спецификация, которая будет использоваться для фильтрации конкретной спецификации смартфона:
enum Specification { case model(String) case price(Int) case screenSize(Double) case processor(String) case memory(Int) case diskSpace(Int) case color(String) }
Каждый случай перечисления содержит связанное значение, которое представляет базовый тип конкретной спецификации.
Теперь, когда все модели настроены, мы можем приступить к реализации функций фильтрации.
Стратегия фильтрации
Мы начинаем с создания контекстного класса и классов, которые соответствуют шаблону проектирования стратегии и отвечают за фильтрацию каждой спецификации смартфона в отдельности.
Стратегия — поведенческий шаблон проектирования, определяющий семейство похожих алгоритмов и помещающий каждый из них в отдельный класс, после чего алгоритмы можно менять местами во время выполнения.
Источник: Шаблоны проектирования: погружение в шаблоны проектирования
Для начала следует наметить протокол, которому будет соответствовать каждый конкретный фильтр:
protocol FilterStrategy { func filter(phones: [Phone], by specs: [Specification]) -> [Phone] }
Он будет содержать только один метод, который имеет два входных параметра:
➊ телефоны — массив телефонов для фильтрации.
➋ specs — массив спецификаций, по которым будет происходить фильтрация.
После этого мы переходим к реализации класса контекста фильтра, который отвечает за управление конкретными фильтрами:
final class Filter { private var strategy: FilterStrategy // 1 init(strategy: FilterStrategy) { self.strategy = strategy } func update(strategy: FilterStrategy) { // 2 self.strategy = strategy } func applyFilter(to phones: [Phone], withSpecs specs: [Specification]) -> [Phone] { return strategy.filter(phones: phones, by: specs) // 3 } }
Именно этот класс может менять стратегию во время выполнения.
➊ Сохраняет ссылку на текущий фильтр.
➋ Функция update, в свою очередь, используется для смены текущей стратегии на новую.
➌ И тогда мы можем взаимодействовать с текущим фильтром через интерфейс, определенный в протоколе.
Установив контекст, мы, наконец, переходим к созданию конкретных фильтров.
Примечание
Мы рассмотрим только один конкретный фильтр, поскольку детали реализации одинаковы для всех фильтров. Полный список фильтров можно посмотреть здесь.
Посмотрим на реализацию ценового фильтра:
final class PriceFilter: FilterStrategy { func filter(phones: [Phone], by specs: [Specification]) -> [Phone] { let priceSpecs = Set(specs.compactMap { (spec) -> Int? in // 1 if case let .price(price) = spec { return price }; return nil // 2 }) return phones.filter { priceSpecs.contains($0.price) } // 3 } }
➊ Мы используем compactMap
для того, чтобы собрать только ценовые параметры, которые в дальнейшем будут использоваться для фильтрации, и убедиться, что они у нас есть. Чтобы включить быстрый поиск, мы обертываем массив в Set.
➋ Синтаксис if case let позволяет нам разворачивать только определенное значение, связанное с регистром перечисления. Подробнее об этом можно узнать здесь.
➌ Наконец, мы применяем фильтр к коллекции телефонов и возвращаем результат.
Закончив с реализацией фильтров, переходим к созданию декоратора фильтров.
Декоратор фильтра
Чтобы включить возможность использования нескольких фильтров одновременно, мы должны создать класс, соответствующий паттерну проектирования декоратора.
Декоратор – шаблон структурного проектирования, который позволяет динамически добавлять новые функции к объектам, заключая их в полезные "обертки".
Источник:Шаблоны проектирования: погружение в шаблоны проектирования
Для начала определим протокол, которому будет соответствовать каждый декоратор:
protocol PhoneFilter { func filter(phones: [Phone], by specs: [Specification]) -> [Phone] }
Он имеет один метод и ту же сигнатуру метода, что и метод FilterStrategy
.
Затем мы создаем базовый класс, соответствующий протоколу PhoneFilter
:
class PhoneFilterDecorator: PhoneFilter { private let phoneFilter: PhoneFilter // 1 init(phoneFilter: PhoneFilter) { self.phoneFilter = phoneFilter } func filter(phones: [Phone], by specs: [Specification]) -> [Phone] { // 2 return phoneFilter.filter(phones: phones, by: specs) } }
Его основная задача — указать интерфейс обертки для каждого конкретного декоратора.
- Класс содержит обернутый объект, соответствующий протоколу
PhoneFilter
, внутри константыphoneFilter
. - Функция
filter
будет переопределена подклассами для обеспечения пользовательской логики фильтрации.
Далее мы создаем класс PhoneBaseFilter
, который будет играть роль фильтра-заполнителя:
class PhoneBaseFilter: PhoneFilter { func filter(phones: [Phone], by specs: [Specification]) -> [Phone] { return phones } }
Мы согласовываем этот класс с протоколом PhoneFilter
и в качестве возвращаемого значения предоставляем тот же массив телефонов, который был передан в функцию.
Мы будем использовать класс PhoneBaseFilter
в качестве фильтра по умолчанию.
Как и в случае со стратегией, мы углубимся в детали реализации только для одного класса декоратора.
Давайте рассмотрим класс PhonePriceFilter
:
final class PhonePriceFilter: PhoneFilterDecorator { // 1 override func filter(phones: [Phone], by specs: [Specification]) -> [Phone] { // 2 let filter = Filter(strategy: PriceFilter()) // 3 let appliedFilterResult = super.filter(phones: phones, by: specs) // 4 let filteredPhones = filter.applyFilter(to: appliedFilterResult, withSpecs: specs) // 5 return filteredPhones } }
➊ Унаследован от базового декоратора — PhoneFilterDecorator
.
➋ Он переопределяет метод фильтрации.
➌ Логика фильтрации обеспечивается стратегией фильтрации.
➍ Мы устанавливаем отфильтрованный массив телефонов на appliedFilterResult
константу, вызывая метод суперфильтра.
Это означает, что если мы передаем, скажем, PhoneDiskSpaceFilter
в PhonePriceFilter
инициализатор, предоставленный исходный массив телефонов будет сначала отфильтрован по условиям дискового пространства, и только потом результат будет передан в PhonePriceFilter
для дальнейшая фильтрация.
➎ Мы используем текущий фильтр (PriceFilter), чтобы отфильтровать результат предыдущего фильтра (например, DiskSpaceFilter), который хранится в константе appliedFilterResult
, поэтому мы передаем appliedFilterResult
на метод applyFilter
.
Это все, что касается декоратора, поэтому давайте перейдем к примеру использования.
Применение
Начнем с создания фиктивных данных, которые будут использоваться для фильтрации:
var phones = [ Phone( model: "iPhone 14", price: 799, screenSize: 6.1, processor: "Apple A15 Bionic", memory: 6, diskSpace: 128, color: "Midnight" ), Phone( model: "iPhone 14 Plus", price: 899, screenSize: 6.7, processor: "Apple A15 Bionic", memory: 6, diskSpace: 256, color: "Starlight" ) ... ]
После того, как это сделано, мы теперь готовы ввести наш фильтр в действие.
Создадим массив спецификаций:
private let specifications: [Specification] = [ .diskSpace(256), .diskSpace(512), .color("Starlight"), .color("Space Black"), .price(1299) ]
Именно этот массив будет заполняться, когда пользователь выбирает параметры в пользовательском интерфейсе.
Теперь осталось только создать цепочку декораторов в нужном порядке.
В этом примере мы создали цепочку из трех декораторов:
override func viewDidLoad() { super.viewDidLoad() let phoneColorFilter = PhoneColorFilter(phoneFilter: PhoneBaseFilter()) // 1 let phoneDiskSpaceFilter = PhoneDiskSpaceFilter(phoneFilter: phoneColorFilter) // 2 let phonePriceFilter = PhonePriceFilter(phoneFilter: phoneDiskSpaceFilter) // 3 dataSource.phones = phonePriceFilter.filter( phones: dataSource.phones, by: specifications ) tableView.reloadData() }
Обратите внимание, как каждый декоратор вставляется один в другой. В этом сила шаблона декоратора.
- Цветовой фильтр даст следующий результат:
- Затем он будет передан фильтру дискового пространства, и результат будет таким:
- В конечном итоге результат предыдущего шага будет передан ценовому фильтру, и конечный результат будет таким:
Заключение
Использование шаблонов проектирования позволяет нам создать гибкое решение, которое значительно упрощает добавление новых параметров фильтрации, не нарушая существующие.
Спасибо за прочтение! Ставьте аплодисменты, если вам понравилось руководство, и следите за обновлениями во второй части, где мы узнаем, как прикрепить логику фильтрации к пользовательскому интерфейсу.
Исходный код
Исходный код этого проекта можно найти в моем репозитории GitHub: