4
$\begingroup$

I've mentioned before that Swift evolution is somewhat limited by the fact that it is now ABI-stable. One recent example that caught my attention was PermutableCollection.

Currently, the Collection protocols create an inheritance tree that looks like this:

                     Sequence
                    /        \
              Collection  LazySequenceProtocol
             /    |     \
MutableCollection | RangeReplaceableCollection
        BidirectionalCollection 
                  |
        RandomAccessCollection

The proposal is simple: add a new protocol PermutableCollection in this tree between Collection and MutableCollection. PermutableCollection doesn't require anything that MutableCollection doesn't already have, so it wouldn't break existing code.

The merits/drawbacks of the proposal aside, it cannot be implemented. Adding a new type to the middle of the inheritance tree is an ABI-breaking change in Swift, even if it doesn't break source in any way. There is some discussion on the forum thread about how it could be retrofitted into Swift's ABI, but my question is slightly different: How could the ABI have been designed that this is not an ABI break?

$\endgroup$
3
  • $\begingroup$ Not really an answer, but some languages avoid this and some other problems by renouncing inheritance at all. Rust is the first example that comes to mind. There a corresponding change would've resulted in an introduction of a new trait and a modification of two inter-trait dependencies, which would've still been ABI-compatible (I believe) $\endgroup$
    – abel1502
    Commented Jun 30, 2023 at 16:15
  • 1
    $\begingroup$ @abel1502 Swift "protocols" are far more similar to Rust traits than to classes or Java interfaces; the name "protocol" is just historical baggage. The way Swift currently implements it is that for some type T, the vtable for T:MutableCollection contains a pointer to the vtable for T:Collection, so by inserting something new into the tree that pointer now points to the vtable for T:PermutableCollection and code that isn't prepared for that breaks. $\endgroup$
    – Bbrk24
    Commented Jun 30, 2023 at 16:35
  • $\begingroup$ @Bbrk24 The end-result of that is you're trading a predictable compile-time error for a nasty runtime surprise. I'm not a Swiftie myself, so I'm genuinely curious if Swift users think that's a good thing or a bad thing; and/or if the new possibilities for flexibility that it introduces are worth the risks. I note this almost feels like Swift's answer to ObjectiveC's Swizzling. $\endgroup$
    – Dai
    Commented Mar 23 at 2:36

1 Answer 1

1
$\begingroup$

I have recently had an idea. For the purpose of this explanation, let's consider a simpler set of protocols, and a type that adopts both:

protocol P {
  func a()
}

protocol Q: P {
  func b()
  func c()
}

struct S: Q {
  func a() {}
  func b() {}
  func c() {}
}

Then, the vtables could look something like this:

'vtable for S:P':
  - &S.a
'vtable for S:Q':
  - &'vtable for S:P'
  - &S.b
  - &S.c

In our hypothetical ABI, the functions are alphabetized in the vtable, and the first element is a pointer to the supertype vtable, if applicable. Because the more specific vtable includes the more generic one, you can have code like this:

func foo(_ x: any P) {}

func bar(_ x: any Q) {
  foo(x) // needs to grab the vtable for (typeof x):P from the vtable for (typeof x):Q
}

Now, say we restructure the protocols and add a new type, without recompiling the existing struct:

protocol P {
  func a()
}

protocol R: P {
  func c()
}

protocol Q: @introducedBefore R { // <- Attribute that does not exist in actual Swift
  func b()
}

struct S2: Q {
  func a() {}
  func c() {}
  func b() {}
}

This @introducedBefore attribute is key. It tells the compiler to generate vtables for Q as if R were not there:

'vtable for S2:P':
  - &S2.a
'vtable for S2:R':
  - &'vtable for S:P'
  - &S2.c
'vtable for S2:Q':
  - &'vtable for S:P'
  - &S2.b
  - &S2.c

Code that dynamically upcasts any Q to any R must construct the vtable for (typeof x):R at runtime, since there is no guarantee that one exists:

func foo(_ x: any R) {}

func bar(_ x: any Q) {
  // In source, this looks like:
  foo(x)
  // At runtime, this does:
  let x_as_R: any R = (value: x.value, vtable: (vtable_for_P: x.vtable.vtable_for_P, c: x.vtable.c))
  foo(x_as_R)
}

Depending on the size of the vtable, this may not actually be noticeably slower than if Q was not introducedBefore R -- it has to pull out the supertype vtable either way.


I didn't intend to invent a solution backwards-compatible with Swift's current ABI, but this may actually work for that, too.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .