Pitch: Allow @objc for all property declarations regardless of type


(Charles Srstka) #1

INTRODUCTION:

This pitch proposes to allow the @objc keyword on all property declarations, even ones whose type cannot be represented in Objective-C.

MOTIVATION:

You’re thinking, “But that’s crazy. Why would you ever want to do that?” But hear me out:

- In Swift 4, barring cases where it’s required for technical reasons, properties are exposed to Objective-C only if there’s an actual @objc keyword present. This keyword represents a clear statement of intent that this protocol is meant to be visible to Objective-C, and means that expanding the scope of the @objc keyword will not increase code size anywhere other than where a deliberate decision has been made to do so.

- Since Swift 3, all Swift types are in fact bridgeable to Objective-C, and an ‘Any’ is bridged to an ‘id’.

- While it’s true that Objective-C will generally get an opaque object that it won’t know what to do with, that doesn’t mean that there aren’t cases where this can still be useful, such as:

- Value transformers. Even if a type is completely opaque to Objective-C, that doesn’t mean a value transformer can’t convert it into something that Objective-C can use. There are some quite useful general-purpose value transformers that could be written for this task, such as:

class CustomStringConvertibleValueTransformer: ValueTransformer {
    override class func transformedValueClass() -> AnyClass { return NSString.self }
    override class func allowsReverseTransformation() -> Bool { return false }
    
    override func transformedValue(_ value: Any?) -> Any? {
        return (value as? CustomStringConvertible)?.description
    }
}

With this value transformer, an enum like this one from the Swift manual could be exposed to Objective-C and bound to a UI element in Interface Builder, resulting in a meaningful value being shown in the UI:

enum Suit: CustomStringConvertible {
    case spades, hearts, diamonds, clubs
    var description: String {
        switch self {
        case .spades:
            return "spades"
        case .hearts:
            return "hearts"
        case .diamonds:
            return "diamonds"
        case .clubs:
            return "clubs"
        }
    }
}

Once we have generalized existentials, we could write this value transformer as well, which would be able to handle all Swift enums backed by ObjC-representable types without any special hacks:

class RawRepresentableValueTransformer: ValueTransformer {
    override class func transformedValueClass() -> AnyClass { return AnyObject.self }
    override class func allowsReverseTransformation() -> Bool { return false }
    
    override func transformedValue(_ value: Any?) -> Any? {
        return (value as? RawRepresentable)?.rawValue
    }
}

- KVO dependencies. Even without a value transformer, a property of a non-ObjC-representable type may be a dependency of other properties which *are* ObjC-representable, as in this example:

class PlayingCard: NSObject {
    @objc dynamic var suit: Suit // currently an error
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }

    @objc private static let keyPathsForValuesAffectingSuitName: Set<String> = [#keyPath(suit)]
    @objc var suitName: String { return self.suit.description }
}

Although the ‘suit’ property is not representable in Objective-C, the ‘suitName’ property is, and it would behoove us to allow it to be updated when ‘suit’ changes. Currently, we have to resort to various hacks to accomplish this. One can manually send the KVO notifications on the original property instead of near the dependent ones, which can be error-prone:

class ErrorPronePlayingCard: NSObject {
    var suit: Suit {
        willSet { self.willChangeValue(for: \.suitName) }
        didSet { self.didChangeValue(for: \.suitName) }
    }
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }
    
    @objc var suitName: String { return self.suit.description }
}

Formerly, one could simply use arbitrary strings as key paths, which was ugly since it required a separate override of value(forKey:) to avoid exceptions if someone actually tried to acquire a value using the key. Also, it doesn’t seem to work anymore in Swift 4, since the will/didChangeValue methods now expect a KeyPath object instead of a String:

class DontWorkNoMorePlayingCard: NSObject {
    var suit: Suit {
        willSet { self.willChangeValue(for: "suit") } // error: this requires a KeyPath now
        didSet { self.didChangeValue(for: "suit") }
    }
    
    override func value(forKey key: String) -> Any? {
        switch key {
        case "suit":
            return self.suit
        default:
            return super.value(forKey: key)
        }
    }
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }
    
    @objc private static let keyPathsForValuesAffectingSuitName: Set<String> = ["suit"]
    @objc var suitName: String { return self.suit.description }
}

One can ugly up the class with separate Any-typed properties, which serve no purpose other than to facilitate KVO dependencies:

class UglyPlayingCard: NSObject {
    @objc private var _objCSuit: Any { return self.suit }
    var suit: Suit {
        willSet { self.willChangeValue(for: \._objCSuit) }
        didSet { self.didChangeValue(for: \._objCSuit) }
    }
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }
    
    @objc private static let keyPathsForValuesAffectingSuitName: Set<String> = [#keyPath(_objCSuit)]
    @objc var suitName: String { return self.suit.description }
}

None of these hacks would be necessary if it were possible to simply put @objc on any property declaration.

DETAILED DESIGN:

For read-only properties, implementation is easy; just expose the type to Objective-C as ‘id’, so:

@objc private(set) var suit: Suit

becomes:

@property (nonatomic, readonly) id suit;

For writable properties, there is the danger that an Objective-C client could pass an object of the wrong type to the setter. For this case, we could generate a thunk for the setter that checks the type of incoming values, and simply ignores values of the wrong type:

func setter(_ val: Any) {
    if let suit = val as? Suit {
        self.suit = suit
    }
}

Alternatively, we could fatalError if the wrong type is sent.

func setter(_ val: Any) {
    if let suit = val as? Suit {
        self.suit = suit
    } else {
        fatalError("Passed-in value is not a Suit")
    }
}

For dynamic properties, we would want to reroute all sets through this thunk to ensure that the automatically-added KVO notifications will be fired. This will add a small performance cost due to the dynamic type check, but generally speaking, KVO is not a tool that one uses in performance-critical sections.

One special-case that would be nice to add would be to make AnyKeyPath, PartialKeyPath, KeyPath, and friends bridge to Objective-C as a string, which would allow us to get rid of the one remaining time #keyPath needed to be used in the examples for this pitch, and declare dependencies instead as:

@objc private static let keyPathsForValuesAffectingSuitName: Set<PartialKeyPath<PlayingCard>> = [\.suit]

which would expose itself to the Objective-C type system as:

@property (class, readonly) NSSet<NSString *> *keyPathsForValuesAffectingSuitName;

allowing the KVO system to process it as normal.

IMPACT ON EXISTING CODE:

None; this is purely additive.

ALTERNATIVES CONSIDERED:

Keep on using the hacks described above.

Charles


(Charles Srstka) #2

Self-correction: will/didChangeValue still do accept string key paths; it’s just that in that case they are not respelled and still use “forKey:” as their first argument label. Not sure why that didn’t come up in autocomplete when I was doing my tests the other day. I still think that making @objc broadly available would be a more elegant solution.

···

On Jun 10, 2017, at 11:47 AM, Charles Srstka via swift-evolution <swift-evolution@swift.org> wrote:

INTRODUCTION:

This pitch proposes to allow the @objc keyword on all property declarations, even ones whose type cannot be represented in Objective-C.

MOTIVATION:

You’re thinking, “But that’s crazy. Why would you ever want to do that?” But hear me out:

- In Swift 4, barring cases where it’s required for technical reasons, properties are exposed to Objective-C only if there’s an actual @objc keyword present. This keyword represents a clear statement of intent that this protocol is meant to be visible to Objective-C, and means that expanding the scope of the @objc keyword will not increase code size anywhere other than where a deliberate decision has been made to do so.

- Since Swift 3, all Swift types are in fact bridgeable to Objective-C, and an ‘Any’ is bridged to an ‘id’.

- While it’s true that Objective-C will generally get an opaque object that it won’t know what to do with, that doesn’t mean that there aren’t cases where this can still be useful, such as:

- Value transformers. Even if a type is completely opaque to Objective-C, that doesn’t mean a value transformer can’t convert it into something that Objective-C can use. There are some quite useful general-purpose value transformers that could be written for this task, such as:

class CustomStringConvertibleValueTransformer: ValueTransformer {
    override class func transformedValueClass() -> AnyClass { return NSString.self }
    override class func allowsReverseTransformation() -> Bool { return false }
    
    override func transformedValue(_ value: Any?) -> Any? {
        return (value as? CustomStringConvertible)?.description
    }
}

With this value transformer, an enum like this one from the Swift manual could be exposed to Objective-C and bound to a UI element in Interface Builder, resulting in a meaningful value being shown in the UI:

enum Suit: CustomStringConvertible {
    case spades, hearts, diamonds, clubs
    var description: String {
        switch self {
        case .spades:
            return "spades"
        case .hearts:
            return "hearts"
        case .diamonds:
            return "diamonds"
        case .clubs:
            return "clubs"
        }
    }
}

Once we have generalized existentials, we could write this value transformer as well, which would be able to handle all Swift enums backed by ObjC-representable types without any special hacks:

class RawRepresentableValueTransformer: ValueTransformer {
    override class func transformedValueClass() -> AnyClass { return AnyObject.self }
    override class func allowsReverseTransformation() -> Bool { return false }
    
