Extension to the canImport statement

Currently, the canImport statement in swift seems to only check if a module can be imported. But apparently it's not enough to know if something in a module can be imported.

For example, I've been playing around with compiling some projects requiring the arc4random_uniform function from glibc.

The code in the project would simply check:

#if os(Linux)
import SwiftGlibc
func arc4random_uniform(...) ... {
 ...
}
#endif

This code will work on certain systems but it appears that the arc4 functions have been added to glibc since version 2.36.

In my case, I have the glibc version 2.39 on my system and the above code will fail because we're redefining a function to one that already exists in SwiftGlibc.

Obviously, checking for os(Linux) isn't enough to know if something you need exists or not. I think it's great that it's so easy to create a shim for something in swift but it's unfortunate that there doesn't seem to be good ways to determine if you need the shim or not.

For that reason, I was thinking that it would make sense to extend the canImport statement to match the import syntax.

This way, we could check if a specific symbol can be imported at all.

For example, the code above could be rewritten has:

#if !canImport(func SwiftGlibc.arc4random_uniform)
   ... define a shim for that specific symbol.
#endif

It would also make sense to be able to check that not only the symbol exists but also match a specific type.

#if !canImport(func SwiftGlibc.arc4random_uniform(UInt32) -> UInt32)
#endif

The canImport can be a much more powerful tool to check if a feature you need really exists and not just if a module exists.

5 Likes

I remember a couple of past pitches that dealt with this same topic:

This is not solving quite the same problem, but it's related:

I think that augmenting canImport with this capability makes some sense. Specifying the module that the declaration is expected in sidesteps one of the main problems with other approaches, which was that allowing the queries to match declarations in the module being compiled would create a cycle between parsing and type checking in the compiler. Since canImport only operates on dependencies, by definition that cycle isn't an issue.

2 Likes

Implementation-wise, this would have to be a runtime check, right? Because the same binary might be run on different systems with different Glibc versions (in the above example). So the compiler would have to insert a trampoline function which sorta pthread_once-initialises its target to either the stdlib version or a compiled-in version.

I'm not saying that's a problem (nor that it isn't :man_shrugging:), I'm just trying to get a better picture in my head of what this would be doing.

If it would work that way, it sounds like it could share a lot of its implementation with the @backDeployed functionality.

2 Likes

The #if canImport() syntax would be a compile time check that would cause some code to be included when compiled because the dependencies/SDK you're building against contain a declaration. You might also need an additional syntax for a runtime check that returns true if the a given weakly linked symbol was resolved successfully by the loader at runtime. These two checks necessarily need to be separate: you can't reference the symbol name at all at compile time unless there's a declaration for it. You may or may not need to deal with runtime availability depending on the details of the deployment of your binary.

3 Likes

Hmmm… that seems incompatible - or at least plays poorly with - dynamic linking, then. I get what you're saying technically, but I'm suggesting that it'd be even better if the solution to this inherently provided a way to just make things work (as opposed to crashing with linker errors at launch).

That's why I brought up @backDeployed, as something that almost does this today. But - IIUC - that can only be used inside the module in question, whereas what's needed here is a way to essentially provide the back-deployed version from a different module.

It's not clear to me how a single syntax would solve both problems simultaneously. But I also think they are separate problems because you can encounter them separately. In some environments, a declaration isn't available in all the configurations you build in, but when it's present at compile time it's always available at runtime. On the other hand, sometimes a declaration is always present in all build configurations but isn't present in all runtimes. By having separate features that can be composed together you can solve either problem or both problems together. Here's an example of solving both problems together (leaning on several hypothetical features):

// Foo
#if SOME_CONDITION
@weakLinked public func bar() -> Int
#endif

// Client of Foo
import Foo

func safeBar() -> Int? {
#if canImport(Foo, func: bar())
    if #hasSymbol(bar()) {
        return bar()
    }
#endif
    return nil
}

You could imagine that the @weakLinked annotation causes the compiler to require that you use if #hasSymbol(bar()) before referencing bar() to provide compile time checked safety of using a dynamically linked library.

3 Likes

Right, I don't know that a @backDeployed-like model can work. The place where I run into this every year is when Apple releases a new iOS SDK, and we want to start exploring its use using the beta SDK. So we start writing code like this:

if #available(iOS 76, *) {
  let unicorn = WidgetKit.unicornWidget()
  // Use the unicorn here.
}
else {
  // Do the old thing
}

This correctly checks whether the API is available at runtime, but the problem is that this code fails to compile entirely at compile-time if we're not using the beta SDK. But of course, we want to continue to be able to release normal updates to our app at this time as well, so we can't require the use of the Beta SDK internally—we still need to be able to release with the stable SDK, which does not have these symbols.

We really do want a compile-time check. There's no reasonable way to back-deploy this API usage, unless WidgetKit.unicornWidget() and all its dependencies are back-deployable (which is unlikely). The entire module would have to be back-deployed.

We usually work around it by using #canImport. But canImport can't directly check for the availability of particular APIs, so instead we hope that there's some unrelated module that was introduced in the same SDK:

#if canImport(BananaTeleportation) { /* unrelated module introduced in iOS 76 */ 
  if #available(iOS 76, *) {
    let unicorn = WidgetKit.unicornWidget()
    // Use the new API here
  }
  else {
    // Do the old thing
  }
}
else {
  // Do the old thing
}

This allows us to build with both the stable and beta SDKs during the beta period. But it's unfortunate that we need to rely on the coincidence of another module being introduced at the same time. It would be great if we could be clearer about what we're actually wanting to import with the canImport statement.

3 Likes

If we could import a particular entry point:

import SwiftGlibc.arc4random_uniform

resulting only that one thing from SwiftGlibc being imported — then canImport(SwiftGlibc.arc4random_uniform) would make sense.


Alternative feature could be:

import SwiftGlibc // business as usual

#if whatsInsideCompilesOK // bike-shed name
func arc4random_uniform_wrapper() -> Int {
    arc4random_uniform()
}
#else
func arc4random_uniform_wrapper() -> Int {
    // a workaround here
}
#endif

where bike-shed named whatsInsideCompilesOK is a special marker: if what's inside the #if "then" branch compiles fine – use that, otherwise compile the #else branch.


Furthermore, whatsInsideCompilesOK could make canImport redundant:

#if whatsInsideCompilesOK // bike-shed name
import SwiftGlibc
#else
import Foundation
#endif

Hmm, maybe that deserves to be a new #try feature?

#try
import SwiftGlibc
#else
import Foundation
#endif
...
#try
func arc4random_uniform_wrapper() -> Int {
    arc4random_uniform()
}
#else
func arc4random_uniform_wrapper() -> Int {
    // a workaround here
}
#endif
4 Likes

The problem is hardly that you can't import the module. The problem is that if you do import the module you're pulling all the symbols in your scope and you'll end up with ambiguous symbols.

In swift it can be resolved by explicitly importing each specific symbols using this syntax.

import func SwiftGlibc.arc4random_uniform

But here's the problem. The syntax for canImport isn't consistent with its own import syntax.

For example here's a repl example:

  1> #if canImport(SwiftGlibc.arc4random_uniform) 
  2. print("Can import SwiftGlibc.arc4random_uniform") 
  3. #else 
  4. print("Can't import SwiftGlibc.arc4random_uniform") 
  5. #endif
Can't import SwiftGlibc.arc4random_uniform
  6> import func SwiftGlibc.arc4random_uniform
  7> SwiftGlibc.arc4random_uniform
$R0: (__uint32_t) -> __uint32_t =  

The code above does compile but the check is just wrong. Then if you try to use the same syntax for the import that I used above. You'll be met with this friendly error message:

  8> #if canImport(func SwiftGlibc.arc4random_uniform) 
  9. print("can import SwiftGlibc.arc4random_uniform") 
 10. #else 
 11. print("can't import SwiftGlibc.arc4random_uniform") 
 12. #endif
error: error while processing module import: error: repl.swift:9:15: error: expected expression in list of expressions
#if canImport(func SwiftGlibc.arc4random_uniform)
              ^

error: repl.swift:9:5: error: expected argument to platform condition
#if canImport(func SwiftGlibc.arc4random_uniform)
    ^

error: repl.swift:9:15: error: extra tokens following conditional compilation directive
#if canImport(func SwiftGlibc.arc4random_uniform)
              ^

