One of the most common problems in software engineering in general is the logic that relies on different sources of truth for a given piece of data – especially when those sources may end up contradicting each other. , which tends to lead to an unknown state.
For example, let’s say we are working on an application to write articles, and that we want to use the same data models to represent published articles, as well as projections. Workshop has not been published.
To handle the two cases, we can provide data model data model is isDraft to know whether it is representing a draft, and we will also need to turn any single data to post. Publish newspaper to optionals – like this:
1 2 3 4 5 6 7 8 | struct Article { var title: String var body: Content var url: URL? // Only assigned to published articles var isDraft: Bool // Indicates whether this is a draft ... } |
At first, it may not seem like the model above has many sources of truth – but it really doesn’t, because whether an article that needs to be considered for publication can both be determined by looking at the parachute. It has a url assigned to it, or whether isDraft is true.
That may not seem like a big deal, but it can quickly lead to conflicts on our code base, and it also requires unnecessary preparation – like every call site has. both check the isDraft flag, and Unwrap the optional url attribute, in order to ensure that its logic is correct.
This is exactly the kind of situation in which Swift enums really shines – since they let us model types on variations such as state states, each of which can carry its own set of data. in an optional way – like this:
1 2 3 4 5 6 7 | extension Article { enum State { case published(URL) case draft } } |
What the enum above allows us to do is replace our previously calculated url and isDraft with a new state attribute – which will act as a unique source of truth to determine the status of each post. :
1 2 3 4 5 6 | struct Article { var title: String var body: Content var state: State } |
With the above in place we can now simply turn on our new state attribute whenever we need to check if an article has been published – and need code paths for the article. Publishing is no longer available to deal with any required URLs. For example, here’s how we can now conditionally create a UIActivityViewController for sharing published articles:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func makeActivityViewController( for article: Article ) -> UIActivityViewController? { switch article.state { case .published(let url): return UIActivityViewController( activityItems: [url], applicationActivities: nil ) case .draft: return nil } } |
However, when making the above types of structural changes to one of our core data models, we will probably also need to update quite a lot of code that uses the model – and We may not be able to make all updates once.
Thankfully, it’s usually relatively easy to solve this kind of problem through some form of temporary backward compatibility layer – which uses our new unique source of truth below, while still letting expose the API just like we had before we went to the rest of the source code.
For example, here’s how we can let its url store until we’re done transferring all our code to its new status API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | #warning("Temporary backward compatibility. Remove ASAP.") extension Article { @available(*, deprecated, message: "Use state instead") var url: URL? { get { switch state { case .draft: return nil case .published(let url): return url } } set { state = newValue.map(State.published) ?? .draft } } } |
So that’s an example of how we can combine structures and other types with enums to establish a unique source of truth for our different states. Next, let’s see how we can go the other way around, and augment some of our enums to make it a lot stronger – while also reducing the overall number of us about conversion reports in the process.
Enums versus protocols
After the above idea of using enums to model different countries – let’s say we are working on a drawing application, and that we have now implemented the tool selection code Our use of an enum contains all drawing tools that support our application:
1 2 3 4 5 6 7 8 | enum Tool: CaseIterable { case pen case brush case fill case text ... } |
In addition to the state management aspects, one of the additional benefits of using an enum in this case is the CaseIterable protocol, the type of tool we follow. For example to build a view toolbox that contains buttons for each of our drawing tools:
1 2 3 4 5 6 7 8 9 10 11 | func makeToolboxView() -> UIView { let toolbox = UIView() for tool in Tool.allCases { // Add a button for selecting the tool ... } return toolbox } |
However, as neat as it is to have all our tools gathered in a single, setup type that doesn’t come with a sizable disadvantage in this case.
Since all our tools will likely need a fair amount of logic, and using an enum requires us to implement all of that logic in a single place, we will probably end up with an increasingly complex series of conversion reports – look for something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | extension Tool { var icon: Icon { switch self { case .pen: ... case .brush: ... case .fill: ... case .text: ... ... } } var name: String { switch self { ... } } func apply(at point: CGPoint, on canvas: Canvas) { switch self { ... } } } |
One problem with our current approach is that it makes it quite difficult to host country specific stuff – since enums that fit CaseIterable cannot make any value that comes with it.
To solve all of the above two issues, let us instead try to implement each of our tools using a protocol – which will give us a common interface, while still allowing each The tool is declared and implemented in isolation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | // A protocol that acts as a shared interface for each of our tools: protocol Tool { var icon: Icon { get } var name: String { get } func apply(at point: CGPoint, on canvas: Canvas) } // Simpler tools can just implement the required properties, as well // as the 'apply' method for performing their drawing: struct PenTool: Tool { let icon = Icon.pen let name = "Draw using a pen" func apply(at point: CGPoint, on canvas: Canvas) { ... } } // More complex tools are now free to declare their own state properties, // which could then be used within their drawing code: struct TextTool: Tool { let icon = Icon.letter let name = "Add text" var font = UIFont.systemFont(ofSize: UIFont.systemFontSize) var characterSpacing: CGFloat = 0 func apply(at point: CGPoint, on canvas: Canvas) { ... } } |
However, while the above change allows us to completely separate our various implementations, we also lose one of the main benefits of our enum-based method – we can Easily loop on each tool using Tool.allCases.
1 2 3 4 5 6 7 8 9 10 | func allTools() -> [Tool] { return [ PenTool(), BrushTool(), FillTool(), TextTool() ... ] } |
But what if we didn’t have to make a choice between protocols and counting, and could instead mix them to sort of achieve the best of both worlds?
Enum externally, protocol inside:
Let’s go back to our Return Type Tool to become an enum, but instead once again implement all our logic like methods and the full properties of conversion reports – Feel free to instead of keeping these protocol-driven implementations, only this time, we’ll make them driven for our tools, rather than the model representation of the tools themselves.
Using our previous protocol as a starting point, let’s define a new protocol called ToolController, – along with our previous request – including a method that allows each tool to provide and manage its own options view. That way, we can end up with a truly discrete architecture, in which each controller completely manages the logic and user interface needed for each given tool:
1 2 3 4 5 6 7 8 | protocol ToolController { var icon: Icon { get } var name: String { get } func apply(at point: CGPoint, on canvas: Canvas) func makeOptionsView() -> UIView? } |
Going back to our TextTool implementation from before, here’s how we can modify it to instead become a TextToolController that follows our new protocol:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | class TextToolController: ToolController { let icon = Icon.letter let name = "Add text" private var font = UIFont.systemFont(ofSize: UIFont.systemFontSize) private var characterSpacing: CGFloat = 0 func apply(at point: CGPoint, on canvas: Canvas) { ... } func makeOptionsView() -> UIView? { let view = UIView() let characterSpacingStepper = UIStepper() view.addSubview(characterSpacingStepper) // When creating our tool-specific options view, our // controller can now reference its own instance methods // and properties, just like a view controller would: characterSpacingStepper.addTarget(self, action: #selector(handleCharacterSpacingStepper), for: .valueChanged ) ... return view } ... } |
Then, instead of having our enum tool contain any actual logic, we’ll just give it a unique method for creating a ToolController that corresponds to its current state – save them. I had the trouble of writing all the conversion statements we had before, while still allowing us to make the most out of CaseIterable:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | enum Tool: CaseIterable { case pen case brush case fill case text ... } extension Tool { func makeController() -> ToolController { switch self { case .pen: return PenToolController() case .brush: return BrushToolController() case .fill: return FillToolController() case .text: return TextToolController() ... } } } |
Finally, putting all the pieces together, we will now be able to both easily loop on each tool to build a toolbox view and enable the current tool logic by communicating with the ToolController. Its – like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | class CanvasViewController: UIViewController { private var tool = Tool.pen { didSet { controller = tool.makeController() } } private lazy var controller = tool.makeController() private let canvas = Canvas() ... private func makeToolboxView() -> UIView { let toolbox = UIView() for tool in Tool.allCases { // Add a button for selecting the tool ... } return toolbox } private func handleTapRecognizer(_ recognizer: UITapGestureRecognizer) { // Handling taps on the canvas using the current tool's controller: let location = recognizer.location(in: view) controller.apply(at: location, on: canvas) } ... } |
Conclude
The beauty of the above method is that it allows us to completely separate logic, while still building a single source of truth for all the states and variants. We may have also chosen to split our code up a bit differently, for example to keep the symbol of each tool and name in our enum, and just move our actual logic out so that ToolController implementations – but that’s always something we can tweak in the future.
The article was translated according to the article of the same name by John Sundell.