Skip to content

Instantly share code, notes, and snippets.

@JensAyton
Last active October 26, 2021 10:37
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JensAyton/5d65d0c4803ae4a377fd1e38bc1f4623 to your computer and use it in GitHub Desktop.
Save JensAyton/5d65d0c4803ae4a377fd1e38bc1f4623 to your computer and use it in GitHub Desktop.
Pre-pitch: Capturing cases as closures

Pre-pitch: Capturing cases as closures

Ever since key paths were introduced, giving us a tool to abstract over members of structs and enums, I have felt that there should be an equivalent tool for enums – particularly enums with associated values. I’ve been assuming this would take a similar form to key paths, but another approach recently occurred to me.

Let’s start with a motivating example: event handling. If you used Swift for Mac or Windows application development in the 90s, your code would have contained something like this:

// AppController.swift

func handleEvent(_ event: Event) {
    switch event {
    case mouseDown(let button):
        handleMouseDown(button)
    case keyDown(let keyCode, let modifierMask):
        handleKeyDown(keyCode, modifierMask)
    case appGainedFocus:
        handleAppGainedFocus()
    default:
        break
    }
}

This approach gets unpleasant in large code bases because you need a centralised entity that knows, in some sense, about every part of the application that handles events.

If instead you happened to be developing for NextStep with nice Swift bindings, your event handlers would just be override methods on appropriate classes. Still, there is a tendency for this approach to lead to singletons or multi-function controllers with too many responsibilities; I certainly feel this way on iOS today.

In a world of scalable! enterprise! decoupled! dependency-injected! software components, one might prefer to have an event router that knows that event handlers exist, but has no opinion on how they are organized. With hypothetical key path-like syntax, it might look like this:

eventRouter.addHandler(for: \.keyDown) { keyCode, modifierMask in
    // ...
}

The biggest stumbling block here, spelling choices aside, is how to declare addHandler in a way that expresses that the arguments to the closure are the same as the associated values of the \.keyDown enumerant. There are questions here which relate somewhat to the thread Extract Payload for enum cases having associated value, and discussions about protocols that unify callables with functions.

A challenger appears

However, consider how we might implement such a thing today:

extension EventRouter {
    func addHandler<Payload>(`for` matcher: @escaping (Event) -> Payload?, `do` handler: @escaping (Payload) -> Void) {
        // Fun type erasure shenanigans go here
    }
}

eventRouter.addHandler(
    for: { event in
        if case .keyDown(let keyCode, let modifierMask) = event {
            return (keyCode, modifierMask)
        } else {
            return nil
        }
    }
    do: { arguments in
        let keyCode, modifierMask = arguments
        // ...
    }
)

This works fine, but the if case ... return ... else return nil construct makes it unpleasant to work with, and this problem arises essentially whenever you want do anything with enums-with-associated-values other than switch on them.

What if, by analogy to SE-0249 Key Path Expressions as Functions, we allow case patterns to be used in place of functions? The previous example could then be sugared as:

eventRouter.addHandler(
    for: case .keyDown(let keyCode, let modifierMask),
    do: { arguments in
        let keyCode, modifierMask = arguments
        // ...
    }
)

Or indeed as:

eventRouter.addHandler(for: case .keyDown(let keyCode, let modifierMask)) { keyCode, modifierMask in
    // ...
}

The idea here is that any case pattern can be passed in place of a function with one argument and the return type being an optional tuple of the values bound in lets within the case. (If there is a single let, the result is an optional of the single bound type.)

As an extension, we might allow a form with implicit capture of all associated types:

eventRouter.addHandler(for: case let .keyDown) { arguments in
    // same as above
}

Empty binding lists

In order for generic code like this addHandler to work as expected, cases with no lets should desugar to (T) -> Void?:

eventRouter.addHandler(for: case .appGainedFocus) {
    // No arguments, this closure is () -> Void
} 

However, it would probably be desirable to also support (T) -> Bool. In that case, the Bool overload should be preferred if both are available.

Disadvantages

Restrictiveness (or absence thereof)

With this form of case abstraction, prescriptive API designers can’t restrict users to only using cases for matching, and must accept arbitrary matching closures. This could be remedied with an attribute, but I suspect I’d be in a minority wanting that.

Performance

From a performance standpoint, evaluating every matching closure (or on average half of them, depending on your precise semantics) isn’t ideal. For large enums, storing the discriminator in a hash table would be preferable. Swift doesn’t currently expose any tools for doing that, but an “abstracted enum case literal” like the \.keyDown in my first key-path-inspired example could perhaps address this by being hashable even when the underlying enum has associated values.

Achieving this hash-based lookup in present-day Swift effectively requires a second enum with no associated values. Generating that (manually or automatically) may be worthwhile in special cases but isn’t generally attractive, especially for generic abstractions (e.g. if the event router in the example is generic over event types).

I don’t think that performance is an argument against sugaring the generally-least-bad existing option, unless we actively want to restrict people to using centralized switch statements for all dealings with enums.

Alternatives Considered

Do nothing

Always an option.

Restrict this to @autoclosure arguments

This was my original thought, since it might feel “too magical” for normal closure arguments. However, SE-0249 Key Path Expressions as Functions already kicked down that door.

Add a new attribute for this kind of closure transform

A reasonable approach, but not consistent with the current trajectory of Swift. In particular, the core team rejected the use of a marker attribute in SE-0253 Callable values of user-defined nominal types despite community objections.

Something that feels like keypaths

I’ve tried to spec this out before and not come up with anything very good. The basic idea is that \Event.keyDown would give you an EnumKeyPath (or similar), which is a hashable abstract representation of the discriminator of the enum, and has methods to 1) test if an enum value matches it and 2) extract the associated types.

There might also need to be special declaration syntax to say that a closure takes arguments matching the associated values of an EnumKeyPath. On the other hand, I punted on that in my proposal, so I guess I don’t really see it as a hard requirement any longer.

extension EventRouter {
    func addHandler<Payload>(`for` enumerant: EnumKeyPath<Event, Payload>, `do` handler: @escaping (Payload) -> Void)
}

By analogy to SE-0249 one would expect to be able to pass an EnumKeyPath<Event, Payload> literal where (Enum) -> Payload? is expected ((Enum) -> Payload would be less useful for enums).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment