Search code examples
swiftobjective-cobjective-c-runtime

How arguments are passed in performSelector


I'm testing perform(_:with:) with the following code

class ObjectA: NSObject {
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}

class ObjectB: NSObject {
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}

class ObjectC: NSObject {
    let date = Date()
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}

class Main: NSObject {
    @objc func printValue(_ o: ObjectA) {
        print("Value: \(o.value)")
    }
}

Main().perform(NSSelectorFromString("printValue:"), with: ObjectB(value: 2))

It works if I pass ObjectB instead of ObjectA.

But it doesn't work if I pass ObjectC instead of ObjectA.

we can say ObjectB is compatible with ObjectA, but ObjectC is not compatible with ObjectA.

After more testing with the following classes

class ObjectA: NSObject {
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}
class ObjectC: NSObject {
    let date = Date()
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}
class ObjectD: NSObject {
    let value: Int
    let date = Date()
    
    init(value: Int) {
        self.value = value
    }
}

class ObjectE: NSObject {
    let date = Date()
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}

class ObjectF: NSObject {
    let date = NSDate()
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}

class ObjectG: NSObject {
    let date = NSDate()
    let value: Int
    
    init(value: Int) {
        self.value = value
    }
}

I found, ObjectD is compatible with ObjectA.

ObjectC is not compatible with ObjectE

ObjectF is compatible with ObjectG

Anyone knows how the arguments are passed?


Solution

  • It gets even weirder

    oh hey, would you look at that

    import Foundation
    
    class WrapperOfInt16: NSObject {
        let value = 123
    }
    
    class TwoBytes: NSObject {
        let byte1: UInt8 = 0xAB
        let byte2: UInt8 = 0xCD
    }
    
    class Main: NSObject {
        @objc func printValue(_ o: WrapperOfInt16) {
            print("Value: \(String(o.value, radix: 16))") // => Value: cdab
        }
    }
    
    Main().perform(NSSelectorFromString("printValue:"), with: TwoBytes())
    

    Why does this happen?

    You've usurped the type-system.

    The optimizer assumes the types of your program to be a true reflection of what will happen at runtime. It makes its optimizations given that assumption to improve performance, without any perceptible difference in behaviour.

    The issue is that types are unsound, the assumption is wrong, and the optimizations are invalid as a result.

    Swift's philosophy

    In Swift, the strong type system can be used to prove that o is always* (unless you subvert it with runtime tricks) of type ObjectA, so you'll always be invoking the same implementation of the value method (property). Rather than repeatedly wasting time looking up the correct implementation at runtime (dynamic dispatching), the compiler will emit a direct call to implementation of value for ObjectA (static dispatch).

    This is much faster, and it's correct for ObjectA, but will be incorrect if you manage to smuggle in a different type.

    The implementation of ObjectA.value is to just read out the contents of the object at a particular offset, and to interpret that sequence of bits as an Int.

    Objective-C

    Objective-C's philosophy is much more dynamic. Many of its dynamic aspects (e.g. method swizzling, KVO, Cocoa bindings, NSProxy, etc.) involve arbitrarily replacing/modifying methods at runtime.

    You can think of static dispatch as a kind of caching. It's fast, but if the underlying value changes, the static dispatch will be incorrect (it'll keep calling the old thing).

    Thus, most Objective-C code is written with the understanding that objects/classes could have been modified at any time (e.g. KVO will add methods that detect changes to properties and notify observers), so dynamic dispatched is used pervasively (heck, it couldn't even do automatic static dispatch if it wanted, until recently). This is done even at the expense of performance (though there are tricks to workaround this when necessary).

    The fix

    To "fix" your problem, you should fix the types in your program.

    ...but if you want the dynamism, then you have two options.

    1. You can manually do some dynamic dispatch:

      // You could also have used `performSelector`
      print("Value: \(o.value(forKey: "value") as? Int)")
      
    2. Mark the field as dynamic, which will make the compiler always emit dynamic dispatch calls for its lookups, even when static type information suggests only one implementation should exist:

      import Foundation
      
      class ObjectA: NSObject {
          @objc let value: Int
          @objc dynamic let x: Int = 123
      
          init(value: Int) {
              self.value = value
          }
      }
      
      class ObjectB: NSObject {
          @objc let value: Int
      
          init(value: Int) {
              self.value = value
          }
      }
      
      class ObjectC: NSObject {
          let date = Date()
          @objc dynamic let value: Int
      
          init(value: Int) {
              self.value = value
          }
      }
      
      class Main: NSObject {
          @objc dynamic func printValue(_ o: ObjectA) {
              print("Value: \(o.value)")
          }
      }
      
      Main().perform(NSSelectorFromString("printValue:"), with: ObjectB(value: 2))
      

    Both of these have the benefit of catching cases where the object doesn't have a value at all:

    class HasNoValue: NSObject {}
    
    Main().perform(NSSelectorFromString("printValue:"), with: HasNoValue())
    

    Terminating app due to uncaught exception NSInvalidArgumentException, reason: '-[main.HasNoValue value]: unrecognized selector sent to instance 0x600003edc070'