Search code examples
iosswiftprotocol-oriented

Building composable objects in Swift with protocols


I'm trying to create a way to build compassable objects in Swift. I feel like I'm almost there with what I have but it's still not 100% correct.

What I'm aiming for is to have a FlowController object that can create our UIViewControllers and then give them any of the dependencies that they need.

What I'd also like to do is make this work as loosely as possible.

I have a small example here that works but is not ideal. I'll explain...

Here are two objects that can be used as components... Wallet and User.

class Wallet {
    func topUp(amount: Int) {
        print("Top up wallet with £\(amount)")
    }
}

class User {
    func sayHello() {
        Print("Hello, world!")
    }
}

We then define a Component enum that has cases for each of these...

enum Component {
    case Wallet
    case User
}

... And a protocol that defines a method requiresComponents that returns an array of Components.

This is where the problem arises. In order for the "factory object" to put the components into a Composable object we need to define the user and wallet properties in the protocol also.

protocol Composable {
    var user: User? {get set}
    var wallet: Wallet? {get set}
    
    func requiresComponents() -> [Component]
}

In an attempt to make these properties "optional" (not Optional) I have defined an extension to the Composable protocol that defines these vars as nil.

extension Composable {
    var user: User? {
        get {return nil}
        set {}
    }
    var wallet: Wallet? {
        get {return nil}
        set {}
    }
}

Now I declare the class that I want to make Composable. As you can see it requires the User component and declares the variable.

class SomeComposableClass: Composable {
    var user: User?
    
    func requiresComponents() -> [Component] {
        return [.User]
    }
}

Now the FlowController that will create these and add the components to them. You can see here that I have had to take the object, create a local var version of it and then return the updated object. I think this is because it doesn't know the type of objects that will be conforming to the protocol so the parameter can't be mutated.

class FlowController {
    func addComponents<T: Composable>(toComposableObject object: T) -> T {
        var localObject = object
        
        for component in object.requiresComponents() {
            switch component {
            case .Wallet:
                localObject.wallet = Wallet()
                print("Wallet")
            case .User:
                localObject.user = User()
                print("User")
            }
        }
        
        return localObject
    }
}

Here I create the objects.

let flowController = FlowController()
let composable = SomeComposableClass()

And here I add the components. In production this would be done all inside the FlowController.

flowController.addComponents(toComposableObject: composable)  // prints "User" when adding the user component
compassable.user?.sayHello()  // prints "Hello, world!"

As you can see, it works here. The user object is added.

However, as you can also see. Because I have declared the vars in the protocol the composable object also has a reference to a wallet component (although it will always be nil).

composable.wallet  // nil

I feel like I'm about 95% of the way there with this but what I'd like to be able to do is improve how the properties are declared. What I'd like is for that last line... composable.wallet to be a compile error.

I could do this by moving the declaration of the properties out of the protocol but then I have the problem of not being able to add the properties to any object that conforms to the Composable protocol.

What would be awesome is for the factory object to be able to add the properties without relying on the declaration. Or even have some sort of guard that says "if this object has a property call user then add the user component to it". Or something like that.

If anyone knows how I could get the other 5% of this working it would be awesome. Like I said, this works, just not in an ideal way.

Thanks :D

Hacky Edit

Hmm... As a quick tacky, horrible, "no-one-should-do-this" edit. I have changed my protocol extension to be like this...

extension Composable {
    var user: User? {
        get {fatalError("Access user")}
        set {fatalError("Set user")}
    }
    var wallet: Wallet? {
        get {fatalError("Access wallet")}
        set {fatalError("Set waller")}
    }
}

Now at least the program will crash if I try to access a variable I have not defined. But it's still not ideal.

Edit after reading Daniel's blog

OK, I think I've done what I wanted. Just not sure that it's exactly Swifty. Although, I also think it might be. Looking for a second opinion :)

So, my components and protocols have become this...

// these are unchanged
class Wallet {
    func topUp(amount: Int) {
        print("Top up wallet with £\(amount)")
    }
}

// each component gets a protocol    
protocol WalletComposing {
    var wallet: Wallet? {get set}
}

class User {
    func sayHello() {
        print("Hello, world!")
    }
}

protocol UserComposing {
    var user: User? {get set}
}

Now the factory method has changed...

// this is the bit I'm unsure about.
// I now have to check for conformance to each protocol
// and add the components accordingly.
// does this look OK?
func addComponents(toComposableObject object: AnyObject) {
    if var localObject = object as? UserComposing {
        localObject.user = User()
        print("User")
    }
    
    if var localObject = object as? WalletComposing {
        localObject.wallet = Wallet()
        print("Wallet")
    }
}

This allows me to do this...

class SomeComposableClass: UserComposing {
    var user: User?
}

class OtherClass: UserComposing, WalletComposing {
    var user: User?
    var wallet: Wallet?
}

let flowController = FlowController()

let composable = SomeComposableClass()
flowController.addComponents(toComposableObject: composable)
composable.user?.sayHello()
composable.wallet?.topUp(amount: 20) // this is now a compile time error which is what I wanted :D

let other = OtherClass()
flowController.addComponents(toComposableObject: other)
other.user?.sayHello()
other.wallet?.topUp(amount: 10)

Solution

  • This seems like a good case for applying the Interface Segregation Principle

    Specifically, rather than having a master Composable protocol, have many smaller protocols like UserComposing and WalletComposing. Then your concrete types that wish to compose those various traits, would just list their "requiredComponents" as protocols they conform to, i.e:

    class FlowController : UserComposing, WalletComposing 
    

    I actually wrote a blog post that talks about this more extensively and gives more detailed examples at http://www.danielhall.io/a-swift-y-approach-to-dependency-injection


    UPDATE:

    Looking at the updated question and sample code, I would only suggest the following refinement:

    Going back to your original design, it might make sense to define a base Composing protocol that requires any conforming class to create storage for composed traits as a dictionary. Something like this:

    protocol Composing : class {
        var traitDictionary:[String:Any] { get, set }
    }
    

    Then, use protocol extensions to add the actual composable trait as a computed property, which reduces the boilerplate of having to create those properties in every conforming class. This way any class can conform to any number of trait protocols without having to declare a specific var for each. Here's a more complete example implementation:

    class FlowController {
        static func userFor(instance:UserComposing) -> User {
            return User()
        }
        static func walletFor(instance:WalletComposing) -> Wallet {
            return Wallet()
        }
    }
    
    protocol Composing : class {
        var traitDictionary:[String:Any] { get, set }
    }
    
    protocol UserComposing : Composing {}
    extension UserComposing {
        var user:User {
            get {
                if let user = traitDictionary["user"] as? User {
                    return user
                }
                else {
                    let user = FlowController.userFor(self)
                    traitDictionary["user"] = user
                    return user
                }
            }
        }
    }
    
    protocol WalletComposing {}
    extension WalletComposing {
        var wallet:Wallet {
            get {
                if let wallet = traitDictionary["wallet"] as? Wallet {
                    return wallet
                }
                else {
                    let wallet = FlowController.walletFor(self)
                    traitDictionary["wallet"] = wallet
                    return wallet
                }
            }
        }
    }
    
    class AbstractComposing {
        var traitDictionary = [String:Any]()
    }
    

    Not only does this get rid of those pesky optionals you have to unwrap everywhere, but it makes the injection of user and wallet implicit and automatic. That means that your classes will already have the right values for those traits even inside their own initializers, no need to explicitly pass each new instance to an instance of FlowController every time.

    For example, your last code snippet would now become simply:

    class SomeComposableClass: AbstractComposing, UserComposing {} // no need to declare var anymore
    
    class OtherClass: AbstractComposing, UserComposing, WalletComposing {} //no vars here either!
    
    let composable = SomeComposableClass() // No need to instantiate FlowController and pass in this instance
    composable.user.sayHello() // No unwrapping the optional, this is guaranteed
    composable.wallet.topUp(amount: 20) // this is still a compile time error which is what you wanted :D
    
    let other = OtherClass() // No need to instantiate FlowController and pass in this instance
    other.user.sayHello()
    other.wallet.topUp(amount: 10) // It all "just works"  ;)