Revisiting Importing Swift Types Forward Declared in Objective-C

Hi all,

I am the author of SE-0384, a proposal to allow the Swift compiler to import forward declared Objective-C types that is now on by default with swift language mode 6. In the process, I made a pitch suggesting that the compiler should also be able to resolve forward declarations in Objective-C headers to imported @objc Swift types, but that was never implemented.

My colleagues have been using SE-0384 for a couple years now, and the lack of support for the feature presented in that pitch, and the manner in which the motivating issue was addressed is leading to confusion and odd workarounds. For this reason, I’d like to revisit the pitch with some more information.

Background:

I wrote and implemented SE-0384 with the goal of having the ClangImporter return some relatively usable Swift representation of forward declared Objective-C interfaces and protocols, instead of just failing to import the type and dependent declarations.

The proposal was to have the ClangImporter return unavailable placeholder types inheriting from NSObject. You cannot use the type itself, you can only pass instances around and send NSObject recognized messages to it.

// @class Foo turns into
@available(*, unavailable, message: “This Objective-C class has only been forward declared; import its owning module to use it”)
class Foo : NSObject {}

// @protocol Bar turns into
@available(*, unavailable, message: “This Objective-C protocol has only been forward declared; import its owning module to use it”)
protocol Bar : NSObjectProtocol {}

At some point, I stumbled upon the edge case of a Swift client attempting to use an Objective-C API which forward declares a Swift type Foo, and also importing the Swift definition at the same time. Consider this example:

Swift module Foo:

@objc public class Foo {}

Objective-C interface forward declaring Foo:

@class Foo; // Forward declared Swift type
void takeAFoo(Foo* foo);

Swift client importing both:

import Foo
import FooObjCWrapper

let myNativeSwiftFoo = Foo()
takeAFoo(myNativeSwiftFoo)

Without any extra changes, the compiler won’t allow you to pass the “native” (generated calling Swift directly) instance of Foo to takeAFoo, because takeAFoo has been synthesized with the placeholder type not the full definition.

This was the motivation for the pitch, but it was never implemented for reasons I’ll address later. Instead it was decided to avoid this edge case by refusing to import a forward declaration for which a matching Swift definition can be found.

I think maybe there was some miscommunication here, and I perhaps didn’t implement the exact semantics the language group wanted, preventing use of forward declared Swift types (if they can be identified as such), instead of preventing forward declaration of Swift types in the first place.

The main reason this solution is confusing is that the Swift modules visible to the compiler while building a given module change over time as users add more import statements. Adding new imports can then expose the compiler to a new Swift definition, and cause it to refuse to import a forward declaration it imported previously. Recently users have also started importing Swift modules through their generated Objective-C header rather than directly to avoid this check.

I'd like to revist if not the pitch all together, at least the handling of this case.

Pitch Objections

When I made the pitch, the objections were around this change allowing for cyclic dependencies between Swift types across modules, and IDE features. I’d like to re-open this discussion.

Cyclic Dependencies

Consider the following example of a Book class and an Author class that mutually depend on one another, defined in a single module:

import Foundation

@objc public class Author : NSObject {
    public var name: String
    public var books: [Book] = []

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

    public func addBook(_ bookName: String) {
        books.append(Book(title: bookName))
    }
}

@objc public class Book : NSObject {
    public var title: String
    public var author: Author?
    @objc public init(title: String, author: Author? = nil) {
        self.title = title
        self.author = author
    }
}

The claim is that the change I’m pitching would allow this type structure with each class in a separate module, by using an intermediary Objective-C header and forward declaration, but I don’t think that is true.

Let us create an intermediary Clang module BookOpaqueInterface to opaquely represent Book, and attempt to split Author and Book classes into separate modules.

BookOpaqueInterface.h

#import <Foundation/Foundation.h>

@class Book;
Book* createABook(NSString* name);

BookOpaqueInterface.mm

#import "BookOpaqueInterface.h"
#import "Book-Swift.h"

Book* createABook(NSString* name) {
    return [[Book alloc] initWithTitle: name];
}

Author.swift:

import Foundation

import BookOpaqueInterface

@objc public class Author : NSObject {
    public var name: String
    public var books: [Book] = []

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

    public func addBook(_ bookName: String) {
        books.append(Book(title: bookName))
    }
}

Book.swift

import Foundation

import Author

@objc public class Book : NSObject {
    public var title: String
    public var author: Author?

    @objc public init(title: String, author: Author? = nil) {
        self.title = title
        self.author = author
    }
}

This code currently won’t compile, and that would not change under my pitch. The representation of @class Book creates an unavailable type that cannot be used directly:

