Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use heterogeneous array of Swift KeyPaths

It seems like it should be possible to use an array of KeyPaths as sort keys to sort an array of Swift structs using an arbitrary number of sort keys. Conceptually, it's simple. You define an array of KeyPaths to a generic object, where the only restriction is that the properties in the keypath be Comparable.

As long as all the KeyPaths point to properties of the same type, all is well. However, as soon as you try to use KeyPaths that point to elements of different types into the array, it stops working.

See the code below. I create a simple struct with 2 Int properties and a double property. I create an extension to Array that implements a function sortedByKeypaths(_:) That function specifies a generic type PROPERTY that is Comparable. It takes an array of kepaths to some object Element that specifies properties of type PROPERTY. (Comparable properties.)

As long as you call that function using an array of KeyPaths to properties that are all of the same type, it works perfectly.

However, if you try to pass in array of keypaths to properties of different types, it throws an error "cannot convert value of type '[PartialKeyPath]' to expected argument type '[KeyPath]'"

Because the array contains heterogenious keypaths, the array devolves to type '[PartialKeyPath]` due to type erasure, and you can't use a PartialKeyPath to fetch elements from an array.

Is there a solution to this problem? Not being able to use a heterogenious array of KeyPaths seems like it severely limits the usefulness of Swift KeyPaths

import UIKit

struct Stuff {
    let value: Int
    let value2: Int
    let doubleValue: Double
}

extension Array {

    func sortedByKeypaths<PROPERTY: Comparable>(_ keypaths: [KeyPath<Element, PROPERTY>]) -> [Element] {
        return self.sorted { lhs, rhs in
            var keypaths = keypaths
            while !keypaths.isEmpty {
                let keypath = keypaths.removeFirst()
                if lhs[keyPath: keypath] != rhs[keyPath: keypath] {
                    return lhs[keyPath: keypath] < rhs[keyPath: keypath]
                }
            }
            return true
        }
    }
}

var stuff = [Stuff]()

for _ in 1...20 {
    stuff.append(Stuff(value: Int(arc4random_uniform(5)),
                       value2: Int(arc4random_uniform(5)),
                 doubleValue: Double(arc4random_uniform(10))))
}

let  sortedStuff = stuff.sortedByKeypaths([\Stuff.value, \Stuff.value2]) //This works
sortedStuff.forEach { print($0) }

let  moreSortedStuff = stuff.sortedByKeypaths([\Stuff.value, \Stuff.doubleValue]) //This throws a compiler error
moreSortedStuff.forEach { print($0) }
like image 958
Duncan C Avatar asked May 02 '18 13:05

Duncan C


1 Answers

The problem with using an array of partial keypaths is that you have no guarantee that the property types are Comparable. One possible solution would be to use a type erasing wrapper in order to erase the value type of a key path, while ensuring that it is Comparable:

struct PartialComparableKeyPath<Root> {

  private let _isEqual: (Root, Root) -> Bool
  private let _isLessThan: (Root, Root) -> Bool

  init<Value : Comparable>(_ keyPath: KeyPath<Root, Value>) {
    self._isEqual = { $0[keyPath: keyPath] == $1[keyPath: keyPath] }
    self._isLessThan = { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
  }

  func isEqual(_ lhs: Root, _ rhs: Root) -> Bool {
    return _isEqual(lhs, rhs)
  }

  func isLessThan(_ lhs: Root, _ rhs: Root) -> Bool {
    return _isLessThan(lhs, rhs)
  }
}

Then you could implement your sorting function as:

extension Sequence {

  func sorted(by keyPaths: PartialComparableKeyPath<Element>...) -> [Element] {
    return sorted { lhs, rhs in
      for keyPath in keyPaths {
        if !keyPath.isEqual(lhs, rhs) {
          return keyPath.isLessThan(lhs, rhs)
        }
      }
      return false
    }
  }
}

and then use like this:

struct Stuff {
  let value: Int
  let value2: Int
  let doubleValue: Double
}

var stuff = [Stuff]()

for _ in 1 ... 20 {
  stuff.append(Stuff(value: Int(arc4random_uniform(5)),
                     value2: Int(arc4random_uniform(5)),
                     doubleValue: Double(arc4random_uniform(10))))
}


let sortedStuff = stuff.sorted(by: PartialComparableKeyPath(\.value),
                                   PartialComparableKeyPath(\.value2))
sortedStuff.forEach { print($0) }

let moreSortedStuff = stuff.sorted(by: PartialComparableKeyPath(\.value),
                                       PartialComparableKeyPath(\.doubleValue))
moreSortedStuff.forEach { print($0) }

Though unfortunately, this requires wrapping each individual keypath in a PartialComparableKeyPath value in order to capture and erase the value type for the keypath, which isn't particularly pretty.

Really the feature we need here is variadic generics, which would let you define your function over a variable number of generic placeholders for the value types of your keypaths, each constrained to Comparable.

Until then, another option would be to just write a given number of overloads for the different number of keypaths to compare:

extension Sequence {
  func sorted<A : Comparable>(by keyPathA: KeyPath<Element, A>) -> [Element] {
    return sorted { lhs, rhs in
      lhs[keyPath: keyPathA] < rhs[keyPath: keyPathA]
    }
  }

  func sorted<A : Comparable, B : Comparable>
    (by keyPathA: KeyPath<Element, A>, _ keyPathB: KeyPath<Element, B>) -> [Element] {
    return sorted { lhs, rhs in
      (lhs[keyPath: keyPathA], lhs[keyPath: keyPathB]) <
        (rhs[keyPath: keyPathA], rhs[keyPath: keyPathB])
    }
  }

  func sorted<A : Comparable, B : Comparable, C : Comparable>
    (by keyPathA: KeyPath<Element, A>, _ keyPathB: KeyPath<Element, B>, _ keyPathC: KeyPath<Element, C>) -> [Element] {
    return sorted { lhs, rhs in
      (lhs[keyPath: keyPathA], lhs[keyPath: keyPathB], lhs[keyPath: keyPathC]) <
        (rhs[keyPath: keyPathA], rhs[keyPath: keyPathB], rhs[keyPath: keyPathC])
    }
  }

  func sorted<A : Comparable, B : Comparable, C : Comparable, D : Comparable>
    (by keyPathA: KeyPath<Element, A>, _ keyPathB: KeyPath<Element, B>, _ keyPathC: KeyPath<Element, C>, _ keyPathD: KeyPath<Element, D>) -> [Element] {
    return sorted { lhs, rhs in
      (lhs[keyPath: keyPathA], lhs[keyPath: keyPathB], lhs[keyPath: keyPathC], lhs[keyPath: keyPathD]) <
        (rhs[keyPath: keyPathA], rhs[keyPath: keyPathB], rhs[keyPath: keyPathC], rhs[keyPath: keyPathD])
    }
  }

  func sorted<A : Comparable, B : Comparable, C : Comparable, D : Comparable, E : Comparable>
    (by keyPathA: KeyPath<Element, A>, _ keyPathB: KeyPath<Element, B>, _ keyPathC: KeyPath<Element, C>, _ keyPathD: KeyPath<Element, D>, _ keyPathE: KeyPath<Element, E>) -> [Element] {
    return sorted { lhs, rhs in
      (lhs[keyPath: keyPathA], lhs[keyPath: keyPathB], lhs[keyPath: keyPathC], lhs[keyPath: keyPathD], lhs[keyPath: keyPathE]) <
        (rhs[keyPath: keyPathA], rhs[keyPath: keyPathB], rhs[keyPath: keyPathC], rhs[keyPath: keyPathD], rhs[keyPath: keyPathE])
    }
  }

  func sorted<A : Comparable, B : Comparable, C : Comparable, D : Comparable, E : Comparable, F : Comparable>
    (by keyPathA: KeyPath<Element, A>, _ keyPathB: KeyPath<Element, B>, _ keyPathC: KeyPath<Element, C>, _ keyPathD: KeyPath<Element, D>, _ keyPathE: KeyPath<Element, E>, _ keyPathF: KeyPath<Element, F>) -> [Element] {
    return sorted { lhs, rhs in
      (lhs[keyPath: keyPathA], lhs[keyPath: keyPathB], lhs[keyPath: keyPathC], lhs[keyPath: keyPathD], lhs[keyPath: keyPathE], lhs[keyPath: keyPathF]) <
        (rhs[keyPath: keyPathA], rhs[keyPath: keyPathB], rhs[keyPath: keyPathC], rhs[keyPath: keyPathD], rhs[keyPath: keyPathE], rhs[keyPath: keyPathF])
    }
  }
}

I've defined them up to 6 keypaths, which should be sufficient for the majority of sorting cases. We're taking advantage of the lexicographic tuple comparison overloads of < here, as also demonstrated here.

While the implementation isn't pretty, the call-site now looks much better, as it lets you say:

let sortedStuff = stuff.sorted(by: \.value, \.value2)
sortedStuff.forEach { print($0) }

let moreSortedStuff = stuff.sorted(by: \.value, \.doubleValue)
moreSortedStuff.forEach { print($0) }
like image 109
Hamish Avatar answered Oct 24 '22 23:10

Hamish