heyfrank
heyfrank

Reputation: 5647

Type Erasure for more complex protocols

I'm building a declarative and type-based filter model. I'm stuck storing the state of the active filters in a property, because my Protocols have associated types.``

I heard about Type Erasure, but all examples I found where using super easy examples and somehow I can't map it to my use case.

This is my protocol:

protocol Filter {
    // The Type to be filtered (`MyModel`)
    associatedtype ParentType
    // The type of the property to be filtered (e.g `Date`)
    associatedtype InputType
    // The type of the possible FilterOption (e.g. `DateFilterOption` or the same as the Input type for filtering in enums.)
    associatedtype OptionType        
    // This should return a list of all possible filter options
    static var allOptions: [OptionType] { get }

    static var allowsMultipleSelection: Bool { get }        
    // the adopting object will be setting this.
    var selectedOptions: [OptionType] { get set }        

    func isIncluded(_ item: InputType) -> Bool        
    // For getting reference to the specific property. I think Swift 4's keypaths could be working here too.
    var filter: FilterClosure<ParentType> { get }
}

And sub protocols which have extensions for reducing copy/paste-code

protocol EquatableFilter: Filter where InputType: Equatable, OptionType == InputType {}
extension EquatableFilter {
    var allowsMultipleSelection: Bool { return true }
    func isIncluded(_ item: InputType) -> Bool {
        if selectedOptions.count == 0 { return true }
        return selectedOptions.contains(item)
    }
}

// Another specific filter. See gist file for extension.
protocol DateFilter: Filter where InputType == Date, OptionType == DateFilterOption {}

For more code, please see my gist to see how my implementation looks, with a example model.


Questions

  1. How can I store an array containing struct instances, conforming to different Filter protocols?

  2. And how can I store a static array, containing only the types of the structs, so I can access the static properties?

Upvotes: 2

Views: 298

Answers (1)

Rob Napier
Rob Napier

Reputation: 299325

Interestingly, I built something not unlike this earlier this year for a commercial project. It's challenging to do in a general way, but most of the problems come from backwards thinking. "Start with the end in mind."

// I want to be able to filter a sequence like this:
let newArray = myArray.filteredBy([
    MyModel.Filters.DueDateFilter(selectedOptions: [.in24hours(past: false)]),
    MyModel.Filters.StatusFilter(selectedOptions: [.a, .b])
    ])

This part is very straightforward. It doesn't even require filteredBy. Just add .filter to each element:

let newArray = myArray
    .filter(MyModel.Filters.DueDateFilter(selectedOptions: [.in24hours(past: false)]).filter)
    .filter(MyModel.Filters.StatusFilter(selectedOptions: [.a, .b]).filter)

If you wanted to, you can write filtered by this way, and do the same thing:

func filteredBy(_ filters: [(Element) -> Bool]) -> [Element] {...}

The point is that Filter here isn't really a "filter." It's a description of a filter, with lots of other things attached about the UI (we'll talk about that more later). To actually filter, all you need is (Element) -> Bool.

What we really want here is a way to build up a ([Element]) -> Element with a nice, expressive syntax. In a functional language, that'd be pretty straightforward because we'd have things like partial application and functional composition. But Swift doesn't really like doing those things, so to make it prettier, let's build some structs.

struct Filter<Element> {
    let isIncluded: (Element) -> Bool
}

struct Map<Input, Output> {
    let transform: (Input) -> Output
}

We'll need a way to get started, so let's use an identity map

extension Map where Input == Output {
    init(on: Input.Type) { transform = { $0 }}
}

And we'll want a way to think about keyPaths

extension Map {
    func keyPath<ChildOutput>(_ keyPath: KeyPath<Input, ChildOutput>) -> Map<Input, ChildOutput> {
        return Map<Input, ChildOutput>(transform: { $0[keyPath: keyPath] })
    }
}

And finally we'll want to create an actual filter

extension Map {
    func inRange<RE: RangeExpression>(_ range: RE) -> Filter<Input> where RE.Bound == Output {
        let transform = self.transform
        return Filter(isIncluded: { range.contains(transform($0)) })
    }
}

Add a helper for "last 24 hours"

extension Range where Bound == Date {
    static var last24Hours: Range<Date> { return Date(timeIntervalSinceNow: -24*60*60)..<Date() }
}

And now we can build a filter that looks like:

let filters = [Map(on: MyModel.self).keyPath(\.dueDate).inRange(Range.last24Hours)]

filters is of type Filter<MyModel>, so any other thing that filters MyModel is legitimate here. Tweaking your filteredBy:

extension Sequence {
    func filteredBy(_ filters: [Filter<Element>]) -> [Element] {
        return filter{ element in filters.allSatisfy{ $0.isIncluded(element) } }
    }
}

OK, that's the filtering step. But your problem is also basically "UI configuration" and for that you want to capture a lot more elements than this does.

But your example usage won't get you there:

// Also I want to be able to save the state of all filters like this
var activeFilters: [AnyFilter] = [ // ???
    MyModel.Filters.DueDateFilter(selectedOptions: [.in24hours(past: false)]),
    MyModel.Filters.StatusFilter(selectedOptions: [.a, .b])
]

How can you transform AnyFilter into UI elements? Your filter protocol allows literally any option type. How would you display the UI for that if the option type were OutputStream or DispatchQueue? The type you've created doesn't address the problem.

Here's one way to go about it. Create a FilterComponent struct that defines the needed UI elements and provides a way to construct a filter.

struct FilterComponent<Model> {
    let optionTitles: [String]
    let allowsMultipleSelection: Bool
    var selectedOptions: IndexSet
    let makeFilter: (IndexSet) -> Filter<Model>
}

Then to create a date filter component, we need some options for dates.

enum DateOptions: String, CaseIterable {
    case inPast24hours = "In the past 24 hours"
    case inNext24hours = "In the next 24 hours"

    var dateRange: Range<Date> {
        switch self {
        case .inPast24hours: return Date(timeIntervalSinceNow: -24*60*60)..<Date()
        case .inNext24hours: return Date()..<Date(timeIntervalSinceNow: -24*60*60)
        }
    }
}

And then we want a way to create such a component with the correct makeFilter:

extension FilterComponent {
    static func byDate(ofField keyPath: KeyPath<Model, Date>) -> FilterComponent<Model> {
        return FilterComponent(optionTitles: DateOptions.allCases.map{ $0.rawValue },
                               allowsMultipleSelection: false,
                               selectedOptions: [],
                               makeFilter: { indexSet in
                                guard let index = indexSet.first else {
                                    return Filter<Model> { _ in true }
                                }
                                let range = DateOptions.allCases[index].dateRange
                                return Map(on: Model.self).keyPath(keyPath).inRange(range)
        })
    }
}

With all that, we can create components of type FilterComponent<MyModel>. No internal types (like Date) have to be exposed. No protocols needed.

let components = [FilterComponent.byDate(ofField: \MyModel.dueDate)]

Upvotes: 3

Related Questions