Как реализовать логику фильтрации с помощью шаблонов проектирования

В этом уроке мы узнаем, как реализовать фильтр множественного выбора, используя 2 шаблона проектирования: стратегию и декоратор.

Учебник будет разделен на 2 части:

  1. В первой части мы сосредоточимся на реализации логики фильтрации.
  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: