Swift @implementationOnly not working correctly with private members

I have a swift class which is based off of a C library, which I am using a modulemap to link to. However, I don't want the C module exported with my library, so I am trying to use @_implementationOnly. However, it's failing when I try to use C structs from the library even when they're not part of the interface. I've got a simple class Capsule, which simply handles cleanup when it is destructed, like so:

class Capsule<Type> {

    var enclosed: Type

    private var cleanupHandler: (inout Type) -> Void

    init(_ object: Type, onDestruct cleanup: @escaping (inout Type) -> Void) {

        enclosed = object

        cleanupHandler = cleanup

    }

    

    deinit {

        cleanupHandler(&enclosed)

    }

}

Then I use this in a class with a private member:

private func cleanupContext(context: inout rcl_context_t) {
// Cleanup logic here...
}

public class Context {
    private var CContext = Capsule<rcl_context_t>(rcl_get_zero_initialized_context(), onDestruct: cleanupContext)

// Rest of the class...
}

rcl_context_t is a C struct imported from the C library marked as implementationOnly. The compiler throws the following error:

error: cannot use struct 'rcl_context_t' here; 'RclC' has been imported as implementation-only
    private var CContext = Capsule<rcl_context_t>(rcl_get_zero_initialized_context(), onDestruct: cleanupContext)

Am I misunderstanding this or is this a bug? rcl_context_t isn't accessible as part of the interface to my class, so it shouldn't be violating the implementationOnly constraint

Please use the "Using Swift" category for asking questions related to using Swift in practice. The Development Compiler category is for topics related to the implementation of the toolchain itself.

As for this particular case, I suspect it is not a bug based on one of the test cases we have in the compiler's test suite:

public struct PublicStructStoredProperties {
  public var publiclyBad: BadStruct? // expected-error {{cannot use struct 'BadStruct' here; 'BADLibrary' has been imported as implementation-only}}
  internal var internallyBad: BadStruct? // expected-error {{cannot use struct 'BadStruct' here; 'BADLibrary' has been imported as implementation-only}}
  private var privatelyBad: BadStruct? // expected-error {{cannot use struct 'BadStruct' here; 'BADLibrary' has been imported as implementation-only}}
  private let letIsLikeVar = [BadStruct]() // expected-error {{cannot use struct 'BadStruct' here; 'BADLibrary' has been imported as implementation-only}}

  private var computedIsOkay: BadStruct? { return nil } // okay
  private static var staticIsOkay: BadStruct? // okay
  @usableFromInline internal var computedUFIIsNot: BadStruct? { return nil } // expected-error {{cannot use struct 'BadStruct' here; 'BADLibrary' has been imported as implementation-only}}
}

Based on that, it looks like stored properties are not permitted to use types from implementation-only imports. That said, I don't know why that is the case though. It does seem a bit strange.

This is one of the reasons @_implementationOnly hasn't been finalized yet. :-/ If you build without library evolution enabled, the layout of classes and structs is statically available to the compiler in order to optimize clients. For that reason, the types of all members must be made available to clients even if they're not public.

Now, in this particular case, the private member CContext is a reference to a class, Capsule, and its generic arguments can't possibly affect the layout of the reference that gets stored inside the Context type. The compiler could be taught to understand that—"this non-ABI-public type can safely be downgraded to AnyObject (or similar)"—though it raises questions about how implementation-only imports will play with cross-module optimization.

The simplest possible answer in the meantime, however, is to manually erase the type in the stored property:

public class Contxet {
  private var CContextStorage: AnyObject = Capsule<rcl_context_t>(rcl_get_zero_initialized_context(), onDestruct: cleanupContext)
  private var CContext: Capsule<rcl_context_t> {
    get { self.CContextStorage as! Capsule<rcl_context_t> }
    set { self.CContextStorage = newValue }
  }
}
5 Likes

Sorry if this is the wrong category, I assumed this was unintended behavior and thus not necessarily a usage question. @jrose Thanks for the explanation and example code. Do you have any ideas if or when implementationOnly will make it out of experimental stage? IMO it’s a pretty essential feature. How are developers working with proprietary, binary only Swift frameworks supposed to hide internal dependencies? My use case is more about minimizing dependencies for the end user rather than closed source but I see this as the most prominent example of such a need.

Related context: [SR-11910] @_implementationOnly types not allowed as private properties on public types · Issue #54328 · apple/swift · GitHub

Binary-only Swift frameworks are expected to use library evolution mode, not because they'll be replaced at runtime like OS-level frameworks, but because there might be other binary-only frameworks that depend on them. (We touched on this briefly when I was still at Apple near the end of the Binary Frameworks in Swift talk at WWDC 2019.) Minimizing dependencies for the end user isn't considered interesting for source packages because you still have to build the source package itself, so this mostly applies to (1) copying around build products for source packages to save on build times, and (2) future improvements to SwiftPM and such wherein package libraries that weren't explicitly exported wouldn't be importable by clients (or something like that). I've also personally run into (3) "my C dependency needs certain macros predefined, and it stinks forcing my clients to define them too". All of these will be great to have, but I don't think they're blocking anything today.

EDIT: the "future improvements to SwiftPM" I poorly described here are mostly "fix the issues mentioned by @mmfl in Swift packages and module dependencies". Definitely worth doing, but also examples of things that work when they shouldn't, which is not quite as bad that things that should work but don't.

1 Like

Okay, I've done a little research and it looks like library evolution might be a good fit for me. In my case I am building a swift client for ROS (Robot operating system) which has its own CMake-based build system, and my swift client will have 30+ C dependencies. This pretty much rules out SPM's source-based workflow, so I'm using the swift support in CMake to build my libraries. I want to allow users to be able to build from source if they like, but in most cases just having a static swift binary with everything rolled in would be more convenient (eliminating ROS build system for end user). As such I really want to hide the implementation imports and it seems like module evolution will be a good way to accomplish that. Thanks for the pointers!

For what it's worth, I have found that you can "fool" the compiler by creating a simple property wrapper:

@propertyWrapper
public struct TypeErased<Value: Any> {
  var value: Value

  public init(wrappedValue value: Value) {
    self.value = value
  }

  public var wrappedValue: Value {
    get { value }
    set { value = newValue }
  }
}

and then in your public class which has a private property which is imported as implementation only:

@_implementationOnly import MyLibraryContainingTypeX

public MyClass {

  @TypeErased
  var myObj: TypeX = TypeX()
}

Probably not what the Swift devs are expecting or looking for, so apt to break in the future. But, it does work :slight_smile:

1 Like