Introduce
Good code is not just the code that solves the problem with high performance, it must also be a code that can be read and understood as natural language. Based on that, people who do not write down these lines of code or even those without programming knowledge can understand what their task is.
So let’s look at the following code:
1 2 3 |
let array = Array(1...100) array.filter { $0 % 2 == 0} |
This code is used to filter out even numbers, and it also looks pretty easy to understand. However, the problem is only more complicated when we add some filtering conditions, such as:
1 2 3 |
let array = Array(1...100) array.filter { $0 % 2 == 0 && $0 < 40 && $0 > 10} |
It also looks cumbersome, right? If you adjust it as follows to look clearer:
1 2 3 4 5 |
let array = Array(1...100) array.filter { $0 % 2 == 0 } .filter { $0 < 40 } .filter{ $0 > 10} |
It unintentionally increased the number of iterations more than before, because now it has to filter again two more times from the array of results of the first filter. Check out the results below:
Case Study
Let’s dig deeper with the following example, we created a struct Person that defines properties like name, eye color, hair color. And the enum contains the following eye and hair color:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
enum EyeColor { case dark, blue, green, brown } enum HairColor { case brunette, blonde, ginger, dark } struct Person { var name: String var eyesColor: EyeColor var hairColor: HairColor } |
We can easily filter out people with certain eye colors and hair colors by combining the filter conditions:
1 2 3 |
let people = [ ... ] let subset = people.filter { ($0.eyesColor == .green && $0.hairColor == .blonde) || ($0.eyesColor == .blue && $0.hairColor == .ginger) } |
But it is a little hard to read and maintain, so we will revise the code above to make it more readable and maintainable.
Filter
Let’s wrap the filter condition into an object as follows:
1 2 3 4 5 |
struct Filter<Element> { typealias Condition = (Element) -> Bool var condition: Condition } |
The struct Filter will now contain filtering conditions for the generic element, so that we can use it for many different objects without having to create other struct. Then extend the Array to use the Filter:
1 2 3 4 5 6 |
extension Array { func filtering(_ filter: Filter<Element>) -> Array { self.filter(filter.condition) } } |
We can add matching and non-matching components to the given conditions, and the results will include both types of output:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
extension Filter { struct Result { private var matchingBlock: () -> Array<Element> private var restBlock: () -> Array<Element> var matching: Array<Element> { matchingBlock() } var rest: Array<Element> { restBlock() } init(matching: @escaping @autoclosure () -> Array<Element>, rest: @escaping @autoclosure () -> Array<Element>) { self.matchingBlock = matching self.restBlock = rest } } } |
And we wrap the components in a closure, so that the filter will only be done when it is called, we will adjust the Array extension as follows:
1 2 3 4 5 6 7 |
extension Array { func filtering(_ filter: Filter<Element>) -> Filter<Element>.Result { Filter.Result(matching: self.filter(filter.condition), rest: self.filter((!filter).condition)) } } |
Now we can write:
let subset = people.filter { $0.eyesColor == .blue }
Fort:
1 2 3 |
let hasBlueEyes = Filter<Person> { $0.eyesColor == .blue } let subset = people.filtering(hasBlueEyes).matching |
It looks neater and easier to read than right, but what if we need more conditions to filter?
1 2 3 4 5 |
let hasBlondeHair = Filter<Person> { $0.hairColor == .blonde } let hasBlueEyes = Filter<Person> { $0.eyesColor == .blue } let subset = people.filtering(hasBlueEyes).mathing .filtering(hasBlondeHair).matching |
We have a performance problem again, as we mentioned at the beginning of this article. However we will fix it in the next section below, let’s continue reading offline.
Add additional functions
To support the combination of filter conditions, we can create additional functions for it, these are the basic functions for operators like and, or.
1 2 3 4 5 6 7 8 9 10 |
var inverted: Self { .init { !self.condition($0) } } func and(_ filter: Self) -> Self { .init { filter.condition($0) && self.condition($0) } } func or(_ filter: Self) -> Self { .init { filter.condition($0) || self.condition($0) } } |
We can rewrite the above code using the following new operators:
let subset = people.filtering(hasBlueEyes.and(hasBlondeHair)).matching
Even more functions can be created:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
struct Filter<Element> { typealias Condition = (Element) -> Bool var condition: Condition } extension Filter { static var all: Self { .init { _ in true } } static var none: Self { .init { _ in false } } var inverted: Self { .init { !self.condition($0) } } func and(_ filter: Self) -> Self { .init { filter.condition($0) && self.condition($0) } } func or(_ filter: Self) -> Self { .init { filter.condition($0) || self.condition($0) } } static prefix func ! (_ filter: Self) -> Self { filter.inverted } static func & (_ lhs: Self, _ rhs: Self) -> Self { lhs.and(rhs) } static func | (_ lhs: Self, _ rhs: Self) -> Self { lhs.or(rhs) } static func any(of filters: Self...) -> Self { Self.any(of: filters) } static func any(of filters: [Self]) -> Self { filters.reduce(.none, |) } static func not(_ filters: Self...) -> Self { Self.combine(filters.map { !$0 }) } static func combine(_ filters: [Self]) -> Self { filters.reduce(.all, &) } static func combine(_ filters: Self...) -> Self { Self.combine(filters) } } |
Combining filter conditions will now be very easy, we can write it as follows:
1 2 3 4 5 6 |
let hasBlondeHair = Filter<Person> { $0.hairColor == .blonde } let hasBlueEyes = Filter<Person> { $0.eyesColor == .blue } let result1 = people.filtering(!hasBlueEyes) let result2 = people.filtering(hasBlueEyes & hasBlondeHair) let result3 = people.filtering(hasBlueEyes | !hasBlondeHair) |
Final Result
To create reusable filters, we can extend the Filter and add the following common cases:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
extension Filter where Element == Person { static let brownEyes = Filter { $0.eyesColor == .brown } static let blueEyes = Filter { $0.eyesColor == .blue } static let darkEyes = Filter { $0.eyesColor == .dark } static let greenEyes = Filter { $0.eyesColor == .green } static let brunette = Filter { $0.hairColor == .brunette } static let blonde = Filter { $0.hairColor == .blonde } static let ginger = Filter { $0.hairColor == .ginger } static let darkHair = Filter { $0.hairColor == .dark } static func name(startingWith letter: String) -> Filter { Filter { $0.name.starts(with: letter) } } } |
Let’s return to the code from the beginning that we want to refactor:
1 2 |
let subset = people.filter { ($0.eyesColor == .green && $0.hairColor == .blonde) || ($0.eyesColor == .blue && $0.hairColor == .ginger) } |
It was rewritten to:
1 2 3 |
let subset = people.filtering(Filter.greenEyes.and(.blonde) .or(Filter.ginger.and(.blueEyes))).matching |
Filter even more complex cases:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
let people = [ Person(name: "Eliott", eyesColor: .brown, hairColor: .blonde), Person(name: "Eoin", eyesColor: .brown, hairColor: .brunette), Person(name: "Michelle", eyesColor: .brown, hairColor: .brunette), Person(name: "Kevin", eyesColor: .blue, hairColor: .brunette), Person(name: "Jessica", eyesColor: .green, hairColor: .brunette), Person(name: "Thomas", eyesColor: .dark, hairColor: .dark), Person(name: "Oliver", eyesColor: .dark, hairColor: .blonde), Person(name: "Jane", eyesColor: .blue, hairColor: .ginger), Person(name: "Justine", eyesColor: .brown, hairColor: .dark), Person(name: "Joseph", eyesColor: .brown, hairColor: .brunette), Person(name: "Michael", eyesColor: .blue, hairColor: .dark) ] people.filtering(.combine(.name(startingWith: "E"), .brownEyes, .blonde)).matching // Eliott people.filtering(.any(of: .ginger, .blonde, .greenEyes)).matching // Eliott, Jessica, Oliver, Jane people.filtering(Filter.not(.name(startingWith: "J"), .brownEyes).and(.brunette)).matching // Kevin people.filtering(Filter.greenEyes.and(.blonde).or(Filter.ginger.and(.blueEyes))).matching // Jane |
Or filter both Dog …
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
enum DogBreed { case pug case husky case boxer case bulldog case chowChow } struct Dog { var name: String var breed: DogBreed } let dog = [ Dog(name: "Rudolph", breed: .husky), Dog(name: "Hugo", breed: .boxer), Dog(name: "Trinity", breed: .pug), Dog(name: "Neo", breed: .pug), Dog(name: "Sammuel", breed: .chowChow), Dog(name: "Princess", breed: .bulldog) ] extension Filter where Element == Dog { static let pug = Filter { $0.breed == .pug } static let husky = Filter { $0.breed == .husky } static let boxer = Filter { $0.breed == .boxer } static let bulldog = Filter { $0.breed == .bulldog } static let chowChow = Filter { $0.breed == .chowChow } } dog.filtering(.boxer).matching // Hugo dog.filtering(.not(.husky, .chowChow)).rest // Rudolph, Sammuel dog.filtering(Filter.boxer.or(.chowChow)).matching // Hugo, Sammuel |
Conclude
For programmers, coding doesn’t just stop at solving problems, it has to be easy to read, understand, and maintain, and it can be read as a natural language. Using generics is also one of the ways we can easily reuse and maintain. In the process of creating an application, writing better code is an interesting challenge that programmers must always try to develop.