Pitch - A New Kind Of Extension

Introduction

In Swift, different types of objects have different available actions, which matches the real world nicely. Toilets can be flushed, lightbulbs cannot. In the real world however I have an additional power that I do not have in Swift, and that I would like to have in Swift. I want the actions available to me on an instance to not only depend on the type of the instance, but also on who I am.

Motivation

Any person at all has access to the ability to square an integer.

extension Int {
    var squared: Int {
        self * self
    }
}

In my super secret club though we have defined a clever secret language in which text can be transformed into an integer, and only members of the club know how to decrypt any given integer back into the text it was supposed to represent.

Ergonomically speaking, when I am writing logic inside of my SuperSecretClubMember type I would prefer to write:

someInteger.decryptedAsText()

than:

decryptAsText(someInteger)

I could separately give my reasons for ergonomically preferring the first over the second, but for now I'll assume it's accepted as a legitimate preference and go on to describe the feature that I'm proposing whose purpose is to allow me this ergonomic preference without the compromises on code quality that are currently unavoidable.

In Swift 5.7, my options are these:

  1. Put the function in an extension on Int, polluting the namespace and leaking the functionality to the whole module:

    extension Int {
        func decryptedAsText () -> String {
            ...
        }
    }
    
  2. Put the function in a fileprivate extension on Int and also force all of my SuperSecretClubMember code into that one file, so that I get to call the function directly on integer values but I don't leak that private functionality to every user of Int in the module:

    fileprivate extension Int {
        func decryptedAsText () -> String {
            ...
        }
    }
    

    Number 2 is often my preferred choice, because the one drawback which is having to put all of the code in one file is usually the least burdensome of the three options.

  3. Put the corresponding function on the SuperSecretClubMember type and give up my preferred ergonomics:

    extension SuperSecretClubMember {
        func decryptAsText (_ integer: Int) -> String {
            ...
        }
    }
    

Now consider one additional aspect of the problem. The decryption algorithm may need to depend on some of the properties of the specific SuperSecretClubMember instance who is doing the decryption. For example, the actual decryption might need to be preformed by a Decrypter object which the SuperSecretClubMember receives on initialization and stores in a property.

In this case, putting the decryption algorithm in an extension on Int becomes significantly less desirable in my opinion because the required values or the caller itself will need to be explicitly passed into every function call:

extension Int {
    func decryptedAsText (caller: SuperSecretClubMember) -> String {
        caller.decrypter.decrypt(self)
    }
}

called like: someInteger.decryptedAsText(caller: self)

Compared to that, I would of course prefer to have it as a function on SuperSecretClubMember:

self.decryptAsText(someInteger)

which of course can often be simply:

decryptAsText(someInteger)

Proposed Solution

I propose adding a new top-level declaration, provisionally named callsite, which functions similarly to extension, but which creates a scope in which the calling type and calling instance are exposed as Caller and caller respectively, and whose declarations (including extensions on other types) are available only within the scope specified by the declaration.

In our example we would use it like this:

callsite SuperSecretClubMember {
    extension Int {
        func decryptedAsText () -> String {
            caller.decrypter.decrypt(self)
        // `caller` is available everywhere within this callsite.
        }
    }
}

This code says "if I am a SuperSecretClubMember, I know how to calculate anyInteger.decryptedAsText()".

More Usage

Let's say I have a subsection of my UI called the wallet. Here's a protocol that I might define:

protocol WalletUIComponent: View {
    var sizeOfWalletView: CGSize { get }
    var colorScheme: ColorScheme { get }
}

All of the SwiftUI views that are subcomponents of the wallet view will conform to this protocol so that they can have easy access to wallet specific view styles.

The reason that WalletUIComponent has the property requirements sizeOfWalletView and colorScheme is so that the shared styling logic can dynamically depend on these values. For example, I might define a font whose size depends on the size that the full wallet view is rendered at, or a color that changes based on light/dark mode. All types of extensions are allowed within a callsite declaration, which means we can create static members that are only available in contexts where they make sense:

callsite WalletUIComponent {
    
    extension Color {
        static var signatureGreen: Self {
            if caller.colorScheme == .dark { return Color(...) }
            else { return Color(...) } 
        }
    }

    extension Font {
        static var sectionTitle: Self {
            .system(size: caller.sizeOfWalletView.height * 0.1, weight: .semibold)
        }
    }

    extension String {
        func displayedAsSectionTitle () -> some View {
            Text(self)
                .font(.sectionTitle) 
                .foregroundColor(.signatureGreen)
        }
    }
}

Now I can make a new component for my wallet view in the ideal manner:

struct WalletNavigationBar: WalletUIComponent {

    var sizeOfWalletView: CGSize

    @Environment(\.colorScheme)
    var colorScheme: ColorScheme

    var body: some View {
        Color.white
            .overlay("Wallet".displayedAsSectionTitle())
    }
}

Lastly, we could use static callsite to allow the declarations to be used from static contexts on the given type, and we could use mutating callsite to require the declarations to be used where the caller is mutable.

5 Likes

It seems like there’s two separate goals here:

  • Define extension members without exposing the functionality to the whole module or confining all the clients to the same file.
  • Pass implicit calling environment state to arbitrary method calls.

For the first issue, there is a 4th option that feels like the ‘right’ way to solve this: create a SuperSecretClub library and put all the files that need to know about the decryption scheme in there, then link your original target against the new module. No namespace pollution, no leak of functionality, multiple files, maintaining the preferred ergonomics. Is there some reason this solution wouldn’t work?

As for the second issue, hiding the state that gets passed to these callsite extension doesn’t seem like an obvious ‘win’ to me. Just because the call site requires fewer characters doesn’t mean it’s better.

That said, there’s already precedent for something similar to this in the language: the unofficial enclosingInstance subscript for property wrappers. In a world where that feature has been fully formalized, I could imagine it being extended to arbitrary members, something like:

extension Int {
  func decryptAsText(enclosingInstance: SuperSecretClubMember) { … }
}

Where enclosingInstance is always passed implicitly at the call site. I don’t think this would ultimately result in clearer code, but I do think it’s marginally better to at least have the implicit state appear in the function signature.

4 Likes