Allow stored properties in extensions

I think that extensions are not for decomposition, but for convenience methods and properties. As splitting the state is clearly the part of decomposition process, I think it is not reasonable to support the bad practice of fake extensions-based decomposition by adding suitable language means for that.
More specifically, stored properties in extensions indicate the low cohesion of the entity.
In the given example, AstronomicalObject should not be really a protocol but a superclass, because it is not specifying any common behavior, but is specifying the "contents" of the entity. Terraform method is quite alarming here already because majority of astronomical objects completely not applicable for that (by the way, that is a classic problem of the inheritance).
So you should either declare this "fundamental" protocol in the declaration of the class (as preferred with SwiftUI views for example) or rethink your protocol as there would clearly be dozens of other properties of astronomical objects and your protocol (or superclass) would grow endlessly.

2 Likes

I’m strongly against this. Being able to look at the main definition of a type and know that it contains all stored properties of that type is a feature, not a bug. And it isn’t just important for low-level programming like @Ben_Cohen mentions—it’s about understanding what states the type can be in. That’s equally important for business logic as it is for low-level programming.

9 Likes

I'm surprised to see so many reactions against this idea. I've frequently wished for the ability to add stored properties in extensions, as a way to incrementally make complex code simpler.

A lot of the arguments against this seem to center around the idea that it's important to be able to see all the states an object is in. That would make sense if the current limitation forced all stored properties to be declared together… but it does not. You can already write code that scatters property declarations and functions, so long as you don't use extensions. You can't assume that the list of vars at the top constitutes the full state of the type; you already have to search the rest of the type definition to make sure you haven't missed any. Tooling could make this easier by extracting all the variable definitions for you, but it could do that even if they were declared in extensions too.

You can, of course, do what @Ben_Cohen suggests and close the type definition right after you've declared all the variables, and then implement all the functions in extensions. But that can have additional consequences; for example, functions declared in a class extension cannot override functions declared in a superclass (and cannot be overridden by a subclass), so if a function is an override (or is intended to be overridden), it must be in the primary type declaration. That means that you can't always keep your primary type declaration short and sweet, with all the variable declarations grouped together and all the functions elsewhere.

It would be great if we could all write code populated entirely with pure value types and small, self-contained type definitions, but that's not always feasible. If you're implementing a UIViewController subclass, for example, you have no hope of understanding all the states your type can be in, because UIViewController (at least as of iOS 10) had over 150 stored properties, in addition to whatever your own subclass was going to add.

So yeah, I'd love to be able to incrementally improve this code by moving things into extensions. Right now, that's not possible, but I'd love if it were. I agree that it's often more useful for classes than for structs, but I don't think it would make sense to restrict it only to classes. This feature would be welcome to me. It would be even more welcome if it were possible to override a superclass function from within an extension, so long as it was also in the same file as the type declaration, but I'm happy to accept incremental progress.

14 Likes

that said, i wouldn’t exactly hold up UIViewController as an example of something to emulate

I agree, but there are a lot of Swift users out there with a lot of existing UIViewController subclasses out there. Should we ignore the large base of existing users we have, in hopes for other ones who won't use such messy subclasses?

1 Like

I'm pretty sympathetic to concerns around keeping code well-organized, since poorly organized code is more difficult to understand. I also think that this is contextual, and can go both ways.

For example, not all functionality in a type is necessarily "core" to its definition. A desirable protocol conformance can include required members that are more incidental, and specifically related to the protocol rather than a core functionality of the type. Being required to define these properties in the main type definition, rather than in the related extension, can make the code less well-organized.

As an example that's a bit less contrived, take this pattern for applying a piece of state to a object:

protocol SetState {
  associatedtype State
  func update(to state: State)
}

As an optimization to avoid redundant work, we may wish to avoid calling this method repeatedly with the same value. We could provide this behavior by default, as long as we had state to track this in:

protocol SetState {
  associatedtype State: Equatable
  func update(to state: State)

  var previouslyAppliedState: State? { get set }
}

extension SetState {
  // Updates the object to the given state, if it isn't already in that state
  func updateIfNecessary(to state: State) {
    if previouslyAppliedState != state {
      previouslyAppliedState = state
      update(to: state)
    }
  }
}

