Making subclassing more flexible

I'm starting this thread after an arduous fight with subclassing in Swift that's left me saddened by the current mental model the language holds, which in my opinion manages to be both limiting and overcomplicated at the same time.

Motivation

I'm writing up a new Swift package made out of a few modules. One of the modules we'll call Base, and it's where our Base class resides:

// Module: Base

open class Base {
    package func printMessage() {
        print("Hello!")
    }
}

My other module Foo that depends on Base would like to override printMessage in its subclass, however this is not currently possible:

// Module: Foo

import Base

public class Foo: Base {
    package override func printMessage() { // 🛑 error: Overriding non-open instance method outside of its defining module
        print("Hello, I'm Foo!")
    }
}

So then I'm forced to mark printMessage as open, which I don't want to do because printMessage is supposed to stay hidden from end users of my library:

// Module: Base

open class Base {
    open func printMessage() {
        print("Hello!")
    }
}

The only other realistic workaround is using @_spi, which is exactly what the introduction of package was trying to improve upon:

// Module: Base

open class Base {
    @_spi(package) open func printMessage() {
        print("Hello!")
    }
}
// Module: Foo

@_spi(package) import Base

public class Foo: Base {
    @_spi(package) public override func printMessage() {
        print("Hello, I'm Foo!")
    }
}

Proposed Solution

I believe open should not be an access control level, but a keyword in its own right that can apply to any class entity along with an actual access control level. For example:

// Module: Base

public open class Base {
    package open func printMessage() {
        print("Hello!")
    }
}
// Module: Foo

import Base

public class Foo: Base {
    package override func printMessage() { âś…
        print("Hello, I'm Foo!")
    }
}

One nice side effect of this is that final isn't even needed in the language anymore, as all classes become actually un-subclassable by default, unless explicitly stated via open:

class Base {}

class Foo: Base {} // 🛑 error: Inheritance from non-open class 'Base'
open class Base {} // subclassable, but still implicitly internal

class Foo: Base {} // âś…

This new behavior would obviously bring on a fair amount of churn, so realistically it could only become default behavior as part of Swift 6, with a feature flag for incremental adoption.

Would love to know everyone's thoughts on this.

PS: looking through the notes on the original pitch for open, I see this idea was considered but discarded at the time. This was before we had additional things in the language like a package ACL. It feels to me it might be time to revisit the question of how much the current behavior is serving us as opposed to hindering the developer experience.

Wouldn't it be a smaller change that satisfies the stated problem to make package members overridable within the package, or to add something like package(override) Ă  la private(set)?

3 Likes

There might be a performance cost to changing things in this way, as methods (and property accessors) that today are inlinable or at least deterministic would be forced to become virtual (in C++ parlance).

Otherwise, in principle I like the idea, but then I like Objective-C and miss its flexibility and power - many people using Swift do not like Objective-C and do not want to see its dynamism in Swift.

2 Likes

It would, but it feels like yet another bandaid. My proposal assumes there's a chance to fix it for good as part of Swift 6.

Re-designing open in a source-breaking way wouldn’t be in scope for Swift 6.

1 Like

mind your telephone grip

package is not a replacement for @_spi and proponents of the keyword didn’t conceive of it as such when it was first being pitched.

i think you are not getting as much out of your @_spi attributes as you could because you are creating a project-wide namespace @_spi(package) which is basically what the package keyword was meant to prevent. the s in @_spi stands for system programming interface, you should have one for each system in your project, like @_spi(messages) for printMessage.

1 Like

While it’s not totally forbidden to break source compatibility in a major language version, there are still balancing tests to determine if a break is worth the cost, and I don’t think this would clear that hurdle. Consider this bit from January’s Design Priorities for the Swift 6 Language Mode thread:

Changing open merely because you don’t like the current design would be a “sweeping change without purpose”. On the other hand, allowing subclassing of package declarations is “achievable while maintaining source compatibility” by choosing an alternate spelling, like open(package), that doesn’t affect any existing meanings. So either way, making open orthogonal to other access levels is probably not a viable proposal.

1 Like

The "S" in "SPI" stands for "system programming interface", as in APIs that are internal to the operating system. Apple and the Apple community have informally used that name for decades (it's not formal because external developers aren't supposed to use them).

I'm not saying what the right way to use @_spi is, but your etymology is definitely wrong.

1 Like

the attribute and the documentation page have been de facto public for a long time. the review for the package keyword really demonstrated how many teams outside of Apple not only heard of, but were extensively leaning on @_spi. in one ecosystem it might have taken on one connotation, but it is completely wrong to generalize that to the entire community of external developers.

I was involved in the design of @_spi and argued for the addition of SPI groups. We added SPI groups in an attempt to achieve two goals that were in tension with one another:

  1. Being able to figure out, with a simple textual search of source code, which client projects might be using an SPI you plan to remove or change.
  2. Not forcing clients to explicitly name every single SPI declaration they want to import.

There are a lot of different ways to group your SPIs; "by subsystem" is one of them, but "by intended client" or "by feature” are equally valid. Personally, I think @_spi(package) is a perfectly good “intended client" spelling for an SPI group created as a workaround for the limitations of the package keyword.

3 Likes

On the original topic, I think @xwu's approach to make package members overridable within the package by default makes the most sense and wouldn't be too controversial. If that's a performance problem for any particular API, the author can almost always explicitly add final—since the API is package, it's not a breaking change to remove it in the future.

There's one case where you can't add final, and that's when the API is supposed to be overridden within the module but not within the package…but that seems like enough of an edge case that (to me) it's not worth worrying about.

(final isn't in the language because of open; it's there because for ABI-stable libraries, you sometimes want to promise that even future versions of the library will never override a method. That's pretty rare, though. It also comes up in our current world where cross-module optimization is still expensive, but that's even less common unless you're trying to compose with @inlinable.)

7 Likes

Hi @davdroman – would you mind retitling this thread to something more descriptive of what you're proposing? The current one is a little unnecessarily incendiary.

2 Likes