/Users/nuriamari/Git/swift-project/build/Ninja-RelWithDebInfoAssert/swift-macosx-arm64/test-macosx-arm64/ClangImporter/examples/example2/Output/Library.swift.tmp/Author.swift:7:24: error: 'Book' is unavailable: This Objective-C class has only been forward-declared; import its owning module to use it
5 | @objc public class Author : NSObject {
6 | public var name: String
7 | public var books: [Book] = []
| `- error: 'Book' is unavailable: This Objective-C class has only been forward-declared; import its owning module to use it
8 | public init(name: String) {
9 | self.name = name

BookOpaqueInterface.Book:2:14: note: 'Book' has been explicitly marked unavailable here
1 | @available(*, unavailable, message: "This Objective-C class has only been forward-declared; import its owning module to use it")
2 | public class Book {
| `- note: 'Book' has been explicitly marked unavailable here
3 | }

/Users/nuriamari/Git/swift-project/build/Ninja-RelWithDebInfoAssert/swift-macosx-arm64/test-macosx-arm64/ClangImporter/examples/example2/Output/Library.swift.tmp/Author.swift:12:22: error: 'Book' cannot be constructed because it has no accessible initializers
10 | }
11 | public func addBook(_ bookName: String) {
12 | books.append(Book(title: bookName))
| `- error: 'Book' cannot be constructed because it has no accessible initializers
13 | }
14 | }

Now can we pass around Book instances, and call NSObject provided methods on it? Yes. But we cannot depend upon any of the specifics of the Book class. If we modify the Author class as follows, it compiles:

Author.swift:

import Foundation
import BookOpaqueInterface

@objc public class Author : NSObject {
    public var name: String
    public var books: [NSObject] = []

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

    public func addBook(_ bookName: String) {
         books.append(createABook(bookName))
    }
}

But we didn’t even need to use forward declarations at all to achieve this. We could just as easily write the bridging module like this, using our own opaque type representation of Book instead of using the compiler synthesized representation of the forward declaration:

BookOpaqueInterface.h

typedef NSObject *OpaqueBook;
OpaqueBook createABook(NSString* name);

BookOpaqueInterface.m

#import "BookOpaqueInterface.h"
#import "Book-Swift.h"

OpaqueBook createABook(NSString* name);
    return [[Book new]];
}

In this way, I don’t think my proposal allows for any kind of cyclic inter-module dependency between types. The pitch just suggests that if a module has access to an Objective-C API with a forward declaration @class Foo and a complete matching Swift definition, that the compiler be able to realize the two refer to the same type. In order to have access to the complete available Swift definition, there can’t be a cyclic dependency between modules (you need to compile depended upon swift modules first).

At the moment, we realize the two are the same type, then refuse to allow the user to interact with the forward declaration, in my opinion, to no benefit.

I have opened a draft PR with compiler tests that provide all these examples I’m referring to and demonstrate the behavior: Pitch examples by NuriAmari · Pull Request #83241 · swiftlang/swift · GitHub

Build Speed / Definition Metadata

As for the second point, I want to clarify that our motivation for using forward declarations as a build speed optimization is not to avoid unnecessary time spent parsing headers. It is to make your build more resilient to incremental changes, not rebuilding more of your build graph than necessary. Given an API that forward declares a type, the build system need not recompile modules that depend upon the API if the internals of the forward declared type change. After all, clients of the API do not depend on specifics of the type, only that it exists. If you instead include a complete definition in your header, you get unnecessary invalidation of your build graph. This kind of invalidation is a big deal for incremental builds of large apps.

I can understand the point about a forward declaration in a header lacking information that a complete definition would provide, but I’m not sure the implementation work needed to teach IDE tooling to handle this is a reason not to pursue this change long term. Primarily because I think IDE tooling already needs a solution for this problem. Between @objcImplementation, pure Objective-C types being forward declared, and Swift types permissibly forward declared in mixed modules, we need a solution for this regardless.

Conclusion

I’m hoping this has added some new information, and we can re-open the discussion regarding the pitch. If I’ve missed something, please let me know.

Thanks,

Nuri

cc @allevato @Douglas_Gregor

3 Likes

Couldn’t you use an Objective-C header file and an @objc @implementation extension Swift implementation to handle this use case?

Sure but at that point it is easier to just import the generated bridinging header (-Swift.h) for your Swift module in the client Swift module instead of importing the Swift module directly and avoid re-writing your code. It seems silly that we need to use these intermediary Objective-C headers (of whatever kind) to hide the fact that a forward declaration refers to a Swift type from the compiler.