    override func transformedValue(_ value: Any?) -> Any? {
        return (value as? RawRepresentable)?.rawValue
    }
}

- KVO dependencies. Even without a value transformer, a property of a non-ObjC-representable type may be a dependency of other properties which *are* ObjC-representable, as in this example:

class PlayingCard: NSObject {
    @objc dynamic var suit: Suit // currently an error
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }

    @objc private static let keyPathsForValuesAffectingSuitName: Set<String> = [#keyPath(suit)]
    @objc var suitName: String { return self.suit.description }
}

Although the ‘suit’ property is not representable in Objective-C, the ‘suitName’ property is, and it would behoove us to allow it to be updated when ‘suit’ changes. Currently, we have to resort to various hacks to accomplish this. One can manually send the KVO notifications on the original property instead of near the dependent ones, which can be error-prone:

class ErrorPronePlayingCard: NSObject {
    var suit: Suit {
        willSet { self.willChangeValue(for: \.suitName) }
        didSet { self.didChangeValue(for: \.suitName) }
    }
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }
    
    @objc var suitName: String { return self.suit.description }
}

Formerly, one could simply use arbitrary strings as key paths, which was ugly since it required a separate override of value(forKey:) to avoid exceptions if someone actually tried to acquire a value using the key. Also, it doesn’t seem to work anymore in Swift 4, since the will/didChangeValue methods now expect a KeyPath object instead of a String:

class DontWorkNoMorePlayingCard: NSObject {
    var suit: Suit {
        willSet { self.willChangeValue(for: "suit") } // error: this requires a KeyPath now
        didSet { self.didChangeValue(for: "suit") }
    }
    
    override func value(forKey key: String) -> Any? {
        switch key {
        case "suit":
            return self.suit
        default:
            return super.value(forKey: key)
        }
    }
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }
    
    @objc private static let keyPathsForValuesAffectingSuitName: Set<String> = ["suit"]
    @objc var suitName: String { return self.suit.description }
}

One can ugly up the class with separate Any-typed properties, which serve no purpose other than to facilitate KVO dependencies:

class UglyPlayingCard: NSObject {
    @objc private var _objCSuit: Any { return self.suit }
    var suit: Suit {
        willSet { self.willChangeValue(for: \._objCSuit) }
        didSet { self.didChangeValue(for: \._objCSuit) }
    }
    
    init(suit: Suit) {
        self.suit = suit
        super.init()
    }
    
    @objc private static let keyPathsForValuesAffectingSuitName: Set<String> = [#keyPath(_objCSuit)]
    @objc var suitName: String { return self.suit.description }
}

None of these hacks would be necessary if it were possible to simply put @objc on any property declaration.

DETAILED DESIGN:

For read-only properties, implementation is easy; just expose the type to Objective-C as ‘id’, so:

@objc private(set) var suit: Suit

becomes:

@property (nonatomic, readonly) id suit;

For writable properties, there is the danger that an Objective-C client could pass an object of the wrong type to the setter. For this case, we could generate a thunk for the setter that checks the type of incoming values, and simply ignores values of the wrong type:

func setter(_ val: Any) {
    if let suit = val as? Suit {
        self.suit = suit
    }
}

Alternatively, we could fatalError if the wrong type is sent.

func setter(_ val: Any) {
    if let suit = val as? Suit {
        self.suit = suit
    } else {
        fatalError("Passed-in value is not a Suit")
    }
}

For dynamic properties, we would want to reroute all sets through this thunk to ensure that the automatically-added KVO notifications will be fired. This will add a small performance cost due to the dynamic type check, but generally speaking, KVO is not a tool that one uses in performance-critical sections.

One special-case that would be nice to add would be to make AnyKeyPath, PartialKeyPath, KeyPath, and friends bridge to Objective-C as a string, which would allow us to get rid of the one remaining time #keyPath needed to be used in the examples for this pitch, and declare dependencies instead as:

@objc private static let keyPathsForValuesAffectingSuitName: Set<PartialKeyPath<PlayingCard>> = [\.suit]

which would expose itself to the Objective-C type system as:

@property (class, readonly) NSSet<NSString *> *keyPathsForValuesAffectingSuitName;

allowing the KVO system to process it as normal.

IMPACT ON EXISTING CODE:

None; this is purely additive.

ALTERNATIVES CONSIDERED:

Keep on using the hacks described above.

Charles

_______________________________________________
swift-evolution mailing list
swift-evolution@swift.org
https://lists.swift.org/mailman/listinfo/swift-evolution