Subtracting NSCountedSets filled with Swift structs

62 views Asked by At

I have two NSCountedSets and need to subtract them. For the sake of clarity, I am using a simple struct with one property as the objects:

struct MyStruct {
    let name: String
    
    init(_ name: String) {
        self.name = name
    }
}

func subtract(_ set1: NSCountedSet, _ set2: NSCountedSet) -> NSCountedSet {
    var result = NSCountedSet()

    for e in set1 {
        let count1 = set1.count(for: e)
        let count2 = set2.count(for: e)
        let newCount = count1 - count2

        if newCount > 0 {
            for _ in 1 ... newCount {
                result.add(e)
            }
        }
    }

    return result
}

let set1 = NSCountedSet(array: [MyStruct("A"), MyStruct("A"), MyStruct("A"), MyStruct("B"), MyStruct("C"), MyStruct("C")])
let set2 = NSCountedSet(array: [MyStruct("A"), MyStruct("B"), MyStruct("C")])

let set3 = subtract(set1, set2) // expected result: ["A", "A", "C"]`

print(set3.count(for: MyStruct("A"))) // observed result: 0 (expected 2)
print(set3.count(for: MyStruct("B"))) // observed result: 0 (expected 0)
print(set3.count(for: MyStruct("C"))) // observed result: 0 (expected 1)

But if I for instance use:

let set1 = NSCountedSet(array: ["A", "A", "A", "B", "C", "C"])
let set2 = NSCountedSet(array: ["A", "B", "C"])

I get the expected result.

I suspect that each MyStruct("A") etc is actually not equal to the other ones since they are recreated. Which is what I see when I add a print statement, count1 is always 1 and count2 is always 0

So maybe there is a way that I can count the number of MyStruct by checking the name property, is that possible?

If not, is there maybe any other solution?

3

There are 3 answers

3
Dávid Pásztor On BEST ANSWER

You need to make your type conform to Hashable to make NSCountedSet compare its elements based on their properties.

struct MyStruct: Hashable {
  let name: String

  init(_ name: String) {
    self.name = name
  }
}

There's also no need to define a subtract method, NSCountedSet already inherits minus from its NSMutableSet superclass

set1.minus(Set(_immutableCocoaSet: set2))

Be aware that minus mutates the set it's called on in place, rather than returning a new instance.

If you want to declare a non-mutating variant of minus without having to reimplement its logic, you can simply do that by copying the set, the calling minus on the copy.

extension NSMutableSet {
  func subtracting(_ other: NSMutableSet) -> NSMutableSet {
    let copy = self.mutableCopy() as! NSMutableSet
    copy.minus(.init(_immutableCocoaSet: other))
    return copy
  }
}

let subtracted = set1.subtracting(set2)
0
Alexander On

Arg, this is a frustrating feature of Swift I've been wishing gone for years now. It was occasionally handy in the early days of the ObjC migration, but it's now far out-stayed its welcome.

To help with Objective-C interop (which to be clear, was outstandingly important, and responsible for much of Swift's success today), Swift lets you pass non-@objc values in some places. Because Objective C only deals with pointers-to-objects, to make that work, the Swift values need to be boxed. Internally, there's an @objc class __SwiftValue that's used.

Frustratingly, this boxing is implicit (so it's hard to detect, apart from the fallout it causes) and worse yet, it's only enabled when you import Foundation.

Here's a demo to explain what's going on:

import Foundation

struct MyValue {
    let i: Int
}

struct MyValue: Equatable {}
// struct MyValue: Hashable {} // Un-commenting this will fix everything

@objc class Demo: NSObject {
    @objc func compare(_ a: Any, _ b: Any) -> Bool {
        // Do the comparison using `isEqual:`, which is what ObjC uses:
        return (a as AnyObject).isEqual(b)
    }
    
    @objc func inpsectClass(_ a: Any) {
        // `type(of:)` is cleverly implemented to hide the fact that it's happening.
        // it knows how to unwrap the `__SwiftValue` box
        print(type(of: a)) // => MyValue
    
        // `object_getClass` gives the honest answer:
        print(object_getClass(a as AnyObject)!) // => `__SwiftValue`
    }
}

let demo = Demo()

let a = MyValue(i: 123)
let b = MyValue(i: 123)

print("Does Swift think they're equal? \(a == b)" ) // => true
print("Does Obj-C think they're equal? \(demo.compare(a, b))") // => false

demo.inpsectClass(a)

Interestingly, this completely changes if you also conform MyValue to Hashable (instead of just Equatable). This triggers the __SwiftValue box to have +isEqual: and +hash methods which delegate to == and hashValue in Swift, which will make things work correctly.

Most frustratingly of all, Swift usually protects you from using == or hashValues on values that don't support them. This'll prompt you to implement the missing protocols. With the implicit boxing here, Objective C is allowed to call +isEqual: and +hash on these boxes, which give the wrong answers, without any indication that you're missing the Hashable conformance.

0
Alexander On

I already made an answer that explains what's going on, but I'd like to chime in with some improvements to this code you might like:

  1. listen to the warnings, var result doesn't need to be mutable (you modify the the pointed-to NSCountedSet object, but not the result pointer itself), you can just make it let result

  2. NSCountedSet conforms to ExpressibleByArrayLiteral, so you can initialize it with

    let set1: NSCountedSet = [MyStruct("A"), ...]
    
  3. Your subtract(_:_:) free function is better expressed as an - operator on NSCountedSet:

    extension NSCountedSet {
        static func - (lhs: NSCountedSet, rhs: NSCountedSet) -> NSCountedSet {
            let copy = lhs.mutableCopy() as! NSCountedSet
            copy -= rhs
            return copy
        }
    
        static func -= (lhs: NSCountedSet, rhs: NSCountedSet) {
            lhs.minus(.init(_immutableCocoaSet: rhs))
        }
    }
    
    let set1: NSCountedSet = [MyStruct("A"), MyStruct("A"), MyStruct("A"), MyStruct("B"), MyStruct("C"), MyStruct("C")]
    let set2: NSCountedSet = [MyStruct("A"), MyStruct("B"), MyStruct("C")]
    
    let set3 = set1 - set2 // expected result: ["A", "A", "C"]`
    print(set3)
    
    1. When working with NSCountedSet, it's nice to have a convenient way inspect its contents. One easy way is to make a helper function that converts it into an array of tuples:
    extension NSCountedSet {
        func asTuples() -> [(Any, count: Int)] {
            map { ($0, self.count(for: $0)) }
        }
    }
    print(set2.asTuples()) // => [(main.MyStruct(name: "B"), count: 1), (main.MyStruct(name: "C"), count: 1), (main.MyStruct(name: "A"), count: 1)]
    

    You could convert it to a dictionary instead, but the result is much nosier:

    extension NSCountedSet {
        func asDictionary() -> [AnyHashable: Int] {
            Dictionary(uniqueKeysWithValues: self.lazy.map { (key: $0 as! AnyHashable, value: self.count(for: $0)) })
        }
    }
    print(set2.asDictionary()) // [AnyHashable(main.MyStruct(name: "C")): 1, AnyHashable(main.MyStruct(name: "A")): 1, AnyHashable(main.MyStruct(name: "B")): 1]