Writing Better Code with Custom Subscripts in Swift
As my first project of the year, I’m working on the Swift version of @catehstn’s iOS Unit Testing Workshopย – you canย sign up here if you’re interested.
One of the big benefits of testing is that you end up with a much better code design. Including using custom subscripts in Swift!
I’m working on a Tic Tac Toe game, so I have a board object:
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 |
// Board.swift /* The board is: 0,0 | 0,1 | 0,2 1,0 | 1,1 | 1,2 2,0 | 2,1 | 2,2 */ struct Board { let positions: [[Position]] init() { var positions = [[Position]]() for rowIndex in 0..<3 { var rowPositions = [Position]() guard let row = Position.Row(rawValue: rowIndex) else { fatalError("Row must be valid") } for columnIndex in 0..<3 { guard let column = Position.Column(rawValue: columnIndex) else { fatalError("Column must be valid") } let position = Position(row: row, column: column, state: .Empty) rowPositions.append(position) } positions.append(rowPositions) } self.positions = positions } } |
As I was testing the initialization of my Board – to make sure all positions are indeed empty, I ended up with this test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// BoardTests.swift import XCTest @testable import SwiftTicTacToe class BoardTests: XCTestCase { func testBoardInitializationAllEmpty() { let board = Board() for row in 0..<3 { for column in 0..<3 { // This is ugly! let position = board.positions[row][column] XCTAssertEqual(position.state, Position.State.Empty) } } } } |
The way I had to get the position on a board looked super ugly and unnatural to me. I wanted it to be let position = board[row][column] instead. Thankfully, this more intuitive interface can be done very easily in Swift with custom subscripts!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// BoardTests.swift struct Board { // note that my positions array can now be private // I want it to be accesses via the subscript only! private let positions: [[Position]] init() { // truncated, see above } // The Custom Subscript Function! subscript(row: Int) -> [Position] { get { return positions[row] } // if your array is mutable, you can also have a setter: // set { // positions[row] = newValue // } } } |
So now, I access the positions from my tests in a much more intuitive way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// BoardTests.swift import XCTest @testable import SwiftTicTacToe class BoardTests: XCTestCase { func testBoardInitializationAllEmpty() { let board = Board() for row in 0..<3 { for column in 0..<3 { // Much more intuitive! let position = board[row][column] XCTAssertEqual(position.state, Position.State.Empty) } } } } |
By adding a test to my complex initializer, I was able to experience (aka dogfood) the interface I was creating, forcing me to make it better designed right away. So when you do write tests, pay close attention to how it feels to use your own functions, and change them if they don’t make intuitive sense.
UPDATE
As I was writing a test for another method, I realized that both row and column should be grouped together. After all, I don’t really have a need for a column array. My real intention with the subscript was to get the position on the board. So I updated my subscript to this:
1 2 3 4 5 6 7 |
// Board.swift subscript(row: Int, column: Int) -> Position { get { return positions[row][column] } } |
Now, my test is just:
1 2 3 4 5 6 7 8 9 10 11 12 |
// BoardTests.swift func testBoardInitializationAllEmpty() { let board = Board() for row in 0..<3 { for column in 0..<3 { // an even cleaner way to get the position! let position = board[row, column] XCTAssertEqual(position.state, Position.State.Empty) } } } |
But this was not good enough. In my other test, I needed to get the row and column from the possible list of row and column values:
1 2 3 4 5 6 7 8 |
// BoardTests.swift // in another test let row = Position.Row.Middle let column = Position.Column.Middle // this is ugly again :( let initialPosition = board[row.rawValue, column.rawValue] |
I really hated having to use the rawValue for my rows and columns – that just looks ugly again! So I created another subscript:
1 2 3 4 5 6 7 |
// Board.swift subscript(row: Position.Row, column: Position.Column) -> Position { get { return positions[row.rawValue][column.rawValue] } } |
Now my new test is nice and clean!
1 2 3 4 5 6 7 8 |
// BoardTests.swift // in another test let row = Position.Row.Middle let column = Position.Column.Middle // no more rawValue here ๐ let initialPosition = board[row, column] |
In fact, I’m going to delete the subscript that gets the position via the Int version or row and column. Using the enum values is much safer for my code! My initial test is now:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import XCTest @testable import SwiftTicTacToe class BoardTests: XCTestCase { func testBoardInitializationAllEmpty() { let board = Board() for rowIndex in 0..<3 { guard let row = Position.Row(rawValue: rowIndex) else { XCTFail("Row must be valid!") return } for columnIndex in 0..<3 { guard let column = Position.Column(rawValue: columnIndex) else { XCTFail("Column must be valid!") return } let position = board[row, column] XCTAssertEqual(position.state, Position.State.Empty) } } } } |