A struct v class ABI considerations

Considering a type-safe alternative to the API discussed in another thread:

extension NSAttributedString {
    init(url: URL, options: [NSAttributedString.DocumentReadingOptionKey : Any], ...)

brought me to these options:

  1. a non frozen struct:
/*non-frozen*/ public struct DocumentReadingOptions {
    public let baseURL: URL?
    public let documentType: NSAttributedString.DocumentType?
    public let timeout: TimeInterval?
    ...
    
    public init(baseURL: URL? = nil, documentType: NSAttributedString.DocumentType? = nil, timeout: TimeInterval? = nil) {
        ...
    }
}
  1. Ditto but a class (possibly final)

  2. Either (1) or (2) but with a private init and a static function to make instances:

    static func make(baseURL: URL? = nil, documentType: NSAttributedString.DocumentType? = nil, timeout: TimeInterval? = nil) -> DocumentReadingOptions {
        ...
    }

Which of these options is better from ABI stability point of view, or does it not matter which option to choose? Let's say this type lives in one of the Apple frameworks and Apple wants to add new fields to it every now and then, if needed grouping the changes to happen on major (and minor?) OS version bumps only.

Classes have some features which do not apply to regular structs (subclasses, superclasses, deinitialisers, etc). If you don't intend to make use of those, there's no meaningful difference (in terms of evolution) between a non-frozen, non-open class and a non-frozen struct.

See: swift/docs/LibraryEvolution.rst at main · apple/swift · GitHub

I don't think having a public initialiser limits evolution either, so long as it isn't @inlinable.

1 Like

Although non-frozen structs can't have @inlinable inits anyway :slight_smile:

1 Like

In the current form the API is quite unsafe:

    init(url: URL, options: [NSAttributedString.DocumentReadingOptionKey : Any], ...)

for example it would crash on duplicate dictionary keys or non-matching types (e.g. passing a font instead of a colour).

What would be the drawbacks to use a safer version of such API's?

    init(url: URL, options: DocumentReadingOptions, ...)

where DocumentReadingOptions is a struct or a class (as per 1...3 above).

Hidden alternative based on Set

I also considered another alternative:

enum DocumentReadingOption: Hashable {
    case baseURL(URL)
    case documentType(NSAttributedString.DocumentType)
    case timeout(TimeInterval)
}
typealias DocumentReadingOptions = Set<DocumentReadingOption>

it solves two issues (type safety and non crashing on duplicate options (they are ignored), although it has serious drawbacks (IRT using more then one option with the same discriminator, and making the API side implementation more complicated).

1 Like

Please note that this question is not about a particular API (as there are many API's that choose to use the [String: Any] parameters approach to pass it's various parameters.

Let me bump this question by trying to answer it myself:

  1. It would be too time consuming to create many special structs for parameters, especially if the API is already established and has hundreds of places where [String: Any] parameters are getting passed. We can't change the current API and as it wasn't done right away when exposing those previously obj-c API's to Swift now we'd need to create new Swift API's, potentially with different names, that take parameters in their struct form. That will double the number of such API's. The framework provider (e.g. Apple) would have to allocate more resources for that to happen and the actual win doesn't look too big overall (even though the current way of passing [String: Any] parameters in unsafe).

  2. It would be impossible (?) or hard (?) to add new fields to that parameters struct without bumping the major (?) minor (?) bug-fix (?) version of the framework and bumping framework version is undesirable (even though the current way of passing [String: Any] parameters in unsafe).

(1) is understandable. I'm more interested in the answer (2). Is that correct answer, or how do you change that answer to make it correct?

There would be no drawbacks to adding a safer API, but designing an improved API is time consuming as you state yourself. You want to carefully consider new API as once they are added, they can "never" be removed. This also means that the existing unsafe API would have to stay, but could be deprecated. Likewise the new safe API would likely be a wrapper over the existing unsafe API, not a complete reimplementation.

I think each iOS release increase the major, minor or patch version of the Foundation framework that ship with that version of the system, bumping the framework version is not a problem, the problem is that you want the Foundation framework to remain API and ABI stable.

API Stable
Remaining API stable is not particular hard, new functions etc. can be added to all structs, classes and enums, but you can't remove function or method calls, or change the return type of a function etc.

If the foundation framework were to break API stability in release X.Y.Z, then all existing code would have to be updated to work with release X.Y.Z, they would have to make extra work to remain backwards compatible with previous releases, and they would have to be recompiled.

I believe Foundation does break API every once in a while, but not often. The only case I can think of is when they remove API that have been deprecated for years and years. The Swift standard library have also broken API in earlier releases.

ABI Stable
Remaining ABI stable places some hard limits on what you can change in the frameworks code. Adding a new instance variables to a struct is ABI breaking, even if it is private. Using a class gives you more freedom to make changes, but you could likely still get into issues if part of the code has been marked as @inlinable. Swift enums support adding new cases in an ABI stable manner, but that is an API breakage I think.

Having Foundation framework break ABI would be highly problematic for consumers of the framework, because it ships with the operation system. If you write software that uses Foundation (or any other code that ship with the OS like the Swift Standard Library) and it had an ABI breakage in iOS release X.Y.Z then you would have to decide to either write your software to work on iOS version X.Y.Z and forward, or all versions before release X.Y.Z. You can't write software that can run on both.

I don't think Foundation have ever had an ABI breakage, and it likely never will. This is also the reason that (almost?) all structs in Foundation and the Swift standard libary is @frozen to highlight that making ABI breaking changes is not an option.

As a final disclosure, take everything above with a grain of salt. I am not an expert on API and ABI stability, some of this might be incorrect or slightly misleading.

1 Like

(just to be clear, this is not true in Swift in general, but it is true in C, and many of these APIs are still designed to be used with both Swift and Objective-C)

3 Likes

Non-frozen structs can have inlinable convenience inits, as long as the init delegates to another public init. Designated inits cannot be inlinable because they expose the stored property layout.

1 Like

this compiled for me, on 5.9:

public
struct S
{
    @usableFromInline
    let x:[Int]

    @inlinable public
    init()
    {
        self.x = []
    }
}

i am not sure why this is allowed, as i understand this could leave the structure in a partially uninitialized state if it gains a second field.

because you're not compiling with -enable-library-evolution

1 Like

ah yep, that would do it. sometimes it slips my mind that there are folks out there using @frozen for what it was originally intended to do…

1 Like

And what are you using @frozen for? ;-)

Without -enable-library-evolution, the only thing @frozen achieves when applied to a struct declaration is enforce the @inlinable restrictions on the initial value expressions of your stored properties.

primarily to work around problems with SPM and cross-module optimization, and secondarily so that i don’t get exclusivity errors when mutating struct properties.