Pitch: Resolve Objective-C Forward Declarations to @objc Swift Definitions

Hi all,

In a follow up to SE-0384, we would like to propose another improvement to Swift - Objective-C interop. Specifically, we would like to improve upon the ClangImporter's ability to tie an @class Foo or @protocol Bar declaration in imported Objective-C, to a Swift definition @objc class Foo {} or @objc protocol Bar {} defined in Swift.

Problem:

First a brief illustration of the problem we wish to solve:

Consider Swift module Foo with following source:

@objc public class Foo {}

An Objective-C Clang module FooObjCWrapper with source:

@class Foo;

void takeAFoo(Foo* foo);
Foo* giveAFoo();

And a Swift client using both these modules like so:

import Foo
import FooObjCWrapper

let myStraightFromSwiftFoo = Foo() // This works!
_ = giveAFoo() // This doesn't!

test.swift:5:5: error: cannot find 'giveAFoo' in scope
_ = giveAFoo()
    ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
FooObjCWrapper.h:3:1: note: function 'giveAFoo' not imported
Foo* giveAFoo();
^
FooObjCWrapper.h:3:1: note: return type not imported
Foo* giveAFoo();
^
FooObjCWrapper.h:3:1: note: interface 'Foo' is incomplete
Foo* giveAFoo();
^
FooObjCWrapper.h:3:1: note: interface 'Foo' forward declared here
@class Foo;
^

Even though we have imported the definition of Foo, it is not visible from in the ClangAST, so giveAFoo and takeAFoo are dropped by the ClangImporter. If Foo was defined in an imported Clang module instead of a Swift module, this would work.

Motivation:

We briefly enumerate the motivating factors below:

  1. Make Objective-C APIs wrapping Swift usable from Swift without unnecessarily bloating headers

The only solution to use this API from Swift is to #import the generated -Swift.h header for the Swift module Foo in the FooObjCWrapper's headers. This unnecessarily bloats headers, making both Swift and Objective-C clients parse more content, to only the benefit of Swift clients. As with all header includes, it makes the build graph more connected and harder to parallelize. As Swift adoption grows, while attempting to reuse existing Objective-C, these kinds of Swift -> Objective-C -> Swift cases become more and more common.

  1. Consistency with Clang modules

As mentioned above, if the definition were in an imported Clang module, not Swift module, this already works. This can be quite confusing. Users are told that if forward declared types are causing them problems to import the defining module. When this only works for Clang modules, it complicates things and increases the minimum understanding of interop implementation users need to operate effectively.

  1. Required for SE-0384 implementation

Unfortunately, I did not know it at the time, but this is required for SE-0384 to land in a reasonable fashion. If SE-0384 were implemented without this, Swift would see two conflicting interpretations of the type Foo. One the complete definition directly from the defining Swift module, the other an opaque placeholder synthesized to represent the @class Foo whose definition "we can't see".

Precedent:

We already do this kind of @class Foo -> @objc class Foo {} resolution to an extent to support mixed modules: swift/ImportDecl.cpp at main · apple/swift · GitHub . In implementing this change, we would be expanding the search from an overlay module, or owners of the bridging header to all imported Swift modules. A draft implementation can be seen here: Link imported ObjC forward declarations to their Swift definition by NuriAmari · Pull Request #63398 · apple/swift · GitHub .

Source Compatibility:

The usual, we are importing new declarations, possibly introducing name conflict based source compatibility breakage with ClangImporter changes applies.

Risks / Factors to Consider:

We would like to know of any edge cases or risks we are not thinking of. One hurdle to be called out is the potential for multiple Swift declarations visible from the main module with the same @objc provided name. It is my impression that writing this kind of code already introduced "undefined behavior" but could use confirmation.

We appreciate any input and feedback. Thanks!

CC: @allevato, @beccadax

8 Likes

I've run into this a number of times with my own projects, seems like a relatively straightforward addition to SE-0384. Thanks for working on this.

We discussed this a bit in the language workgroup and mentioned it in some offline conversations, but I wanted to make sure our thoughts/concerns made it into this thread as well.

There are two main reasons that folks forward declare a class/protocol:

  1. To break a cyclic dependency between two types.
  2. To improve build performance by not requiring the compiler to parse headers when the type's name provides enough information for a particular usage.

Regarding #1, Swift by design does not permit circular dependencies between Swift types in different modules; for two types to depend on each other, they must be in the same module. If we resolve forward declarations of the form @class/@protocol to matching Objective-C-compatible Swift types, then this opens a "back door" to get around that limitation—a user could use an intermediary Objective-C module to create a cyclic relationship between two Swift types. This could make it harder to reason about those types when compiling Swift code that uses them.

For #2, the generated header is again a compiler artifact by design, and it contains various metadata about the declaration beyond just what a forward-declaration contains. For example, by marking the generated @interface with information about its originating module, IDE actions like "jump-to-definition" can find the original type definition in Swift instead of just pointing to a forward declaration that's otherwise divorced from all context. The implementation work needed to get around this limitation, and potentially other related issues, are unnecessary if we just tell users of classes implemented in Swift to "just import the generated header".

My general feeling is that if someone does want to have a class that's implemented in Swift but is indistinguishable from an Objective-C class, including full header control and the ability to forward declare it, then other recently-pitched features like @objcImplementation are likely to be a better fit.

6 Likes

For issue #1, wouldn't a full import be required in order to do anything with the forward declared type except pass through a class with a matching name, and thus these Swift modules aren't depending on each other but rather a string name of a class? We can very much already introduce implicitly circular dependencies with stringly typing, this is just another case of it. We still don't create an actual build dependency between targets.

Hey all, I am bumping this thread as I think that I am facing the problem which should have been fixed as part of this proposal but for a custom framework: No member in framework target: Swift class as forward class in ObjC, accessed in Swift through ObjC class.

Let me know if I am missing anything over here.

This proposal was never implemented for the reasons explained by @ allevato above. The entire feature isn't on by default though, if you want to try it, you need to pass -enable-upcoming-feature ImportObjcForwardDeclarations, but again it intentionally does not solve your use case.

@NuriAmari I see. Is there any work around through which I can solve my use case?

Can't you just move #import <UtilitiesFramework/UtilitiesFramework-Swift.h> to Utilities.h instead of Utilities.m. Why do you need to forward declare the Swift type. I don't think you are supposed to be forward declaring Swift types if you can help it.

@NuriAmari as per Importing Swift into Objective-C | Apple Developer Documentation :

When declarations in an Objective-C header file refer to a Swift class or protocol that comes from the same target, importing the generated header creates a cyclical reference. To avoid this, use a forward declaration of the Swift class or protocol to reference it in an Objective-C interface.

Am I missing anything over here? Please suggest.