This previouslyAppliedState stored property requirement is mostly an incidental implementation detail of the protocol, and not something that I would consider "core" to the functionality of any type that implements the protocol. In fact, as a consumer of the type, I don't even really need to know or care that this stored property exists.

In this case it seems much better to define the stored property in the same extension as the other properties:

class MyComponent {
  ... "core" stored properties

  ... main methods
}

extension MyComponent: SetState {
  var previouslyAppliedState: ComponentState?

  func update(to: ComponentState) {
    ...
  }
}

rather than define it in the main body of the type with all of the other stored properties:

class MyComponent {
  ... "core" stored properties

  // Implements `SetState` requirement
  var previouslyAppliedState: ComponentState?

  ... main methods
}

extension MyComponent: SetState {
  func update(to: ComponentState) {
    ...
  }
}
1 Like

Even if those extensions were in different files.. interesting angle :thinking:. Of course we don't use IDE's always (sometimes it's reviewing pull requests with some web based tool or even a terminal, etc).

Quite right. Another example would be var properties with didSet. To keep those cleaner you can split them up, but that's an additional complexity to worry about:

struct Foo {
    var property: Int {
        didSet { propertyChanged() }
    }
    ...
}

extension Foo {
    func propertyChanged() {
        ... many lines of code here...
    }
}
1 Like

I wonder if there’s a middle ground where we make it clear that the “what is this thing” properties must continue to live in the main definition while “auxiliary,” non-essential pieces of state can live in same-file extensions… we could, for example, keep it so that synthesis of Equatable et al only considers stored properties in the main type definition. Of course, that would mean that in the following example:

struct S {
  var x: Int
}
extension S: Equatable {
  var y: Int
}

Only x would be used for Equatable synthesis, which seems not great.

2 Likes

Agreed that this seems a bit too surprising, particularly in the example you shared. I think we would want to avoid having different "classifications" of stored properties. One problem with that is that moving code between the main definition and extension would continue compiling but silently change the behavior of the code.

2 Likes

I would agree that we definitely don’t want that.

If it is considered wise to allow stored properties to be broken up into extensions, then one would expect them all to be considered for Equatable; if it is not wise, then I would expect the whole proposal to be rejected.

I don’t think the answer to the complexity of having stored properties in extensions is to add further complexity by making them a “second tier” of stored properties with different behavior in certain circumstances.

5 Likes

Perhaps a genuinely useful distinction here is whether or not the property needs to appear in the struct's memberwise initializer. I would expect "core" properties of a type to appear in the memberwise initializer, but other incidental properties wouldn't necessarily need to be included. This is the case for both of the non-contrived examples I shared above, and may actually be a pretty natural solution for the tension here.

If we only permitted this for default-initialized properties, such that they weren't included the synthesized memberwise initializer, would that also help prevent "misuse" of this feature?

Another question: is the issue this pitch identifies with the existing solution to the conformance problem:

struct Planet {
  let name: String
  let atmosphere: Atmosphere?
  // Implements AstronomicalObject:
  var mass: Double
  let parent: AstronomicalObject?
}

extension Planet: AstronomicalObject {
  func terraform() { /* add an atmosphere */ }
}

that mass and parent must appear in the main Planet definition, or that they don't appear in the extension that declares the AstronomicalObject conformance? IOW, would another resolution be to allow the following?

struct Planet {
  let name: String
  let atmosphere: Atmosphere?
  // Implements AstronomicalObject:
  var mass: Double
  let parent: AstronomicalObject?
}

extension Planet: AstronomicalObject {
  // No-op redeclarations, perhaps must be witnesses for `AstronomicalObject` requirements
  // or referenced by other members in the extension?
  var mass: Double
  let parent: AstronomicalObject?
  func terraform() { /* add an atmosphere */ }
}
3 Likes

I really think same-file extensions already got way more special-case support from the language than they deserve, but there is an option that would actually simplify the rules:
Allow stored properties to be declared in extensions — without restrictions.

There's a price to pay for that flexibility (at least when the type comes from another module), but I don't think that penalty is big enough to completely exclude this alternative from the discussion.

I don’t think it’s possible to add stored properties to types defined in other modules:

3 Likes

I'd say the goal is more to avoid needing to define the requirement in the primary body. If we expand the example to include more protocols:

protocol AstronomicalObject {
  var mass: Double { get }
}

protocol PlanetaryBody {
  var surfaceComposition: Surface { get }
  var atmosphere: Atmosphere? { get }
}

protocol OrbitingBody {
  var parent: AstronomicalObject { get }
  var orbitalPeriod: Double { get }
}

I think defining the properties in the same extension as their conformance is really reasonable and quite clear / well-organized:

struct Planet {
  let name: String
}

extension Planet: AstronomicalObject {
  let mass: Double
}

extension Planet: PlanetaryBody {
  let surfaceComposition: Surface
  let atmosphere: Atmosphere?
}

extension Planet: OrbitingBody {
  let parent: AstronomicalObject
  let orbitalPeriod: Double
}

as opposed to defining them all in the primary declaration:

struct Planet: AstronomicalObject, PlanetaryBody, OrbitingBody {
  let name: String
  // Implements AstronomicalObject:
  let mass: Double
  let surfaceComposition: Surface
  // Implements PlanetaryBody:
  let atmosphere: Atmosphere?
  // Implements OrbitingBody:
  let parent: AstronomicalObject
  let orbitalPeriod: Double
}

Or defining them in both places:

struct Planet {
  let name: String
  // Implements AstronomicalObject:
  let mass: Double
  let surfaceComposition: Surface
  // Implements PlanetaryBody:
  let atmosphere: Atmosphere?
  // Implements OrbitingBody:
  let parent: AstronomicalObject
  let orbitalPeriod: Double
}

extension Planet: AstronomicalObject {
  let mass: Double
}

extension Planet: PlanetaryBody {
  let surfaceComposition: Surface
  let atmosphere: Atmosphere?
}

extension Planet: OrbitingBody {
  let parent: AstronomicalObject
  let orbitalPeriod: Double
}
3 Likes

I think I just have a fundamentally different reaction to this code. I see

struct Planet {
  let name: String
}

and immediately think "oh a Planet is fundamentally just a thing with a name." I guess this is partially just a chicken-and-egg problem, because of course in Swift today that interpretation is correct. Maybe I could learn to not make the assumption that the main definition contains all the state there is, but even if I did manage to learn that I think this change would make tracking down all that state nontrivially more difficult.

6 Likes

This is a good point, and I think if we want to encourage the pattern of "close the type after you declare all the state" (and thereby reject this pitch) we should consider what we'd need to do in order to make that possible. IMO it feels more permissible to, as you suggest, allow overrides from same-file extensions than to allow stored properties in same file extensions.

I'm not sure if it would be feasible to change the language behavior to allow same-file extension methods to be overridable in subclasses, but if it were that also feels 'better' to be than allowing stored properties in extensions.

2 Likes

I have to say that I'm thoroughly confused by this discussion. There is no such thing as an auxiliary property of a product type. Either the property is a pure function of other properties, in which case it can be a computed property, or it isn’t, in which case you need to know that it is a part of the definition of the type to be able to reason correctly about the type. That some product types like UIViewController are already hard to reason about is not argument for making all product types harder to reason about.

9 Likes

I don’t think this is just a chicken-and-egg problem. Several of Swift’s tentpole features are about enabling local reasoning (value semantics, private/fileprivate, etc). "A planet is a thing with a name" isn’t just the natural interpretation because it’s the way the language happens to work—it’s the natural interpretation because it’s the one that fits in a language that goes to great lengths to enable local reasoning. Any other interpretation would be fighting that core design principle.

6 Likes

This is true.

A good point made above, however, is whether the closing brace of a main declaration is the ideal quantum for locality purposes given that there can be arbitrarily many lines of code between the opening and closing brace and separating each stored property from the next, with the only hard bound being that it must all be contained within a single file.

If I understand correctly, the counterpoint is that when one does read up to a closing brace currently, one can know for sure that there are no more stored properties. This is worth something.

Unless there’s a property wrapper, in which case the storage can only be understood by studying the wrapper type. Or unless the type is a simple wrapper around an underlying storage type which holds the actual stored properties, which could be implemented in an entirely different file. And wait until we have macros… So the local reasoning that is guaranteed today upon reaching a closing brace is very much contingent, and it bears assessing how much it’s getting us now versus the expressivity of freer code organization.

4 Likes