Uninitialised nonnull properties of Foundation types are set to sensible defaults in Swift

array
bridging
objective-c
swift4
string

(Eliezer Talón) #1

Hello everyone,

I was writing a unit test in Swift for a class written in Objective-C when I came across the following behaviour: nonnull properties of some Foundation types (e.g. NSString or NSArray) declared in a Objective-C class are automatically set to a default value (e.g. an empty string or an empty array) when they're bridged to Swift.

Here's a code sample demonstrating this behaviour. Given an Objective-C class:

@interface Team : NSObject

@property (nonatomic, copy, nonnull) NSString *name;

@property (nonatomic, copy, nonnull) NSArray<NSString *> *codes;

@end


@implementation Team

// Note there is no explicit initialisation here

@end

and a unit test written in Swift:

final class TeamTests: XCTestCase {

    func testExample() {
        let team = Team()

        XCTAssertNil(team.name) // fails, name is an empty string
        XCTAssertNil(team.codes) // fails, codes is en empty array

        XCTAssert(team.name.isEmpty)
        XCTAssert(team.codes.isEmpty)
    }

}

the assertions checking for nil always fail. That shouldn’t be surprising, as the types become String (not String?) and Array<String> (not Array<String>?) in Swift. The same assertions written in an Objective-C test succeed:

@interface TeamTests : XCTestCase

@end

@implementation TeamTests

- (void)testExample {
    Team *team = [[Team alloc] init];

    XCTAssertNil(team.name); // succeeds
    XCTAssertNil(team.codes); // succeeds
}

@end

Admittedly this is a programmer error. Without initialising name and codes to proper values we are indeed breaking the contract established by nonnull in the public interface.

And I would argue that this implicit initialisation makes sense. Without setting these properties to any value, it is not possible to implement the equivalent generated interface:

open class Team : NSObject {
    
    open var name: String    
    open var codes: [String]

}

An empty string and an empty array are perfect candidates for a sensible default value when bridging.

Is this expected and/or intended behaviour or rather a detail of the bridging implementation. Moreover, is it documented anywhere?

Thank you!

PS: Whether the compiler should emit a warning when using XCTAssertNil with a non optional type is another discussion.


(Marco Masser) #2

I have no inside knowledge on this, but I would guess that this is an effect of the bridging behavior that is probably not actually intentional. Apart from trapping at runtime, there’s not much that could be done, though.

Note that this mapping to empty strings and arrays can only happen when the Objective-C representation is a class and the Swift representation is not a class.

I would argue very strongly against this statement. It is highly dependent on the situation whether empty arrays or strings are acceptable values, much less “sensible” values.


I came across this very behavior a few weeks ago while debugging some code that behaved in unexpected ways. Essentially, it was a Swift method that expected a non-optional String that was called from Objective-C and passed nil where it shouldn’t have been.

(Fun fact: the very first thing that method does is verify that the input is not an empty string :wink:)

Just like this:

@objcMembers class Greeter: NSObject {
    class func sayHello(to name: String) { … }
}

… and called from Objective-C like this:

NSString *name = nil;
[Greeter sayHelloTo:name]; // No warning here.

The surprising thing here was that the Swift method receives an empty String. If that had trapped with some assertion or something like that, it would have been much easier to debug.


(Eliezer Talón) #3

Yes, you are right. I meant they are perfect candidates in the context where such initialisation is the intended behaviour and the implementation must set a value. But I would rather have some kind of trap or compiler diagnostic as well.

I'm curious now about something in your code sample. When you pass nil to -sayHelloTo:, does the compiler emit a warning if you set CLANG_ANALYZER_NONNULL=YES?


(Marco Masser) #4

It’s set in Xcode to Yes (Aggressive) and I get no warning at all. It looks like you only get warnings if you pass the nil literal directly, but if there’s an indirection via a variable, clang does not seem to catch it.


(Joe Groff) #5

This is in fact intentional, both as a guard against incorrectly-annotated Objective-C APIs, and as backward compatibility support for Objective-C APIs that historically returned nil instead of empty strings/arrays but don't make a semantic distinction between those as return values. Note that this behavior is specific to bridged types that have a reasonable empty state; bridged types that don't have empty states will trap at runtime if the ObjC API tries to smuggle a null result through to Swift.


(Marco Masser) #6

Ah, that makes sense. Thanks for the clarification.

If anyone reading this is interested in actual code, here’s String._unconditionallyBridgeFromObjectiveC(_:) that maps nil to String() and here’s Locale. _unconditionallyBridgeFromObjectiveC(_:) that just traps on nil.