Static function encapsulation in Swift by passing Protocols.Type better than OO encapsulation and just as testable?

712 Views Asked by At

Given that I have a function that does not need to share and store state; should I use a static class/struct/enum to hold the function? I have read in many places that it is a bad design to use static functions to hold code, as static function do not adhere to the SOLID principles and are considered procedural code. Testability seems to be there as I can isolate the parent class with the injected static Enums by injecting mock static enums.

E.g. I can encapsulate and have polymorphism by using protocols for static functions:

Static Protocol Approach

enum StaticEnum: TestProtocol {
    static func staticMethod() {
        print("hello")
    }
}

enum StaticEnum2: TestProtocol {
    static func staticMethod() {
        print("hello2")
    }
}

protocol TestProtocol {
    static func staticMethod()
}

class TestClass {
    let staticTypes: [TestProtocol.Type]
    init (staticTypes: [TestProtocol.Type]) {
        self.staticTypes = staticTypes
    }
}

class TestFactory {
    func makeTestClass() -> TestClass {
        return TestClass(staticTypes: [StaticEnum.self, StaticEnum2.self])
    }
}

vs

Object Oriented Approach

class InstanceClass: TestProtocol {
    func instanceMethod() {
        print("hello")
    }
}

class InstanceClass2: TestProtocol {
    func instanceMethod() {
        print("hello2")
    }
}

protocol TestProtocol {
    func instanceMethod()
}

class TestClass {
    let instances: [TestProtocol]
    init (instances: [TestProtocol]) {
        self.instances = instances
    }
}

class TestFactory {
    func makeTestClass() -> TestClass {
        return TestClass(instances: [InstanceClass(), InstanceClass2()])
    }
}

The static version still allows for protocol polymorphism as you can have multiple enums adhere to the static protocols. Furthermore no initialisation is needed after the first dispatch call to create the static function. Is there any drawback in using the Static Protocol approach?

1

There are 1 best solutions below

8
Alexander On

Why static methods can violate SOLID

I have read in many places that it is a bad design to use static functions to hold code, as static function do not adhere to the SOLID principles and are considered procedural code.

I suspect you've read about this in the context of other languages, such as Java, which I'll use as a concrete example for simplicity.

Indeed, this is true for Java. But rather than a hand-wavy quote about why static is the boogeyman, it's good to understand the precise details.

In Java, static methods are truly static. They're really no different than a global function that just happens to be namespaced to a class.

As such, it violated the dependency inversion principle (the "D" in SOLID). Any usage of a static method in Java is a case where the calling code is relying on a concretion (that particular implementation of the method) and not an abstraction (an interface that describes that method), making polymorphism impossible. Essentially there's no way to substitute one implementation for another.

... but not in Swift.

This is just not the case in Swift.

In Swift, when a type conforms to a protocol, instances of that type are guaranteed to meet the protocols requirements for instance methods, properties and subscripts, just like Java.

But as you've discovered, when a Swift type conforms to a protocol, the type itself is guaranteed to meet the protocol requirements for static methods, static properties, static subscripts and initializers.

This goes a step beyond what Java's interfaces can express.

I like to visualize it by thinking of each Swift protocol as acting as if it were (up to) two conventional Java interfaces in one.

When you have:

protocol MyProtocol {
    static func myStaticMethod() {}
    func myInstanceMethod() {}
}

struct MyImplementation: MyProtocol {
    static func myStaticMethod() {}
    func myInstanceMethod() {}
}

It behaves as if you had (psuedo-code):

interface MyProtocol_MetaHalf {
    static func myStaticMethod() {}
}

interface MyProtocol_InstancedHalf {
    func myInstanceMethod() {}
}

struct MyImplementation.Type: MyProtocol_MetaHalf {
    static func myStaticMethod() {}
}

struct MyImplementation: MyProtocol_InstancedHalf {
    func myInstanceMethod() {}
}

You might recognize this as being eerily similar to the way Java interfaces are often used. You'd often have a pair, interface Foo, and interface FooFactory. I argue that the prevalence of "Factory" classes in Java stems from their interfaces' inability to express static requirements, thus what could have been a static method has to be instead split off into an instance method on a new class.

As you've also seen, Swift's static methods can satisfy protocol requirements and they can be polymorphically called. Example:

protocol MyProtocol {
    static func staticMethod()
}

struct Imp1: MyProtocol {
    static func staticMethod() { print("Imp1") }
}

struct Imp2: MyProtocol {
    static func staticMethod() { print("Imp1") }
}

let someImplementation: MyProtocol.Type = Imp1.self

// A polymorphic call to a static method
someImplementation.staticMethod()

Thus, you can see that the testability issue with Java interfaces doesn't translate to Swift's interfaces. You can easily implement a new mock type that conforms to a protocol with a static requirement, and provide a mock implementation to the static method.


All that is to say: What you proposed is possible (well clearly, you provided a working demonstration). But it begs the question: why would you do it?

Why use metatypes when types will do?

Having a type system that supports both types (which describe the methods of their instances, the objects) and metatypes (which describes the methods of their instances, the types) can be useful for expressing ideas like "I have a widget, and it's paired with a thing that makes widgets, here's how the two go together into one protocol/class/struct."

But what you've done is essentially ... dial up the "meta level" by one. In your example, you use types instead of objects, and metatypes instead of types. But fundamentally, you haven't achieved anything that can't already be expressed more easily/clear using the typical pairing of objects + types.

Here's how I would express what you wrote:

struct Implementation1: TestProtocol {
    func instanceMethod() {
        print("hello")
    }
}

struct Implementation2: TestProtocol {
    func instanceMethod() {
        print("hello2")
    }
}

protocol TestProtocol {
    func instanceMethod()
}

class TestClass {
    let implementations: [TestProtocol]
    init(implementations: [TestProtocol]) {
        self.implementations = implementations
    }
}

class TestFactory {
    func makeTestClass() -> TestClass {
        return TestClass(implementations: [Implementation1(), Implementation2()])
    }
}

Given that I have a function that does not need to share and store state

They might not need that today, but maybe they will need it in the future. Or maybe not. For now, I just modelled the implementations using empty structs.

This is probably a good time to emphasize there's a difference between "I only need one object of this", vs "I'm adamant I only ever want exactly one object of this".

That might sound silly/obvious, but you'd be surprised how frequently post here asking about how they can be two instances of a singleton :)

If you're totally adamant that you'll never need state, then perhaps you enforce the one-objectness constraint with a singleton:

struct Implementation1: TestProtocol {
    public static var shared: Self { Self() }
        
    private init() {}
    
    func instanceMethod() {
        print("hello")
    }
}