error: repl.swift:9:30: error: expected '(' in argument list of function declaration
#if canImport(func SwiftGlibc.arc4random_uniform)
                             ^

error: repl.swift:9:30: error: consecutive statements on a line must be separated by ';'
#if canImport(func SwiftGlibc.arc4random_uniform)
                             ^
                             ;

error: repl.swift:9:49: error: consecutive statements on a line must be separated by ';'
#if canImport(func SwiftGlibc.arc4random_uniform)
                                                ^
                                                ;

error: repl.swift:9:49: error: expected expression
#if canImport(func SwiftGlibc.arc4random_uniform)
                                                ^

My main issue is that if canImport was consistent with the actual import statement syntax. It would solve my problem and would feel natural for this specific use case.

The #try might be able to fix the problem as long as you can trigger a compilation error within the block. In this case, it would be require adding something that will call the imported function because importing alone isn't really a problem. But it also begs the question, why even import things you don't need and accidentally create ambiguous functions because you're importing everything.

You could have something like this:

#if canImport(func SwiftGlibc.arc4random_uniform)
  import func SwiftGlibc.arc4random_uniform
#elseif canImport(func Swoosh.arc4random_uniform)
  import func Swoosh.arc4random_uniform
#else
  func arc4random_uniform(UInt) -> UInt {
  }
#endif

Where you could chain multiple packages that could have the wanted function.

4 Likes

I see. I didn't know about import(func Foo.bar) syntax.
I wonder why the "func" prefix... could there be the same named "bar" so this whole fragment could compile?

import(func Foo.bar)
import(struct Foo.bar)
import(enum Foo.bar)
import(class Foo.bar)

If that's not possible and "Foo.bar" must be unique then the prefix "func", etc seems to be redundant.

This is not bad, although it's not exactly DRY... The most typical thing to do after "canImport thing" is ... well... import that thing, no?

Maybe this?

let arc4random_uniform = 
    #import(SwiftGlibc.arc4random_uniform) ?? 
    #import canImport(Swoosh.arc4random_uniform) ?? 
    fallback

func fallback() -> Int { ... }

// subsequent usage as usual:
arc4random_uniform()

I mean sure it's not pretty. It might not be DRY, but it's KISS enough to support more than one use case including the one of @bjhomer.

1 Like

It's not really wrong. canImport only considers module names, so it's interpreting SwiftGlibc.arc4random_uniform as the submodule named arc4random_uniform of the module SwiftGlibc, just as if you had written import SwiftGlibc.arc4random_uniform. It's telling you (correctly) that no such submodule exists.

Submodules aren't very frequently encountered in Swift because there's not yet a way to define submodules for pure Swift code, but they're a little bit more common in C, at least on Apple platforms. For example, you might see import Darwin.C specifically when users only want libc functions and not everything else that's found in the Darwin module.

5 Likes

Some history: The specific-item import syntax was added by me very early in Swift’s development as a way to disambiguate when two modules provide the same name, giving priority to one over the other. The “kind” keyword that goes before the value is to distinguish from submodules, as allevato pointed out. Given the syntax, I kind of made it work as a way to say “only introduce this name into the top-level scope of this file”, but it still pulls in extensions (see [Pitch] Fixing member import visibility ), so it isn’t really a clean way to say “I’m just using this one thing”. It wasn’t ever rigorously designed, and I wish I had limited it to only working as disambiguation so we could have had a proper review and implementation effort later. Oh well.

Why does the kind matter, though? Mostly cause I had to put something there, and this seemed like a reasonable choice at the time. And so it does, in fact, filter down a choice between a top-level type and a top-level function with the same base name, and only import the one you said. But whether that’s actually a good way to express it… :person_shrugging:

10 Likes

Thank you!

I assume these are also allowed:

import typealias Foo.y
import let Foo.x

FWIW, this is how Modula-2 would do did it:

from Foo import x, y, z

Basically a slightly different order and trading a specific kind keyword for a neutral "from".

Ah that make sense. I wasn't aware those existed. Also the [type] to distinguish between something to import and submodules make sense given the current syntax.

My natural instinct was to test canImport(func Mod.funk) but when it failed was to test without it which compiled but gave the wrong answer... while being technically right to compile.