Without using an associated type, can you restrict a variable in a protocol to only RawRepresentables where the raw type is String?

543 Views Asked by At

I have a case where I am trying to define a function that takes in an array of objects with the requirement that each object must define a string-based enum called 'Commands'.

Here's an example of how you would do it if you were using an associated type:

protocol CommandSetProtocol {

    associatedtype Command : RawRepresentable where Command.RawValue == String

    var commands:[Command] { get }
}

class FooCommands : CommandSetProtocol {

    enum Command : String {
        case commandA = "Command A"
        case commandB = "Command B"
    }

    let commands = [
        Command.commandA,
        Command.commandB
    ]
}

class LaaCommands : CommandSetProtocol {

    enum Command : String {
        case commandC = "Command C"
        case commandD = "Command D"
    }

    let commands = [
        Command.commandC,
        Command.commandD
    ]
}

The problem is you can't do this because of that associated type:

var commandSets:[CommandSetProtocol.Type] = [
    FooCommands.self,
    LaaCommands.self
]

Of note: I'm trying to stop someone form doing this, which should fail compilation because the Raw type is not a string.

class BadCommands : CommandSetProtocol {

    enum Command : Int {
        case commandE = 1
        case commandF = 2
    }

    let commands = [
        Command.commandE,
        Command.commandF
    ]
}

How can this (or similar) be achieved?

2

There are 2 best solutions below

2
matt On

Eliminating all the red herrings in the question, you are really just pointing out the well-known fact that this sort of thing is legal:

protocol P {}
class A:P {}
class B:P {}

let arr : [P] = [A(), B()]

... but this sort of thing is not:

protocol P {associatedtype Assoc}
class A:P {typealias Assoc=String}
class B:P {typealias Assoc=String}

let arr : [P] = [A(), B()]

The problem is that you hit the "can only be used as a generic constraint" wall. That wall is due to be torn down in a future version of Swift, but until then, the way to make that array is to use type erasure.

0
Mark A. Donohoe On

Ok, I figured out how to get what I needed.

As you know, the issue is I needed to restrict the type of the RawRepresentable, which requires a generic/associated type, but if you use that, then you can't define a variable as an array of that type.

But then I asked myself why did I need that. The answer is because I'm using that RawRepresentable:String to build up another collection of CommandDefinition objects, and it's that value that I'm really interested in. So, the solution was to use a second level of protocols with that second level having the associated type to satisfy the requirements of the first level (base) protocol which can't have them.

Here's the above re-written, with the missing pieces of the puzzle added.

First, the reusable framework that can be added to any extension project as-is. Its comprised of the CommandSetBase, CommandSet, ExtensionBase and an extension on CommandSet:

typealias CommandDefinition = [XCSourceEditorCommandDefinitionKey: Any]

protocol CommandSetBase : XCSourceEditorCommand {

    static var commandDefinitions : [CommandDefinition] { get }
}

protocol CommandSet : CommandSetBase {

    associatedtype Command : RawRepresentable where Command.RawValue == String

    static var commands:[Command] { get }
}

class ExtensionBase : NSObject, XCSourceEditorExtension {

    var commandSets:[CommandSetBase.Type]{
        return []
    }

    final var commandDefinitions: [CommandDefinition] {
        return commandSets.flatMap{ commandSet in commandSet.commandDefinitions }
    }
}

Here's the extension for CommandSet that uses the CommandSet-defined 'commands' associated type to satisfy the CommandSetBase requirement of the commandDefinitions (this was the missing piece):

extension CommandSet {

    static var commandDefinitions:[CommandDefinition] {

        return commands.map({

            command in

            return [
                XCSourceEditorCommandDefinitionKey.classNameKey  : String(reflecting:self),
                XCSourceEditorCommandDefinitionKey.identifierKey : String(describing:command),
                XCSourceEditorCommandDefinitionKey.nameKey       : command.rawValue
            ]
        })
    }
}

And here's the app-specific implementation of the command sets and the extension that uses them.

First, the extension itself...

class Extension : ExtensionBase {

    override var commandSets:[CommandSetBase.Type]{

        return [
            NavigationCommands.self,
            SelectionCommands.self
        ]
    }

    func extensionDidFinishLaunching() {

    }
}

Now the Selection commands:

class SelectionCommands: NSObject, CommandSet {

    enum Command : String {
        case align            = "Align"
        case alignWithOptions = "Align with options..."
    }

    static let commands = [
        Command.align,
        Command.alignWithOptions
    ]

    func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void {

        print("You executed the Selection command \(invocation.commandIdentifier)")

        completionHandler(nil)
    }
}

And lastly, the Navigation commands:

class NavigationCommands : NSObject, CommandSet {

    enum Command : String {
        case jumpTo = "Jump to..."
        case goBack = "Go back"
    }

    static let commands = [
        Command.jumpTo,
        Command.goBack
    ]

    func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void {

        print("You executed the Navigation command \(invocation.commandIdentifier)")

        completionHandler(nil)
    }
}

And here's the result...

enter image description here

If Swift ever allows you to enumerate the cases of an enum, then I could eliminate the seemingly-redundant 'static let commands' in the CommandSets above.