Chainable Operations

One of the great things about NSOperation is the dependency management. This is a really powerful tool that when used properly allows us to architect our code in a much cleaner way. It enables us to structure our code and in just a few lines gives us the ability to guarantee the order of execution. The only thing I have found to be missing is a way to pass the output of one operation to the input of another. This is what I have set out to achieve.

My Initial Requirements

When thinking about the kind of interface I would like to use with chainable operations I identified the following must-haves;

  • Type Safety enforced by the compiler:- When trying to chain two operations where the first operation's output type does not match the second operation's input type this should be a compiler error not a runtime error.

  • No inconvenient optionals:- The parameter passed to the input operation should only be optional if the Input type of the operation is an optional type.

  • High level of readability:- When creating a chain of operations it should be easy to understand the flow of the code and should not have a lot of nesting.

  • No need to write boiler plate code when creating a new ChainableOperation:- I'm looking at you KVO.

Hiding the boilerplate

One of the more laborious parts of creating an operation in swift is all the boiler plate code that needs to be written to deal with the KVO. In order to eliminate this from the framework users experience I created a BaseAsyncOperation class from which my ChainableOperation would inherit. I could have put all this boilerplate straight in the ChainableOperation but I didnt for two reasons; A BaseAsyncOperation is a useful class to have around as it can be used to create other NSOperations and second I wanted my code to be more readable and separating the layers of my operation into KVO boilerplate and ChainableOperation made for a far better structure.

The Implementation

ChainableOperation

I start off by declaring my generic class like so,

class ChainableOperation<Input, Output>: BaseAsyncOperation {}

The main reason for making the operation generic with an input and an output like so is to make it easy to enforce my first requirement of Type Safety.

Next I need three properties. The first is to store the input type of the operation, as this would need to be set after the operation has been initialised it is declared as Input?. The next property is just a reference to the next operation in the chain. This is how we will actually pass the output of our operation to the next operation. This is also declared as optional as the last operation in the chain clearly won't have a nextOperation. The final property is just a flag to indicate when an Operation has failed. This will be used to ensure no operations execute if a previous operation fails.

private var input: Input?
weak var nextOperation: ChainableOperationType?
var didFail: Bool = false

Notice the ChainableOperationType? Because it is impossible to know the output type of the next operation (we know the input type because we can infer it from our output type) I wrapped ChainableOperation in a protocol to be filled out later.

protocol ChainableOperationType: class {}

class ChainableOperation<Input, Output>: BaseAsyncOperation, ChainableOperationType  {}

This wrapping makes it far easier for me to store and pass around the ChainableOperations were necessary.

Next I defined a function that subclasses of ChainableOperation would need to override which actually does the work,

func main(input: Input) {
  fatalError("Must be overriden by subclass")
}

I then went on to define the execute function (which is called by the BaseAsyncOperation). This is a fairly simple function which ensures no dependancies of the operation have failed and the input exists. If both these conditions are satisfied it passes the input to the main function.

override final func execute() {
  if hasFailingDependencies() {
    finish(.Failure(ChainableOperationError.DependanciesFailed))
    return
  }

   guard let input = input  else {
     fatalError("Something went wrong this should not be called")
   }
   main(input)
 }

The hasFailingDependencies function is just a simple function that filters the dependencies of the operation returning and array of ChainableOperations where didFail is true, ignoring any other type of operation. If the count is greater than zero this function returns true.

Next I define the finish function allowing a user to pass the output of their operation into the next operation.

final func finish(result: Result<Output>) {
    switch result {
    case .Success(let output):
      guard let nextOperation = nextOperation else {
        finishWithError(nil)
        return
      }
      nextOperation.setInput(output)
      finishWithError(nil)
    case .Failure(let error):
      finishWithError(error)
    }
  }

This function takes a Result<T> enum which is either Success with a value of the Output Type of the operation or failure with an ErrorType. If the result is successful it checks for a next operation. If the next operation exists, the operation sets the next operations input and finishes, otherwise it finishes with no error. If the result is a failure it finishes with the error passed in.

In order to set the input value on the next operation I updated my protocol like so

protocol ChainableOperationType: class {
  func setInput(input: Any)
  var didFail: Bool { get }
}

#### OperationChain In order to link operations together I create an `OperationChain`. The creation of this object is where we will really use the compiler to enforce that the `Output` type of the first `ChainableOperation` matches the `Input` type of the second `ChainableOperation`. `ChainableOperations` should never be added to an operation queue directly, they should be used to create an `OperationChain` which will be added to an `NSOperationQueue`.

To keep everything type safe I make an OperationChain generic to the Output type of the last ChainableOperation.

struct OperationChain<LastOperationOutputType> {}

The strategy is to not allow an OperationChain to mutate only be used to create a bigger chain. I also want the creation of these OperationChains to be strictly managed by static builder functions where I can easily define my constraints. So I start off by creating a the OperationChains only initialiser and making it private.

private init(operations: [ChainableOperationType], lastOperation: ChainableOperation<Any, LastOperationOutputType> ) {
  var allOperations = operations
  guard let previousLastOperation = allOperations.last else {
    allOperations.append(lastOperation)
    self.operations = allOperations
    return
  }
  if let previousLastOperation  = previousLastOperation as? NSOperation {
    lastOperation.addDependency(previousLastOperation)
  }
  previousLastOperation.nextOperation = lastOperation
  allOperations.append(lastOperation)
  self.operations = allOperations
}

This initialiser simply takes in an array of ChainableOperationTypes and a ChainableOperation, it then adds the last operation of the array as a dependency of the lastOperation. It then adds the lastOperation to the array and stores it. This does all the hard work we need but nothing is enforcing our Types properly yet, this is remedied by our static builder functions.

static func create<X,Y>(firstChainableOperation: ChainableOperation<Void,X>, secondChainableOperation: ChainableOperation<X,Y>) -> OperationChain<Y> {
   return OperationChain<Y>(operations: [firstChainableOperation], lastOperation: secondChainableOperation)
}

static func join<X,Y,>(previousChain: OperationChain<X>, newOperation: ChainableOperation<X,Y>) -> OperationChain<Y> {
   return OperationChain<Y>(operations: previousChain.operations, lastOperation: newOperation)
}

The first function creates an OperationChain from two Chainable Operations. This function insures that the Input type of the first operation is Void and the Output type of the first operation matches the Input type of the second operation. The second function takes an OperationChain and appends a creates a new OperationChain by appending a new ChainableOperation where the Input type of the ChainableOperation matches the LastOperationOutputType of the OperationChain.

With this approach however I run into a problem. There is no way I have been able to find that will allow me to cast ChainableOperation<X,Y> as ChainableOperation<Any,Y>. I have tried several ways and have come up short each time. If you know how to cast a generic constraint as Any please go to the repo linked at the end and submit a PR! For the time being to get around this I have to amend my OperationChain to be generic to the Input type and Output type of it's last ChainableOperation.

struct OperationChain<LastOperationInputType, LastOperationOutputType> {
  private init(operations: [ChainableOperationType], lastOperation: ChainableOperation<LastOperationInputType, LastOperationOutputType> ) {

And update my static functions with the following

static func create<X,Y>(firstChainableOperation: ChainableOperation<Void,X>, secondChainableOperation: ChainableOperation<X,Y>) -> OperationChain<X,Y> {
   return OperationChain<X,Y>(operations: [firstChainableOperation], lastOperation: secondChainableOperation)
 }

static func join<X,Y,Z>(previousChain: OperationChain<Z,X>, newOperation: ChainableOperation<X,Y>) -> OperationChain<X,Y> {
  return OperationChain<X,Y>(operations: previousChain.operations, lastOperation: newOperation)
}

Now I can create my OperationChains it is time to see how it all looks by creating two small test operations,

class TestChainableOperationOne: ChainableOperation<Void, String> {
  override func main(input: Void) {
    print("Void Operation")
    finish(.Success("Void Input String Output"))
  }
}

class TestChainableOperationTwo: ChainableOperation<String, Void> {
  override func main(input: String) {
    print(input)
    finish(.Success())
  }
}

And creating my OperationChain with my static builder function.

let operationChain = OperationChain<String, Void>.create(TestChainableOperationFour(), secondChainableOperation: TestChainableOperationFive())

This isn't too bad, but the compiler has issues unless I defined the constraints of the OperationChain. Also this is quite complex for just joining two operations together. If we had a larger chain we would either have to define lots of variables to hold interim OperationChains or have a large amount of complicated nesting. This hasn't really ticked the readable box yet.

Now I'm usually very cautions when it comes to defining custom operators, but in this case it made sense to me. So I define the ==> operator for my join and create functions. This allows me to create long OperationChains clearly and easily.

let operationChain =  TestChainableOperationOne()
                      ==> TestChainableOperationTwo()
                      ==> TestChainableOperationThree()

This reads much nicer to me personally.

Void inputs

WHen I first attempted this I created my OperationChain added it to my queue and ran my application, which immediately crashed. There was something wrong. Because the first operation in my chain was Void input we had no input and my execute method was failing. This leads to an interesting problem as I need to pass Void to my first operation's main(input: Void) function and originally I had no idea how to achieve this. Sending an optional or a nil still caused issues. I only arrived at a solution when I looked at the definition for Void and learnt something new about Swift. Void is actually the empty Tuple Type (). With this knowledge I could amend my execute function with the following,

override final func execute() {
   guard let input = input  else {
     if let void = () as? Input where self.input is Void? {
       main(void)
       return
     }
     fatalError("Something went wrong this should not be called")
   }

   main(input)
 }

With this in place when I can run my test operations and everything works perfectly.

Conclusion

While still not quite production ready I have managed to convince myself of the feasibility and value of writing this framework. Looking at my initial requirements they have all been satisfied. If you have any thoughts please get in touch with me on twitter (@Neil3079) and do go checkout the example project I am currently brainstorming in.