Generics/Architecture in Swift


(Jon Hull) #1

I could use some advice on how to architect things for Swift (as opposed to ObjC). This seems like the most appropriate list to ask, but let me know if there is a more appropriate place.

I am updating an old library to Swift 3 (from 2), and it seemed like a good opportunity to experiment and modernize things. The library allows you to write expressions, which you can simplify and calculate the end result. The API I am going for is similar to a class cluster, where it looks like a single type to the outside world, but behind the scenes there are different structs to efficiently represent different types of equations. These structs all adhere to a common protocol:

public protocol ExpressionProtocol {
    associatedtype Result
    func value(using provider:ValueProvider)throws -> Result
    func simplified(using provider:ValueProvider) -> Expression<Result>
    
    func variables<Identifier:Hashable>() -> Set<Identifier>
    
    var isConstant:Bool {get}
    var isLeaf:Bool {get}
}

public extension ExpressionProtocol {
    func value()throws -> Result {
        return try value(using: EmptyValueProvider())
    }
    func simplified() -> Expression<Result>{
        return self.simplified(using: EmptyValueProvider())
    }
}

The ‘ValueProvider’ mentioned here is an abstraction that provides values (of Result) in return for a given Identifier. Basically, it lets you fill in values for variables.

All of this gets wrapped behind an ‘Expression’ class which type erases the various conforming structs and provides initializers for constant values. Then in separate files, I define various private types of expressions as structs and use an extension to add convenience initializers to Expression that create them. The class has a default initializer which takes anything conforming to ExpressionProtocol.

public extension Expression {
    public convenience init(left: Expression<T>, right: Expression<T>, operation:@escaping (T,T)throws->T) {
        self.init(OperationExpression(lhs: left, rhs: right, operation: operation))
    }
}

struct OperationExpression<T>:ExpressionProtocol {
    typealias Result = T
    var lhs:Expression<T>
    var rhs:Expression<T>
    var operation:(T,T)throws->T
    
    init(lhs:Expression<T>, rhs:Expression<T>, operation:@escaping (T,T)throws->T) {
        self.lhs = lhs
        self.rhs = rhs
        self.operation = operation
    }
    
    func value(using provider:ValueProvider)throws -> Result {
        return try operation(lhs.value(using: provider), rhs.value(using: provider))
    }
    
    func simplified(using provider:ValueProvider) -> Expression<Result> {
        let simpleLeft = lhs.simplified(using: provider)
        let simpleRight = rhs.simplified(using: provider)
        if simpleLeft.isConstant, let left = try? simpleLeft.value(using: provider) {
            if simpleRight.isConstant, let right = try? simpleRight.value(using: provider) {
                if let answer = try? operation(left, right) {
                    return Expression(answer)
                }
            }else{
                return Expression(OperationExpression(lhs: Expression(left), rhs: simpleRight, operation: operation))
            }
        }
        return Expression(OperationExpression(lhs: simpleLeft, rhs: simpleRight, operation: operation))
    }
    
    func variables<Identifier:Hashable>() -> Set<Identifier> {
        return lhs.variables().union(rhs.variables())
    }
    
    var isConstant:Bool {return lhs.isConstant && rhs.isConstant}
    var isLeaf:Bool {return false}
}

This all works pretty well, but I am having two main areas of difficulty:

  1) Containers such as Optionals and Arrays - I can go one way, but not the other. If Result is [T] or T?, I can’t seem to get at the internal type T. I can do it with a free function, but not as an init or even static function for some reason.

  2) Bools - This one surprised me. Most of my expression structs are generic over their result, but there are a few (e.g. checking equality) where their Result should always be a Bool. The compiler freaks out about this and says it isn’t able to convert Result/T to Bool.

extension Expression {
    convenience init<E:Equatable>(_ left:Expression<E>, isEqual right:Expression<E>) {
        self.init(EqualsExpression(lhs: left, rhs: right)) //ERROR: 'T' is not convertable to 'EqualsExpression.Result' (aka Bool)
    }
}

struct EqualsExpression<T:Equatable>:ExpressionProtocol {
    typealias Result = Bool
    var lhs:Expression<T>
    var rhs:Expression<T>
    
    init(lhs:Expression<T>, rhs:Expression<T>) {
        self.lhs = lhs
        self.rhs = rhs
    }
    
    func value(using provider:ValueProvider)throws -> Result {
        return try lhs.value(using: provider) == rhs.value(using: provider)
    }
    
    func simplified(using provider:ValueProvider) -> Expression<Result> {
        let simpleLeft = lhs.simplified(using: provider)
        let simpleRight = rhs.simplified(using: provider)
        if simpleLeft.isConstant, let left = try? simpleLeft.value(using: provider) {
            if simpleRight.isConstant, let right = try? simpleRight.value(using: provider) {
                return Expression(left == right)
            }else{
                return Expression(EqualsExpression(lhs: Expression(left), rhs: simpleRight))
            }
        }
        return Expression(EqualsExpression(lhs: simpleLeft, rhs: simpleRight))
    }
    
    func variables<Identifier:Hashable>() -> Set<Identifier> {
        return lhs.variables().union(rhs.variables())
    }
    
    var isConstant:Bool {return lhs.isConstant && rhs.isConstant}
    var isLeaf:Bool {return false}
}

I have tried about a dozen ways, and I can’t seem to make it fit. I can do free functions, but not initializers.

Is there a way to make this work? Is my architecture fundamentally wrong for Swift? Is there another design I should be considering instead?

What I like about the current approach that I would like to keep:
• Everything is a single type to the end user. They don’t have to remember whether it was called an EqualsExpression, etc… there are just a bunch of inits which autocomplete.
• I like the generic return type, as it lets me put things together correctly, and reads clearly. e.g. Expression<Int> is a very clear type to explain to people.
• There aren’t restrictions on what you can have an Expression of. I may conditionally add some features based on the Result type, but at the very least, you can make an Expression of anything and combine them with operations.
• It is retroactively extensible with new types of expressions while keeping all of the above features.

Any advice is appreciated…

Thanks,
Jon