square / Cleanse

Lightweight Swift Dependency Injection Framework
Other
1.78k stars 90 forks source link

[RFC] Assisted Injection Feature #112

Closed sebastianv1 closed 5 years ago

sebastianv1 commented 5 years ago

Cleanse: Assisted Inject

Author: sebastianv1
Date: 9/4/2019
Related Links:
https://github.com/google/guice/wiki/AssistedInject


Background

Factories that combine parameterization (or a seed value) and pre-bound dependencies are commonplace in any piece of software that derives its class variables from a particular state. For example, consider a customer list view that drills into a customer detail page. The detail view controller initializer may look like:

class CustomerDetailViewController: UIViewController {
    private let customerID: String
    private let customerService: CustomerService

    init(customerID: String, customerService: CustomerService) {
      self.customerID = customerID
      self.customerService = customerService
    }
    ...
}

CustomerService in this example is a pre-bound dependency that can be constructed independently from CustomerDetailViewController. Our class variable customerID on the other hand is a parameter passed in from the list view that is dependent on which cell the user selected.

Today, Cleanse supports constructing subcomponents that can inject in a seed value, but doesn't have support for constructing a dependency from a seed in the same component. If one wanted to achieve the effect described in the example above, they would have to create a new factory object that injects all of the target object's dependencies, and create a custom build(_:) function.

class CustomDetailViewControllerFactory {
  let customerService: CustomerService

  // seed is just a String representing `customerID`
  func build(_ seed: String) -> CustomerDetailViewController {
    return CustomerDetailViewController(customerID: seed, customerService: customerService)
  }
}

extension CustomDetailViewControllerFactory {
  struct Module: Cleanse.Module {
    public static func configure(binder: UnscopedBinder) {
      binder
        .bind(CustomDetailViewControllerFactory.self)
        .to(factory: CustomDetailViewControllerFactory.init)
    }
  }
}

This creates a large amount of unnecessary boilerplate that Cleanse can help eliminate without needing to create a whole new subcomponent.

Proposed Solution

To eliminate the unnecessary boilerplate required, Cleanse will support a new binding builder whose purpose is to support Assisted Inject.

extension CustomerDetailViewController {
  struct AssistedSeed : AssistedFactory {
    typealias Seed = String
    typealias Element = CustomerDetailViewController
  }

  struct Module: Cleanse.Module {
    public static func configure(binder: UnscopedBinder) {
      binder
        .bindFactory(CustomerDetailViewController.self)
        .with(AssistedSeed.self)
        .to { (seed: Assisted<String>, customerService: CustomerService) in
          return CustomerDetailViewController(
            customerID: seed.get(),
            customerService: customerService)
        }
    }
  }
}

The above binding will create a Factory<CustomerDetailViewController.AssistedSeed> instance that can be used in the dependency graph. For instance, our customer detail list view could look like:

class CustomerListViewController: UIViewController {
  let customerDetailFactory: Factory<CustomerDetailViewController.AssistedSeed>
  ...

  func tappedCustomer(with customerID: String) {
    let customerDetailViewController = customerDetailFactory.build(customerID)
    self.present(customerDetailViewController, animated: true)
  }
}

Detailed Design

Factory<Tag: AssistedFactory>

public struct Factory<Tag: AssistedFactory> {
    let factory: (Tag.Seed) -> Tag.Element
    public func build(_ seed: Tag.Seed) -> Tag.Element {
        return factory(seed)
    }
}

This is a very lightweight and simple class. It's primary purpose is exposing the public API build(:) and passing the provided seed parameter into the factory closure.

Assisted<E>

The Assisted object wraps the seed value that is injected via the build(:) function from our factory.

public struct Assisted<E> : ProviderProtocol {
    public typealias Element = E

    let getter: () -> Element
    public init(getter: @escaping () -> E) {
        self.getter = getter
    }

    public func get() -> E {
        return getter()
    }
}

The purpose of Assisted is primarily annotative to make it more explicit that these values are provided via the assisted inject mechanism, similar to Guice's @Assisted annotation. Wrapping the seed inside the Assisted instead of directly using the seed type adds helpful transform functions (i.e map) and can be used to create more succinct bindings leveraging Swift's type inference. More on this later.

protocol AssistedFactory

public protocol AssistedFactory: Tag {
    associatedtype Seed
}

AssistedFactory inherits from the protocol Tag, which makes it expand to:

public protocol AssistedFactory: Tag {
    associatedtype Seed
    // From Tag
    associatedtype Element
}

Utilizing a tagging system for assisted injection make the surface area of changes required smaller and easier to spot when adding or changing an assisted inject object.

This point is easier to show if we removed the tagging system. So instead of Factory having a generic over an AssistedFactory instance, let's say we turned it into Factory<E, S> where E and S are the same as Element and Seed respectively from the AssistedFactory protocol, and had our builder function with(:) take in the raw types.

Consider the following assisted injection class:

struct CoffeeMachine {
  let bean: Bean
  let handle: CoffeeHandle
  let maxWeight: Int

  init(bean: Bean, handle: CoffeeHandle, maxWeight: Int) {
    ...
  }
}

We would create our binding as such:

