8

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) }
Duncan C
  • 128,072
  • 22
  • 173
  • 272

1 Answers1

12

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) }
Hamish
  • 78,605
  • 19
  • 187
  • 280
  • I thought about variable numbers of arguments. At first I tried to have 1 function with the max count of optional KeyPath arguments and default value of nil for all but the first, but the generic Comparable prevents that. Having umpteen different versions of the method with variable numbers of arguments just feels wrong (And violates the DRY principle.) – Duncan C May 02 '18 at 16:36
  • I like your type-erasing wrapper wrapper though. I'll have to experiment with that. (Voted) – Duncan C May 02 '18 at 16:38
  • Great answer again! – I just wonder about the private underscored properties in struct PartialComparableKeyPath. Why don't you simply define two (public) properties `let isEqual: (Root, Root) -> Bool` and `let isLessThan: (Root, Root) -> Bool` instead of the instance methods? – Martin R May 03 '18 at 13:38
  • @MartinR Thanks! I generally find methods are more ergonomic than function-typed properties, as they allow for argument labels (though not actually used in this specific case), and they auto-complete better – with a function typed property, Swift will just autocomplete the base name, whereas with methods it will also autocomplete placeholders for the arguments, which is nice. Besides that, I don't believe there's any real technical reason to favour one over the other in this case. – Hamish May 03 '18 at 13:51