Fogmeister Fogmeister - 5 months ago 25
iOS Question

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)

Answer

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:

Since there's no real need to make every UserComposingor WalletComposing type declare var user:User?, or var wallet:Wallet?, etc., you can save the boilerplate by doing this:

class FlowController {
    static func userFor(instance:UserComposing) -> User {
        return User()
    }
    static func walletFor(instance:WalletComposing) -> Wallet {
        return Wallet()
    }
}

protocol UserComposing {}
extension UserComposing {
    var user:User { get { return FlowController.userFor(self) } }
}

protocol WalletComposing {}
extension WalletComposing {
    var wallet:Wallet { get { return FlowController.walletFor(self) } }
}

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: UserComposing {} // no need to declare var anymore

class OtherClass: 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"  ;)