extension CoffeeMachine {
  public struct Module: Cleanse.Module {
    public static func configure(binder: UnscopedBinder) {
      binder
        .bindFactory(CoffeeMachine.self)
        .with((Bean.self, CoffeeHandle.self, Int.self))
        .to { (seed: Assisted<(Bean, CoffeeHandle, Int)>) in
          let (bean, coffeeHandle, weight) = seed.get()
          return CoffeeMachine(bean: bean, coffeeHandle: coffeeHandle, weight: weight)
        }
    }
  }
}

And our injection would be:

struct Shop {
  let coffeeMachineFactory: Factory<CoffeeMachine, ((Bean, CoffeeHandle, Int))>
  ...
}

If we were to change the Seed by adding another parameter (say a new String), we would have to change every injection of Factory<CoffeeMachine, ((Bean, CoffeeHandle, Int))> to Factory<CoffeeMachine, ((Bean, CoffeeHandle, Int, String))> in addition to any semantical changes required. This can make refactors and changes burdensome and annoying.

Assisted Injector Builder Objects

The entry point into our assisted injector builder is through the bindFactory(_:) function:

extension BinderBase {
    public func bindFactory<Element>(_ class: Element.Type) -> AssistedInjectionBindingBuilder<Self, Element> {
        return AssistedInjectionBindingBuilder(binder: self)
    }
}

AssistedInjectionBindingBuilder is a type that conforms to BaseAssistedInjectorBuilder

public protocol BaseAssistedInjectorBuilder {
    associatedtype Binder: BinderBase
    associatedtype Tag: AssistedInjector = EmptySeed<Element>
    associatedtype Element
    var binder: Binder { get }
}

The default type for Tag is EmptySeed<Element> whose Seed = Void, meaning that the binding builder with(:) is actually an optional builder step. This means that if one uses assisted injection without a seed, the resulting Factory type expands to Factory<Element, Void>.

Terminating Step and Generated Arity Code

The terminating builder step for an assisted injection will have 2 functions. For example, the 1st-arity function will look like:

@discardableResult public func to<P_1>(file: StaticString=#file, line: Int=#line, function: StaticString=#function, factory: @escaping (Assisted<Tag.Seed>, P_1) -> Element) -> BindingReceipt<Factory<Tag>>

and

@discardableResult public func to<P_1>(file: StaticString=#file, line: Int=#line, function: StaticString=#function, factory: @escaping (P_1, Assisted<Tag.Seed>) -> Element) -> BindingReceipt<Factory<Tag>>

Note the difference between factory parameters, where the Assisted<Tag.Seed> comes at the beginning of one, and the end of the other. The reasoning behind why we are choosing to provide two functions is explained more in the Swift Type Inference section.

Generated arity-code

Similar to property injection and constructor injection, assisted injection will also generate (1,n] functions to support a variadic number of injected dependencies. This code will also live in main.swift for the CleanseGen target.

Error Handling

Assisted injection only supports 1 unique binding per AssistedFactory tag. Any additional bindings will throw an exception.

It is possible however, to create create a constructor and assisted injection binding for the same type. For example both of the following are allowed:

struct Coffee {
  let name: String
}

struct CoffeeModule : Cleanse.Module {
  struct CoffeeAssistedInject : AssistedInject {
    typealias Element = Coffee
    typealias Seed = String
  }

  static func configure(binder: UnscopedBinder) {
    // Both of these bindings are valid and will pass validation.
    binder
      .bind(Coffee.self)
      .to { Coffee(name: "Hello") }

    binder
      .bindFactory(Coffee.self)
      .with(CoffeeAssistedInject.self)
      .to { (seed: Assisted<CoffeeAssistedInject.Seed>) in
        return Coffee(name: seed.get())
      }
  }
}

The difference lies in the final types that are bound into the object graph. In the first case, an instance of Coffee.self is bound into the object graph, in the second an instance of Factory<CoffeeAssistedInject>.self is bound into the object graph.

As of this proposal, it is not possible to create tagged bindings for assisted injector objects for binding different instances of the same factory.

Swift Type Inference

Cleanse's API was implemented to leverage Swift's type inference via the binding arity functions we generate. For instance, we can create a succinct binding based on the init function like:

binder
  .bind(Coffee.self)
  .to(factory: Coffee.init)

When it comes to assisted injection, we can still leverage the type inference system if we include the Assisted<Seed> parameter in our initializer:

struct Coffee {
  let name: Assisted<String>
}

extension Coffee {
  struct CoffeeAssisted: AssistedInject {
    typealias Element = Coffee
    typealias Seed = String
  }
  struct Module : Cleanse.Module {
    static func configure(binder: UnscopedBinder) {
      binder
        .bindFactory(Coffee.self)
        .with(CoffeeAssisted.self)
        .to(factory: Coffee.init)
    }
  }
}

However, it's important to note that the Assisted<Seed> parameter must either come first or last in the initializer function to leverage the type inference from the generated arity functions. This is because if we allowed Assisted<Seed> to go anywhere in the initializer, then the number of functions required to generate would grow exponentially, slowing down the generator and bloating up the binary size.

Revisions