SE-0360: Opaque result types with limited availability

Hi everyone. The review of SE-0360, Opaque result types with limited availability, begins now and runs through June 14, 2022.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager by email or DM. When contacting the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

Joe Groff
Review Manager

7 Likes

Seems reasonable, +1.

I guess we'll find out next week what it will be used for? :stuck_out_tongue:

2 Likes

Can anyone speak to why availability conditions can be supported but not general conditions? They both violate the single return type rule, so what makes availability so special here?

It looks like this restriction isn't quite right:

A function returning an opaque result type is allowed to return values of different concrete types from conditionally available if #available branches without any other dynamic conditions, if and only if, all universally available return statements in its body return a value of the same concrete type T . All returned values regardless of their location must meet all of the constraints stated on the opaque type.

  • This seems to be restricting the if statement that the if #available check is in, but of course an if #available check can be nested completely within another if:
    if foo {
      if #available(greenOS 1000) {
        return Y()
      }
    }
    return X()
    
  • The reverse is true as well: an ordinary if can be nested within an if #available, and the two branches of that if (if they contain returns) must return the same type:
    if #available(greenOS 1000) {
      if foo {
        return Y()
      } else {
        return X()
      }
    }
    return X()
    
  • Falling out of an if, or not taking it, has similar implications to nesting:
    if foo {
      return X()
    }
    if #available(greenOS 1000) {
      return Y()
    }
    return X()
    
    or
    if #available(greenOS 1000) {
      if foo {
        return Y()
      }
    }
    return X()
    

And of course you can do the same with any other conditional control flow: guard, switch, for, etc. I think the rule just has to discuss the possibility of constructing a static decision tree.

Should the proposal just say that it's allowed if the implementation can construct a static decision tree for the result type, but that the situations in which that's guaranteed to work can expand over time? Or will we need to put any future expansions through separate evolution review?

2 Likes

The underlying type of an opaque type must be constant during the execution of a process. Availability conditions do not change mid-execution, which is what we're taking advantage of here. Conceivably this could be extended to other known-constant conditions, like global let bindings or @const expressions, in the future as further proposals.

11 Likes

Ah, makes sense, thanks. Never heard that explanation before.

guard examples would also be a good idea, even if many users can easily apply the rules either way. I think it's slightly odd the proposal calls out if conditions and not just general conditions.

1 Like

I think we'd have to put any expansions through a separate review. I'll adjust the sentence to be explicit that if #available cannot be nested in other dynamic conditions or have nested dynamic conditions in it.

Seems like something which SwiftUI would need for pretty unremarkable purposes. Part of the point of returning opaque types all over the place is so that they can change them to different types without breaking clients, but without this they can't actually switch to a new type which sort of defeats the purpose.

It'd be neat to have this generalized to any @_effects(readonly) (or something more appropriate if that isn't it) condition. I can imagine things like switching to a debug version of some type depending on if an env variable is set at startup, where the condition isn't @const-suitable but we can promise that it'll never change. I assume that'd be dramatically more complicated than this proposal, though, and it's not like #available being special and different is a new thing.

2 Likes

+1 but I think the example is pretty weird. I had to read it a couple of times to figure out what it was trying to do.

struct Square {
  func asRectangle() -> some Shape {
     if #available(macOS 100, *) {
        return Rectangle(...)
     }
     
     return self
  }
}

In particular, the function "asRectangle()" doesn't return a Rectangle - neither statically (the return type is some Shape) and not necessarily dynamically (it could just return self).

It could be clearer. It could be any function which returns an instance of OldThing(...) or NewThing(...) depending on availability, with that difference being hidden behind an opaque type.

1 Like

+1 This seems like a rather straightforward fix.

The example demonstrated by the proposal does not make it particularly clear, but this seems like a useful feature towards making APIs back-portable by 1st or 3rd parties. :tada:

For a trivial example, I could think of ways to make SwiftUI’s refreshable(action:) modifier available (albeit in a renamed form[1]) to older platform versions without resorting to SwiftUI._ConditionalContent the actual return type of the modifier:

extension View {
    // Note: no `@ViewBuilder` here!
    @available(iOS 14.0, *)
    func refreshable_(action: @escaping @Sendable () async -> Void) -> some View {
        if #available(iOS 15.0, *) {
            return self.refreshable(action: action)
        }
        // Something like https://github.com/siteline/SwiftUI-Introspect#scrollview
        ...
    }
}

(If the function interface to backport contains data types or protocols unavailable to older targeted platform versions, however, then I'm afraid something more is needed than this proposal alone.)


  • What is your evaluation of the proposal?

+1

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes.

  • Does this proposal fit well with the feel and direction of Swift?

It seems like a natural extension to the rules about #available (and #unavailable).

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

N/A

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Quick reading, but also gave it a quick try on the latest main snapshot toolchain using iOS Simulator 14.5 and 15.5.


  1. Completely shadowing the existing implementation would further need a way for the shadowing function’s body to refer back to the shadowed one in the SwiftUI module in a qualified form, say, return `SwiftUI.View`.refreshable(action: action). Something similar was discussed but mainly for module-level symbols, not members, in Pitch: Fully qualified name syntax. ↩︎

1 Like

+1 It seems pretty reasonable!

For clarification, is the next usage allowed?

let v: some P
if #available(...) {
    v = P1()
} else {
    v = P2()
}
2 Likes

I should add that at first, I mistakenly used @ViewBuilder in my prototype implementation. Like so:

extension View {
    @ViewBuilder
    func refreshable_(action: @escaping @Sendable () async -> Void) -> some View {
        if #available(iOS 15.0, *) {
            self.refreshable(action: action)
        } else {
            self   // ignored for simplicity
        }
    }
}

This, however, would cause the app to segfault when pulling to refresh on iOS simulator 15.5.

Summary of crash report
Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x000000000000001f
Exception Codes: 0x0000000000000001, 0x000000000000001f
VM Region Info: 0x1f is not in any region.  Bytes before following region: 140737487060961
      REGION TYPE                    START - END         [ VSIZE] PRT/MAX SHRMOD  REGION DETAIL
      UNUSED SPACE AT START
--->  
      VM_ALLOCATE              7fffffec4000-7fffffec5000 [    4K] r-x/r-x SM=ALI  
Exception Note:  EXC_CORPSE_NOTIFY
Termination Reason: SIGNAL 11 Segmentation fault: 11
Terminating Process: exc handler [26342]

Triggered by Thread:  0

Thread 0 Crashed::  Dispatch queue: com.apple.main-thread
0   libdispatch.dylib             	    0x7fff201379c4 voucher_adopt + 59
1   ???                           	    0x7fff7010e510 ???
2   ???                           	    0x7fff7010e41f ???
3   ???                           	    0x7fff7010ed2d ???
4   libdispatch.dylib             	    0x7fff20122ec9 _dispatch_main_queue_drain + 672
5   libdispatch.dylib             	    0x7fff20122c1b _dispatch_main_queue_callback_4CF + 31
6   CoreFoundation                	    0x7fff20371ed5 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
7   CoreFoundation                	    0x7fff2036c6ca __CFRunLoopRun + 2761
8   CoreFoundation                	    0x7fff2036b704 CFRunLoopRunSpecific + 562
9   GraphicsServices              	    0x7fff2cba9c8e GSEventRunModal + 139
10  UIKitCore                     	    0x7fff2509e65a -[UIApplication _run] + 928
11  UIKitCore                     	    0x7fff250a32b5 UIApplicationMain + 101
12  SwiftUI                       	    0x7fff5dc81e5d closure #1 in KitRendererCommon(_:) + 196
13  SwiftUI                       	    0x7fff5dc81d97 runApp<A>(_:) + 148
14  SwiftUI                       	    0x7fff5d644854 static App.main() + 61
15  Backporting SwiftUI           	       0x10df7ccde static Backporting_SwiftUIApp.$main() + 30 (Backporting_SwiftUIApp.swift:10)
16  Backporting SwiftUI           	       0x10df7cd69 main + 9
17  dyld_sim                      	       0x10e1a7f21 start_sim + 10
18  dyld                          	       0x11bac351e start + 462

Would be good to check that this proposal interacts kindly with result builders.

1 Like

No, the semantics are changing only for opaque result types returned by functions, so an attempt to assign different types in your example would still fail to type-check.

1 Like

The proposal has been updated to clarify the rules regarding placement and semantics of if #available conditions and provide more examples of well-formed and invalid uses. I'd like to thank @John_McCall for all the help with this!

4 Likes

I view this proposal not from the back porting perspective but rather as a door opener for transparent under the hood improvements.

func swiftUIScrollView() -> some View {
  If #available(/* later os version */) {
    return NewSwiftUINativeSrollViewWithAmazingPerformance()
  }
  return OldScrollViewBakedByUIKit()
}

This would basically allow the framework maintainer to replace the underlying type in a safely manner and apply bug fixes.

+1 for me.

I have one question though. Are @frozen types doomed from such improvements or can they still be updated and improved somehow?

One concrete example would be the State type in SwiftUI, that type isn‘t lazy unlike its object counterpart StateObject which can be somewhat an annoyance . I‘m not 100% sure if that proposal would enable anything to flip some internals of that type though.

1 Like

Is there a reason for this?

A local variable with opaque type needs an initial underlying type at the declaration, and this proposal does not suggest changing that. I assume it would work if you assigned it to a closure that was immediately called if there were a way to specify that the closure returned some P, e.g.

let v: some P = {
  if #available(...) {
    return P1()
  } else {
    return P2()
  }
}()
2 Likes

I think this is an interesting functionality but I find the syntax very odd. It's not going to be clear as a developer when I can send a different type.

For instance going from

func test() -> some Shape {
  if #available(macOS 100, *) { ✅
    return Rectangle()
  }

  return self
}

to

func test() -> some Shape {
  if cond {
    ...
  } else if #available(macOS 100, *) { ❌
    return Rectangle()
  }
  return self
}

my code won't compile.
But what's going to be the error message? If it's "return statements in its body do not have matching underlying types" then it's misleading because it was the case just before when using only #if.

I find even less clear when reading the guard example.

Shouldn't the restriction be on the function itself? And Swift have a way to choose the right definition to use based on the availability?

// swift use this version of `test` when on macOS >= 100
@available(macOS 100, *)
func test() -> some Shape {
  // now you can put whatever condition you want
  guard let x = <opt-value> else {
    return ...
  }
    
  return Rectangle()
}

// swift use this version of `test` when on macOS <100
func test() -> some Shape {
guard let x = <opt-value> else {
    return ...
  }
    
  return Square()
}

You might have a little bit more duplicated code but at least what happen is clear IMO.

Edit: re-reading the proposal the availability check is done at runtime. So I think it's the right way. Just maybe make sure to have a clear error message on why it will fail to compile in some use cases.

Also can we expect to have guard support someday?