[Pitch] Elide `some` in Swift 6

You can do this in Rust and it is used pretty regularly, so it cannot be too bad in terms of performance. I've always wondered why the Swift type checker still has performance problems even though it is mostly restricted to type-checking a single expression at a time while Rust does much heavier type-checking very seamlessly.

1 Like

Thanks for your response @angela-laar!

Yeah I it would be great for the proposal to include discussion of any empirical data that has already been gathered, and to be as concrete as possible. IMO "most cases" still sounds pretty hedge-y to me.

My use of "more opaque" should be construed fairly narrowly. I am specifically talking about the issue that others in the thread have pointed out where, from looking at declarations such as:

func f(_: J)
var j: J

it will become impossible to infer from the signatures alone some potentially salient details about how these declarations can be used, even if I am fully aware of the difference between any and some. Instead, I must either click through to the definition of each type to determine if it's a protocol, or attempt to use the declarations in an invalid way to discover the problem. That is, to me, a strictly worse user experience than the status quo.

Now, @jeremyabannister's point is also relevant here:

I will admit that I cannot off the top of my head rattle off all the ways in which using some P instead of any P might change or affect the use of a declaration. So, perhaps we think that the potential issues are narrow enough that the benefit of not requiring users to insert 'some' outweighs the costs. But in general I bias towards favoring ease of reading over ease of writing.

I disagree that "apply fix-it" requires a deep dive. IMO an error along the lines of "bare protocol name cannot be used as a type" along with a "fix it: add 'some'" is reasonably in line with progressive disclosure. It would enforce the idea that some should be the default and prevent users who don't care about the difference from having to think too hard about it until they need to.

Sure, I concede that such changes are probably unlikely to cause any major issues, I only mean to highlight that I don't the current text paints a full picture of the source compatibility story. I also think the problem is somewhat different in kind to that noted in SE-0352, where we are bringing new possible overloads in-bounds—here, we're taking two overloads which both already type check and changing their ranking.

Yeah I expect we can catch the most obvious cases but I am still moderately worried that every additional edge case we have to account for increases the complexity and makes it more likely that something will slip through the cracks. But if we have reason to believe that we really can migrate large complex codebases with a 'one-click' solution (as the 'add any' migration would be) in the majority of cases, I would love to be contradicted. :slightly_smiling_face:

I will also note that even if we can get to a 'one-click' migration with the change pitched here, the difficulty for users who wish to ensure behavior won't change with the migration is still quite high (since the migration will be prioritizing a smaller diff over fewer semantic changes). So I still think the option to add any everywhere would be a good one.

Additionally, IMO the proposal should more fully grapple with the story for all library authors, including those that are not writing resilient libraries as @xwu calls out above ("all public APIs"). We can't count on these authors to be running any kind of post-migration check on their code besides "does it still compile," and they may not have test cases that will catch the situations where the existential-to-generic conversion will break their clients.

Yeah as mentioned above I think having a more complete migration story would greatly help the proposal!

2 Likes

I am very, very supportive of the goals of this pitch. Any attempt to mitigate the effects of SE-0335 as passed is welcome in my book.

I had a quick look at last night’s developer snapshot trying to compile GitHub - Alamofire/Alamofire: Elegant HTTP Networking in Swift. I know this is a work in progress but perhaps we’re going to have to refine the idea and compromise a little on migrating to some. The list of errors trying to compile a fresh clone and using swift build -Xswiftc -enable-experimental-feature -Xswiftc ImplicitSome is below.

https://johnholdsworth.com/firstattempt.txt

The first impression these errors give is perhaps we can elide to some except in the following cases:

  • Where the bare protocol is in a Container (Array, Dictionary or Optional)
  • Where the bare protocol is the type of an uninitialised property.
  • Where the bare protocol specifies the argument to a closure.
  • Where eliding to any satisfied a legacy protocol constraint.

I’ve edited the project manually as if these rules were applied and it builds until there is a crash later in code generation which may or may not be relevant.

Suggested tempering of elision to `some`
diff --git a/Source/AFError.swift b/Source/AFError.swift
index 8cd60c7..37c568c 100644
--- a/Source/AFError.swift
+++ b/Source/AFError.swift
@@ -363,7 +363,7 @@ extension AFError {
 
 extension AFError {
     /// The `URLConvertible` associated with the error.
-    public var urlConvertible: URLConvertible? {
+    public var urlConvertible: (any URLConvertible)? {
         guard case let .invalidURL(url) = self else { return nil }
         return url
     }
@@ -377,7 +377,7 @@ extension AFError {
     /// The underlying `Error` responsible for generating the failure associated with `.sessionInvalidated`,
     /// `.parameterEncodingFailed`, `.parameterEncoderFailed`, `.multipartEncodingFailed`, `.requestAdaptationFailed`,
     /// `.responseSerializationFailed`, `.requestRetryFailed` errors.
-    public var underlyingError: Error? {
+    public var underlyingError: (any Error)? {
         switch self {
         case let .multipartEncodingFailed(reason):
             return reason.underlyingError
@@ -460,7 +460,7 @@ extension AFError {
 }
 
 extension AFError.ParameterEncodingFailureReason {
-    var underlyingError: Error? {
+    var underlyingError: (any Error)? {
         switch self {
         case let .jsonEncodingFailed(error),
              let .customEncodingFailed(error):
@@ -472,7 +472,7 @@ extension AFError.ParameterEncodingFailureReason {
 }
 
 extension AFError.ParameterEncoderFailureReason {
-    var underlyingError: Error? {
+    var underlyingError: (any Error)? {
         switch self {
         case let .encoderFailed(error):
             return error
@@ -503,7 +503,7 @@ extension AFError.MultipartEncodingFailureReason {
         }
     }
 
-    var underlyingError: Error? {
+    var underlyingError: (any Error)? {
         switch self {
         case let .bodyPartFileNotReachableWithError(_, error),
              let .bodyPartFileSizeQueryFailedWithError(_, error),
@@ -564,7 +564,7 @@ extension AFError.ResponseValidationFailureReason {
         }
     }
 
-    var underlyingError: Error? {
+    var underlyingError: (any Error)? {
         switch self {
         case let .customValidationFailed(error):
             return error
@@ -594,7 +594,7 @@ extension AFError.ResponseSerializationFailureReason {
         }
     }
 
-    var underlyingError: Error? {
+    var underlyingError: (any Error)? {
         switch self {
         case let .jsonSerializationFailed(error),
              let .decodingFailed(error),
@@ -632,7 +632,7 @@ extension AFError.ServerTrustFailureReason {
         }
     }
 
-    var underlyingError: Error? {
+    var underlyingError: (any Error)? {
         switch self {
         case let .customEvaluationFailed(error):
             return error
diff --git a/Source/AuthenticationInterceptor.swift b/Source/AuthenticationInterceptor.swift
index c3a3f31..625cec1 100644
--- a/Source/AuthenticationInterceptor.swift
+++ b/Source/AuthenticationInterceptor.swift
@@ -81,7 +81,7 @@ public protocol Authenticator: AnyObject {
     ///   - credential: The `Credential` to refresh.
     ///   - session:    The `Session` requiring the refresh.
     ///   - completion: The closure to be executed once the refresh is complete.
-    func refresh(_ credential: Credential, for session: Session, completion: @escaping (Result<Credential, Error>) -> Void)
+    func refresh(_ credential: Credential, for session: Session, completion: @escaping (Result<Credential, any Error>) -> Void)
 
     /// Determines whether the `URLRequest` failed due to an authentication error based on the `HTTPURLResponse`.
     ///
@@ -193,7 +193,7 @@ public class AuthenticationInterceptor<AuthenticatorType>: RequestInterceptor wh
     private struct AdaptOperation {
         let urlRequest: URLRequest
         let session: Session
-        let completion: (Result<URLRequest, Error>) -> Void
+        let completion: (Result<URLRequest, any Error>) -> Void
     }
 
     private enum AdaptResult {
@@ -247,7 +247,7 @@ public class AuthenticationInterceptor<AuthenticatorType>: RequestInterceptor wh
 
     // MARK: Adapt
 
-    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         let adaptResult: AdaptResult = $mutableState.write { mutableState in
             // Queue the adapt operation if a refresh is already in place.
             guard !mutableState.isRefreshing else {
diff --git a/Source/EventMonitor.swift b/Source/EventMonitor.swift
index 3b09671..4333f0c 100644
--- a/Source/EventMonitor.swift
+++ b/Source/EventMonitor.swift
@@ -312,13 +312,13 @@ extension EventMonitor {
 public final class CompositeEventMonitor: EventMonitor {
     public let queue = DispatchQueue(label: "org.alamofire.compositeEventMonitor", qos: .utility)
 
-    let monitors: [EventMonitor]
+    let monitors: [any EventMonitor]
 
-    init(monitors: [EventMonitor]) {
+    init(monitors: [any EventMonitor]) {
         self.monitors = monitors
     }
 
-    func performEvent(_ event: @escaping (EventMonitor) -> Void) {
+    func performEvent(_ event: @escaping (any EventMonitor) -> Void) {
         queue.async {
             for monitor in self.monitors {
                 monitor.queue.async { event(monitor) }
@@ -571,7 +571,7 @@ public final class CompositeEventMonitor: EventMonitor {
 /// `EventMonitor` that allows optional closures to be set to receive events.
 open class ClosureEventMonitor: EventMonitor {
     /// Closure called on the `urlSession(_:didBecomeInvalidWithError:)` event.
-    open var sessionDidBecomeInvalidWithError: ((URLSession, Error?) -> Void)?
+    open var sessionDidBecomeInvalidWithError: ((URLSession, (any Error)?) -> Void)?
 
     /// Closure called on the `urlSession(_:task:didReceive:completionHandler:)`.
     open var taskDidReceiveChallenge: ((URLSession, URLSessionTask, URLAuthenticationChallenge) -> Void)?
@@ -589,7 +589,7 @@ open class ClosureEventMonitor: EventMonitor {
     open var taskDidFinishCollectingMetrics: ((URLSession, URLSessionTask, URLSessionTaskMetrics) -> Void)?
 
     /// Closure called on the `urlSession(_:task:didCompleteWithError:)` event.
-    open var taskDidComplete: ((URLSession, URLSessionTask, Error?) -> Void)?
+    open var taskDidComplete: ((URLSession, URLSessionTask, (any Error)?) -> Void)?
 
     /// Closure called on the `urlSession(_:taskIsWaitingForConnectivity:)` event.
     open var taskIsWaitingForConnectivity: ((URLSession, URLSessionTask) -> Void)?
diff --git a/Source/MultipartUpload.swift b/Source/MultipartUpload.swift
index ceda21f..04df61a 100644
--- a/Source/MultipartUpload.swift
+++ b/Source/MultipartUpload.swift
@@ -31,7 +31,7 @@ final class MultipartUpload {
     @Protected
     private(set) var multipartFormData: MultipartFormData
     let encodingMemoryThreshold: UInt64
-    let request: URLRequestConvertible
+    let request: any URLRequestConvertible
     let fileManager: FileManager
 
     init(encodingMemoryThreshold: UInt64,
diff --git a/Source/Request.swift b/Source/Request.swift
index 3d64743..148e98b 100644
--- a/Source/Request.swift
+++ b/Source/Request.swift
@@ -72,11 +72,11 @@ public class Request {
     /// The queue used for all serialization actions. By default it's a serial queue that targets `underlyingQueue`.
     public let serializationQueue: DispatchQueue
     /// `EventMonitor` used for event callbacks.
-    public let eventMonitor: EventMonitor?
+    public let eventMonitor: (any EventMonitor)?
     /// The `Request`'s interceptor.
-    public let interceptor: RequestInterceptor?
+    public let interceptor: (any RequestInterceptor)?
     /// The `Request`'s delegate.
-    public private(set) weak var delegate: RequestDelegate?
+    public private(set) weak var delegate: (any RequestDelegate)?
 
     // MARK: - Mutable State
 
@@ -89,9 +89,9 @@ public class Request {
         /// `ProgressHandler` and `DispatchQueue` provided for download progress callbacks.
         var downloadProgressHandler: (handler: ProgressHandler, queue: DispatchQueue)?
         /// `RedirectHandler` provided for to handle request redirection.
-        var redirectHandler: RedirectHandler?
+        var redirectHandler: (any RedirectHandler)?
         /// `CachedResponseHandler` provided to handle response caching.
-        var cachedResponseHandler: CachedResponseHandler?
+        var cachedResponseHandler: (any CachedResponseHandler)?
         /// Queue and closure called when the `Request` is able to create a cURL description of itself.
         var cURLHandler: (queue: DispatchQueue, handler: (String) -> Void)?
         /// Queue and closure called when the `Request` creates a `URLRequest`.
@@ -165,7 +165,7 @@ public class Request {
     // MARK: Redirect Handling
 
     /// `RedirectHandler` set on the instance.
-    public private(set) var redirectHandler: RedirectHandler? {
+    public private(set) var redirectHandler: (any RedirectHandler)? {
         get { $mutableState.redirectHandler }
         set { $mutableState.redirectHandler = newValue }
     }
@@ -173,7 +173,7 @@ public class Request {
     // MARK: Cached Response Handling
 
     /// `CachedResponseHandler` set on the instance.
-    public private(set) var cachedResponseHandler: CachedResponseHandler? {
+    public private(set) var cachedResponseHandler: (any CachedResponseHandler)? {
         get { $mutableState.cachedResponseHandler }
         set { $mutableState.cachedResponseHandler = newValue }
     }
@@ -1083,7 +1083,7 @@ public protocol RequestDelegate: AnyObject {
 /// `Request` subclass which handles in-memory `Data` download using `URLSessionDataTask`.
 public class DataRequest: Request {
     /// `URLRequestConvertible` value used to create `URLRequest`s for this instance.
-    public let convertible: URLRequestConvertible
+    public let convertible: any URLRequestConvertible
     /// `Data` read from the server so far.
     public var data: Data? { mutableData }
 
@@ -1244,7 +1244,7 @@ public final class DataStreamRequest: Request {
     }
 
     /// `URLRequestConvertible` value used to create `URLRequest`s for this instance.
-    public let convertible: URLRequestConvertible
+    public let convertible: any URLRequestConvertible
     /// Whether or not the instance will be cancelled if stream parsing encounters an error.
     public let automaticallyCancelOnStreamError: Bool
 
@@ -1778,7 +1778,7 @@ public class UploadRequest: DataRequest {
     // MARK: Initial State
 
     /// The `UploadableConvertible` value used to produce the `Uploadable` value for this instance.
-    public let upload: UploadableConvertible
+    public let upload: any UploadableConvertible
 
     /// `FileManager` used to perform cleanup tasks, including the removal of multipart form encoded payloads written
     /// to disk.
diff --git a/Source/RequestInterceptor.swift b/Source/RequestInterceptor.swift
index 7ed39a5..f591ee7 100644
--- a/Source/RequestInterceptor.swift
+++ b/Source/RequestInterceptor.swift
@@ -43,7 +43,7 @@ public protocol RequestAdapter {
     ///   - urlRequest: The `URLRequest` to adapt.
     ///   - session:    The `Session` that will execute the `URLRequest`.
     ///   - completion: The completion handler that must be called when adaptation is complete.
-    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void)
+    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void)
 
     /// Inspects and adapts the specified `URLRequest` in some manner and calls the completion handler with the Result.
     ///
@@ -51,11 +51,11 @@ public protocol RequestAdapter {
     ///   - urlRequest: The `URLRequest` to adapt.
     ///   - state:      The `RequestAdapterState` associated with the `URLRequest`.
     ///   - completion: The completion handler that must be called when adaptation is complete.
-    func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, Error>) -> Void)
+    func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, any Error>) -> Void)
 }
 
 extension RequestAdapter {
-    public func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    public func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         adapt(urlRequest, for: state.session, completion: completion)
     }
 }
@@ -89,7 +89,7 @@ extension RetryResult {
         }
     }
 
-    var error: Error? {
+    var error: (any Error)? {
         guard case let .doNotRetryWithError(error) = self else { return nil }
         return error
     }
@@ -118,7 +118,7 @@ public protocol RequestRetrier {
 public protocol RequestInterceptor: RequestAdapter, RequestRetrier {}
 
 extension RequestInterceptor {
-    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         completion(.success(urlRequest))
     }
 
@@ -148,11 +148,11 @@ open class Adapter: RequestInterceptor {
         self.adaptHandler = adaptHandler
     }
 
-    open func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    open func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         adaptHandler(urlRequest, session, completion)
     }
 
-    open func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    open func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         adaptHandler(urlRequest, state.session, completion)
     }
 }
@@ -207,9 +207,9 @@ extension RequestRetrier where Self == Retrier {
 /// `RequestInterceptor` which can use multiple `RequestAdapter` and `RequestRetrier` values.
 open class Interceptor: RequestInterceptor {
     /// All `RequestAdapter`s associated with the instance. These adapters will be run until one fails.
-    public let adapters: [RequestAdapter]
+    public let adapters: [any RequestAdapter]
     /// All `RequestRetrier`s associated with the instance. These retriers will be run one at a time until one triggers retry.
-    public let retriers: [RequestRetrier]
+    public let retriers: [any RequestRetrier]
 
     /// Creates an instance from `AdaptHandler` and `RetryHandler` closures.
     ///
@@ -242,14 +242,14 @@ open class Interceptor: RequestInterceptor {
         self.retriers = retriers + interceptors
     }
 
-    open func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    open func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         adapt(urlRequest, for: session, using: adapters, completion: completion)
     }
 
     private func adapt(_ urlRequest: URLRequest,
                        for session: Session,
-                       using adapters: [RequestAdapter],
-                       completion: @escaping (Result<URLRequest, Error>) -> Void) {
+                       using adapters: [any RequestAdapter],
+                       completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         var pendingAdapters = adapters
 
         guard !pendingAdapters.isEmpty else { completion(.success(urlRequest)); return }
@@ -266,14 +266,14 @@ open class Interceptor: RequestInterceptor {
         }
     }
 
-    open func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, Error>) -> Void) {
+    open func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         adapt(urlRequest, using: state, adapters: adapters, completion: completion)
     }
 
     private func adapt(_ urlRequest: URLRequest,
                        using state: RequestAdapterState,
-                       adapters: [RequestAdapter],
-                       completion: @escaping (Result<URLRequest, Error>) -> Void) {
+                       adapters: [any RequestAdapter],
+                       completion: @escaping (Result<URLRequest, any Error>) -> Void) {
         var pendingAdapters = adapters
 
         guard !pendingAdapters.isEmpty else { completion(.success(urlRequest)); return }
@@ -300,7 +300,7 @@ open class Interceptor: RequestInterceptor {
     private func retry(_ request: Request,
                        for session: Session,
                        dueTo error: Error,
-                       using retriers: [RequestRetrier],
+                       using retriers: [any RequestRetrier],
                        completion: @escaping (RetryResult) -> Void) {
         var pendingRetriers = retriers
 
diff --git a/Source/ResponseSerialization.swift b/Source/ResponseSerialization.swift
index 3097364..03130ef 100644
--- a/Source/ResponseSerialization.swift
+++ b/Source/ResponseSerialization.swift
@@ -65,7 +65,7 @@ public protocol DownloadResponseSerializerProtocol {
 /// A serializer that can handle both data and download responses.
 public protocol ResponseSerializer: DataResponseSerializerProtocol & DownloadResponseSerializerProtocol {
     /// `DataPreprocessor` used to prepare incoming `Data` for serialization.
-    var dataPreprocessor: DataPreprocessor { get }
+    var dataPreprocessor: any DataPreprocessor { get }
     /// `HTTPMethod`s for which empty response bodies are considered appropriate.
     var emptyRequestMethods: Set<HTTPMethod> { get }
     /// HTTP response codes for which empty response bodies are considered appropriate.
diff --git a/Source/Result+Alamofire.swift b/Source/Result+Alamofire.swift
index 39ac286..6fe83ab 100644
--- a/Source/Result+Alamofire.swift
+++ b/Source/Result+Alamofire.swift
@@ -79,7 +79,7 @@ extension Result {
     ///
     /// - returns: A `Result` containing the result of the given closure. If this instance is a failure, returns the
     ///            same failure.
-    func tryMap<NewSuccess>(_ transform: (Success) throws -> NewSuccess) -> Result<NewSuccess, Error> {
+    func tryMap<NewSuccess>(_ transform: (Success) throws -> NewSuccess) -> Result<NewSuccess, any Error> {
         switch self {
         case let .success(value):
             do {
@@ -105,7 +105,7 @@ extension Result {
     ///
     /// - Returns: A `Result` instance containing the result of the transform. If this instance is a success, returns
     ///            the same success.
-    func tryMapError<NewFailure: Error>(_ transform: (Failure) throws -> NewFailure) -> Result<Success, Error> {
+    func tryMapError<NewFailure: Error>(_ transform: (Failure) throws -> NewFailure) -> Result<Success, any Error> {
         switch self {
         case let .failure(error):
             do {
diff --git a/Source/Session.swift b/Source/Session.swift
index 4232f85..3be3c97 100644
--- a/Source/Session.swift
+++ b/Source/Session.swift
@@ -54,17 +54,17 @@ open class Session {
     public let serializationQueue: DispatchQueue
     /// `RequestInterceptor` used for all `Request` created by the instance. `RequestInterceptor`s can also be set on a
     /// per-`Request` basis, in which case the `Request`'s interceptor takes precedence over this value.
-    public let interceptor: RequestInterceptor?
+    public let interceptor: (any RequestInterceptor)?
     /// `ServerTrustManager` instance used to evaluate all trust challenges and provide certificate and key pinning.
     public let serverTrustManager: ServerTrustManager?
     /// `RedirectHandler` instance used to provide customization for request redirection.
-    public let redirectHandler: RedirectHandler?
+    public let redirectHandler: (any RedirectHandler)?
     /// `CachedResponseHandler` instance used to provide customization of cached response handling.
-    public let cachedResponseHandler: CachedResponseHandler?
+    public let cachedResponseHandler: (any CachedResponseHandler)?
     /// `CompositeEventMonitor` used to compose Alamofire's `defaultEventMonitors` and any passed `EventMonitor`s.
     public let eventMonitor: CompositeEventMonitor
     /// `EventMonitor`s included in all instances. `[AlamofireNotifications()]` by default.
-    public let defaultEventMonitors: [EventMonitor] = [AlamofireNotifications()]
+    public let defaultEventMonitors: [any EventMonitor] = [AlamofireNotifications()]
 
     /// Internal map between `Request`s and any `URLSessionTasks` that may be in flight for them.
     var requestTaskMap = RequestTaskMap()

I’m sure this sounds "not serious" and a bit arbitrary, being neither eliding to one or the other in a seemingly non-deterministic way but the rules aren't that complicated and essentially amount to continuing to use an existential when the value is contained in another type so as not to leak it’s genericisity to the containing type.

In this way the net effect of this aspect of Swift 6 would be reaping the benefits of a partial migration directly to some without trying to apply it where it wouldn't have been appropriate anyway which seems to be reasonably easy to determine.

Alamofire is great starting point. It is a popular, rich codebase which makes extensive use of protocols. If it can be made to build without modification that would bode well for the existing Swift codebase.

4 Likes

With respect, I must dissent from this proposal. I believe it represents a significant decrease in readability and comprehensibility of Swift code for very little benefit.

I belive much of the crux of the argument for this pitch (aside from the migration benefit, addressed below, and saving five characters of typing) is expressed in the following quote:

Swift 6 could apply this same principle [concision as a form of progressive disclosure] to plain protocol names in other contexts. Such a practice could be invaluable for beginners learning Swift, removing the mental load of comparing tradeoffs between some and any when adding protocols to their code

However, most people who are learning a new language do so by rote at first and then later learn why they were taught what they were taught. Telling people who are new to using protocols that "you write protocols as some P when you use them in functions or variables" is close enough to true for almost any student and requires no cognitive overhead at all besides remembering a syntax, which is par for the course when learning a language. I actually think the distinctive syntax for protocols is an advantage, as it helps make clear to learners that protocols are a different kind of thing than enums, structs, classes and (for the very brave) actors. Perhaps that will pique their interest to inquire about why and how they're different.

It is, of course, true to say that a developer who's familiar with Swift (in a future where this pitch is accepted) would know that a bare P is shorthand for generic P. It's not true, however, that a developer reading some code would know that P is a protocol name. Indeed, this proposal would make it impossible to know that (in most cases) without going to the declaration of P. Progressive disclosure is a wonderful design tenet, but I think it shouldn't be used to justify adding a papercut to experienced devs' lives.

I'm sympathetic to the idea of easing the migration to Swift 6, but this strikes me as paying a permanent price (worse syntax, as argued above) for a one-time problem. And, at that, a one-time problem that occurs at a time of a user's choosing and that can be resolved automatically with guaranteed correctness. To me (someone who works daily in a very large Swift codebase with a dozen or so other developers), this argument holds no sway in light of the cost.

I'm also concerned about the subtle breaking of expected behavior (any -> some will sometimes result in unexpected behavior changes), but as that, too, is a one-time thing in a major language revision, I feel that sort of change would be acceptable if the benefits outweighed the other costs.

I thank you for your consideration and for all the hard work you and everyone else have put into making Swift such a great language.

30 Likes

To clarify, while this list of differences that you mention is of course also highly relevant to this discussion, I was asking about the list of differences between the ways I can use the value pushNotification in this function, where PushNotification is a struct (or enum, etc.):

func sendPushNotification (_ pushNotification: PushNotification) async throws {
    // code...
}

vs. this function where PushNotification is a protocol.

func sendPushNotification (_ pushNotification: some PushNotification) async throws {
    // code...
}

because if this proposal were approved then they would both look like the first function, but would continue to behave differently (assuming the list of differences I'm asking for is not empty).

P.S. Thank you, whoever you were, for dark mode on the Swift forums! In the middle of writing this post my MacBook switched to dark mode and the Swift forums did too along with it, which is a hugely appreciated recent upgrade. Thank you!
4 Likes

I have been looking for surprising side effects in the interaction of this pitch with the fact that overload resolution favors existentials.

First, a reminder of what I mean by "overload resolution favors existentials". It is this:

protocol P { }
struct S: P { }
func foo(_ x: any P) { print("P existential") }
func foo(_ x: some P) { print("P generic") }
// Prints "P existential"
foo(S())

Yes, that's how the language works today - one of its little gotchas. It's hard to consider the existential version as "more specific", and it would be interesting to know why avoiding the generic and specializable method was considered as the best implementation strategy. Anyway, that's not the point of this post. Overload resolution favors existentials, that's what's important.

So I found this. The code below changes its behavior depending on how the bare P is interpreted:

protocol P { }
protocol Q { }
struct S: P, Q { }
func foo(_ x: P) { print("Bare P") }
func foo(_ x: some P & Q) { print("P & Q generic") }
// Prints "Bare P" if P is interpreted as `any P`
// Prints "P & Q generic" if P is interpreted as `some P`
foo(S())

I have difficulties deciding if this would create a lot of churn. But this definitely is a breaking change that is worth mentioning in this pitch.

9 Likes

It's certainly worth mentioning in the proposal. My feeling is this change is very much for the better (and any semantic change resulting could as or more likely fix bugs as cause them). It's undesirable for P to win out over P & Q, the latter being more specific. The fact that existentials are ranked higher than generics is a strange counterintuitive quirk that (this needs a separate thread for discussion) should also be fixed in Swift 6.

8 Likes

Why is it for the better though? Apart from the migration argument (which I disagree with), how would this actually make Swift better? It seems significantly more confusing to me.

A hand-wavy description of Swift's overload resolution is that it picks "the most specific" function. So it'll prefer e.g. a method on the concrete String to a generic method on StringProtocol – the concrete one has more information about the type so is more likely to be optimal. It'll also prefer a method on RandomAccessCollection over one on Collection – the random access one can take advantage of guaranteed constant-time movement around the collection.

It also prefers functions that don't need implicit conversion so for example, a function that takes a String? is preferred over one that takes String. This goes for conversion to Any too. A function f<T>(_ t: T) is preferred over one that takes f(_ a: Any).

There are patches where this intuition breaks down though. Let's take a simpler example, that works today without upcoming or experimental features:

func f(_ a: any Collection) {
  print("A collection of mystery")
}

func f(_ a: some Collection<Int>) {
  print("A collection of sum ", a.reduce(0,+))
}

f([1,2,3]) // prints A collection of mystery

This is clearly not the best choice. Of course a function that knows the collection contains an Int should be picked over one that knows nothing about the contents of the collection and can't be optimized for it (perhaps it attempts to figure out what the collection contains using as? at runtime... which is very inefficient). But it works this way because of the path Swift has taken over the years, and overload resolution not being updated to keep up.

@gwendal.roue's example is similar. Of course overload resolution should pick a function that knows its argument conforms to P & Q over a function that knows the type conforms only to P.

Now as it happens, this is happening as a byproduct of P moving from being an existential to a generic argument. Another way of achieving this would be to change the overload rules to prefer generic arguments with the same constraints as an existential. But in either case, we are moving more towards the "correct" (IMO) behavior. I think we should do both, but this thread is about a pitch to do the former.

5 Likes

I'll post my thoughts on the pitch (of which I'm very much in favor) in another post. For now, I wanted to add some practical usage data to the discussion.

I took the latest nightly toolchain and used it to port the Mac target of NetNewsWire to implicit some. It's a fairly large-sized real world project. I only updated the Mac target, and only the app project itself, not the packages on which it depends, which stayed with the current behavior.

The experience went pretty smoothly (I filed some diagnostic improvements that would have helped along the way) and took me about half an hour. Every change was mechanical – I basically added any wherever I needed to to make it compile. In all cases this mechanical translation was "correct" – there were a few places where I could have fiddled with the code to instead make it generic, but didn't.

After migration, the code compiled and worked both with -enable-experimental-feature ImplicitSome and without it.

Overall there were 319 needed additions of any. Note that these are the requirement of of SE-0335 rather than this pitch. Incidentally, this has solidified in my mind that the parenthesis in (any P)? have got to go. This was discussed during the SE-0335 but needs revisiting based on (my :) experience in the wild.

By far the biggest cause was Result<_, any Error>, of which there were 205 occurrences, almost entirely found in callbacks. Presumably these will all go away as codebases migrate to async functions instead.

The remaining 200 or so any were mostly either:

  • Storage: delegate protocols where the any is there to represent it could be any delegate (you could model this instead by making the type generic over its delegate, but that is probably not worth doing), or heterogenous collections. In these cases the any adds useful meaning. It really could be any of a number of type-erased types.
  • Optional Arguments: the function was being passed an existential, and it was optional, so the concrete type may not be available when opening the existential. In some cases, some local refactoring could have eliminated the existential, improving the code slightly. In others places, use of any was the best option.
  • Conformances to protocols that took any ObjCType and that cannot be generic because of the requirements of @objc. It is worth discussing a targeted carve-out to default imported ObjC protocols to be any.

Offsetting these, I counted about 40 or so places where some was in the code but could be eliminated – all uses of it in SwiftUI (some View, some Widget etc). There is currently a bug in the nightly toolchain so I didn't actually do this migration.

NetNewsWire started as a *Kit app so has relatively little SwiftUI. This will presumably increase significantly over time. An app that was all SwiftUI would likely eliminate several hundred of these (it might be interesting to try porting Ice Cubes or MovieSwiftUI). Subjectively, my view is that these some View annotations are pure noise, adding little readability benefit. This is in stark contrast to e.g. [any View] storage where the heterogenous nature of the storage is of significance. I'll say more about that when I post my pitch response.

After I did this port, I then went back and switched to -enable-upcoming-feature ExistentialAny instead. This required me to go through and add 60 additional annotations. These were mainly either on arguments where the function was now generic but functionally equivalent to an existential, or they were on as? or is where the annotation doesn't really have much effect. Again my personal opinion is these extra annotations were just noise without readability benefit (will post further on this), and added additional migration effort without much benefit.

27 Likes

Thanks for the thorough reply (in old Airspeed fashion), but it seems I misunderstood your previous post. I thought you meant that the proposed change in general was good, and that's what I wanted to ask about, not the overload priority change that you where actually referring to.

1 Like

Heh, oops. I do also think the proposal is generally good. I think it makes Swift into what I wish it had always been. Still writing that. But in this case, yeah, I was just talking about the overload resolution priority concern.

3 Likes

i have been doing a lot of API and DSL design recently, so my opinion is colored by that use case.

right now i am very opposed to “hiding” generic-ness, because (as many others have already pointed out), generic APIs behave very differently from concrete APIs:

  • generic APIs cannot use leading-dot syntax. (as an API designer, SE-0299 is not an attractive workaround because of the symbol pollution it causes.)
  • generic APIs are incompatible with the various ExpressibleBy literal protocols. this can have serious performance implications for libraries that expose a preferred and more-efficient ExpressibleBy type, but also provide a fallback conformance for Array<Element>, Dictionary<Key, Value>, etc.

if member/conformance lookup were generalized to “just work” for generic APIs the way they work for concretely-typed APIs, i would have no issue with implicit some.

10 Likes

I’m very opposed to this pitch. It wasn’t so long ago that MyProtocol meant any MyProtocol, and that code will now swap meaning some MyProtocol without any source-code changes. That level of churn is damaging, I think.

Mandating any was a big win for clarity and the mitigation of foot-guns; we threaten to undo many of those benefits if we permit the elision of some. I’m aware that there’s a narrative that developers should start with the opaque type and only switch to the existential version if the property/function/whatever needs to support heterogeneity. That’s wise advice in some scenarios, but it’s not a good reason to hide an incredibly important detail about the semantics of your generic types.

For a similar reason to what led to the any keyword, it would be totally nonobvious to someone who isn’t familiar with the intricacies of the Swift type system that func doSomething() -> MyProtocol must always return the same underlying concrete type. Novice developers will be confused about why they can’t use an if statement to return one of two different concrete types. “They both conform to the same protocol, so why does it care? I already set the return type to be the protocol, not just one of the concrete types!”

Someone will inevitably answer a relevant Stack Overflow question by just advising that the original poster add the any keyword without further explanation, and then it’ll come to be known as “the keyword that you add to make the annoying type-check error go away”, at which point we’ll be right back to where we were before the any keyword, with developers falling off the existential cliff (does that count as a pun? I think so!) of no Self or associated-type references without realizing it.

The some keyword is an indication that “there’s something more going on”, and it disambiguates between the two interpretations of using a protocol as a type. The fact that you can’t “just use the protocol as a type” is precisely the point!

The short version is that developers absolutely need to know the difference between opaque and existential types. This domain is sufficiently complex and nuanced that you can’t just hand-wave away those details and declare one as the syntactic default.

21 Likes

I'm against this.

Swift 1.0 made the unfortunate decision to conflate "interfaces" (ObjC @protocols) with "typeclasses" (Swift protocols with Self constraints, etc.). Where an interface creates a type, a typeclass does not.

With some and any, we've finally undone this conflation, and have a more or less clear understanding that a protocol represents a family of types, and that some P selects one such type, and any P allows any such type (with corresponding restrictions on what you can then achieve given the lack of information).

Shifting the line in the sand to introduce a conflation in the opposite direction doesn't seem helpful, particularly given the significant number of situations where some P is disallowed or in some way does not act like a normal, concrete type.

37 Likes

This is intriguing to me, because it suggests a way to accomplish the goal of a smooth migration by going in a contrary direction to this pitch.

To back up a bit: One thing that concerns me about the pitch is that we already have two special-case elisions allowed by SE-0355: Any is equivalent to any Any, and AnyObject is equivalent to any AnyObject—if we adopt this pitch as-is, then we would have a scenario where everywhere a bare protocol P means some P except for Any and AnyObject. That much can be taught but seems...inelegant.

I'm not sure if I'm reading you correctly where you talk about a "targeted carve-out," but if this is to say that for an imported Objective-C protocol ObjcP, bare ObjcP should mean any ObjcP, but for a Swift-native protocol SwiftP, bare SwiftP should mean some SwiftP—well, it would seem to me that this would make the language and our migration story more confusing, as we'd be extending the special rule above beyond just two cases that the user can memorize to arbitrarily many protocols that users have to consult documentation and/or rely on tooling to clarify the provenance of.

However, there is something particular about Objective-C protocols, shared in common with Error and with marker protocols, which bears considering: namely, in these cases, the existential types conform to the corresponding protocol. I'd argue that it makes the distinction between any P and some P less salient to the end user, since of course in those particular cases any P is notionally a valid underlying concrete type where a function expects an argument or returns a value of opaque type some P.

Indeed, one might argue that in those cases specifically the distinction shades closer to being a compiler implementation detail that the user shouldn't have to worry about unless they want to. It's a very "Swifty" thing to apply a progressive disclosure approach in such scenarios; for example, we envision that users shouldn't be required to deal with ownership operators if they're using copyable types unless they want to fine-tune their code for performance.

Therefore, if we decide not to go in the direction of this pitch, an alternative could be to roll back the requirement for explicit any specifically for "self-conforming protocols" (i.e., Obj-C protocols, Error, and marker protocols, of which we have only Sendable). In those cases, bare P would continue to mean any P, with the compiler being free as it now is to optimize provably unnecessary uses to be equivalent to some P.

In the case of your migration example, this would eliminate over half of the required edits.

This would also have the salutary benefit of allowing us to stage in the proposed "flip" in overload resolution rules discussed above in a less invasive way, although this may also be too clever by half:

For "self-conforming protocols," it is arguably the case that existential P (being notionally a valid underlying concrete type) is "more specific" than some P, aligning with present-day overload resolution rules. We could therefore say that only any P when spelled out as such is regarded in Swift 6 as being "less specific" than some P, because the former explicitly specifies an existential box, leaving any continued valid uses of bare P as "more specific."

9 Likes

It's true many of the errors trying to compile Alamofire were where any had been elided to some for closure arguments of callbacks such as the following:

/Volumes/Data2/some/Alamofire/Source/RequestInterceptor.swift:121:71: error: 'some' cannot appear in parameter position in parameter type '(Result<URLRequest, Error>) -> Void'
    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {

Is there a conceptual reason why closures cannot receive an opaque arguments or is that just a current limitation of the compiler? From a low level point of view it feels like it should be possible (being just a change in calling convention) though I don't imagine it would be a small change. It just wan't expressible in the language before now.

Would you please make the arguments explicit, if you have time? Favoring existentials can only prevent optimizations (both user-written optimizations for specific types, and compiler optimizations with specialization, guided by user annotations and internal heuristics). It seems to me that favoring existentials goes against the general spirit of this pitch which is to reward the use of generics with more efficient runtime behavior.

The presence of two overloads, one that targets existentials, one that targets generics, may be the consequence of api ergonomics (the author intends to accept both generics and existentials, for the convenience of the client, but also in order to support literals such as 123 or nil). Maybe automatic opening of existentials have made this technique a thing of the past (an assertion that needs to be checked - oops it is checked below and well it does not hold) but there is still some code like this in the wild.

EDIT: some examples

These tests: 1, 2, 3 would not compile without explicit overloads for existentials.

Note:

  1. The use of the non-public @_disfavoredOverload in order to reverse the overload resolution rule.
  2. Automatic opening of existentials does not make these tests compile when I remove the explicit support for existentials and rely on generics only.
3 Likes

It would be nice if we could enhance the proposal with some examples of what other languages do with their protocol/interfaces. I believe c# interfaces (Java’s as well?) work like swift’s some protocol.

C# and Java I believe just have generics. No existential types. Swift is a pretty cutting-edge language compared to most others. It would be hard to compare to other languages.