Programming with Structures
In this chapter, you’ll revisit the implementation of Tic-Tac-Toe. You’ll identify the types in this program and introduce them into the code. This will add structure and reduce the overall complexity.
You’ll be guided through the changes you need to make, but you won’t see a full code listing for each change — some changes are left as exercises. The solutions bundle includes a solution for this chapter so that you can verify your code at the end.
To begin, open the solution project for Tic-Tac-Toe. Take a moment to refamiliarize yourself with the code. When you’re ready, open board.swift to begin making changes.
Type Board
The contents of board.swift implement the game board:
- Variables
topLeft
tobottomRight
hold the state of the board. - Functions
printBoard
,boardIsFull
, andresetBoard
read or modify this state.
This combination of variables that hold state and functions that operate on them suggests a type: a Board
. You can think of the contents of board.swift as the properties and methods of this type.
To create type Board
, add the following structure to board.swift:
struct Board {
}
Once this type is fully fleshed out with properties and methods, it’ll replace the other code in board.swift.
Properties
Your new type needs properties to store the state of a board. There are several ways you can do this. An obvious solution is to use the current nine variables:
struct Board {
var topLeft: String
var top: String
var topRight: String
var left: String
var center: String
var right: String
var bottomLeft: String
var bottom: String
var bottomRight: String
}
However, as you saw in Arrays, you can also use an array:
struct Board {
var positions: [String]
}
This is far better than using nine separate properties. There’s still room for improvement, though.
Every position holds either a player symbol ("O"
or "X"
) or, if the position is empty, a number string ("1"
to "9"
). However, these numbers aren’t part of the game; they’re how you choose to display empty positions.
Separating how data is stored from how it’s displayed is an important design principle in software development. Therefore, it’s better to rely on optionals and store empty positions as nil
:
struct Board {
var positions: [String?]
}
The positions
property is now focused on storing the board. The printBoard
function will decide how to display it.
Next, add an initializer to create an empty board:
struct Board {
var positions: [String?]
init() {
positions = Array(repeating: nil, count: 9)
}
}
Alternatively, you can set a default value for positions
:
struct Board {
var positions: [String?] = Array(repeating: nil, count: 9)
}
However, by declaring a custom initializer, you disable the memberwise initializer and make it clear that the board always starts with nine empty positions.
Your initializer takes no parameters, so you can create a board as follows:
var board = Board()
However, don’t do this yet. board.swift should only declare type Board
. You’ll create instances where they’re needed.
Methods
Next, consider the functions printBoard
, boardIsFull
, and resetBoard
, and how you can incorporate them into type Board
.
printBoard
is fairly straightforward. You can make it a method, like so:
struct Board {
// ...
func print() {
// ...
}
}
This method is named print
, not printBoard
, in accordance with the API Design Guidelines mentioned in Functions. One of these guidelines is that names should not contain words (such as Board
) that needlessly repeat type information.
The implementation of print
is similar to the printBoard
function it replaces, but you need to make a few changes:
- Because
print
has the same name as theprint
function from the Standard Library, you must refer to the latter asSwift.print
in this method, otherwiseprint
will call itself. - Replace references to
topLeft
,left
, and so on, with references to the corresponding elements ofpositions
. - Use the nil-coalescing operator (
??
) to handle empty positions, for example:positions[0] ?? "1"
.
Next, consider boardIsFull
. This function can become a computed property:
struct Board {
// ...
var isFull: Bool {
for element in positions {
if element == nil {
return false
}
}
return true
}
}
This implementation uses a for-in
loop, which is efficient but not very clear. The loop searches for an element that’s nil
, which indicates that the board is not full. This inverted logic is what makes the code difficult to understand at a glance.
Fortunately, type Array
has a method that can improve this code:
var isFull: Bool {
positions.allSatisfy { $0 != nil }
}
This implementation clearly conveys that a board is full when all of its positions are no longer nil
.
Finally, consider resetBoard
. This function can become a reset
method:
struct Board {
// ...
mutating func reset() {
positions = Array(repeating: nil, count: 9)
}
}
This method modifies the state of the board, so it must be marked as mutating
.
Using type Board
Now that type Board
is complete, you can remove the other code from board.swift as it’s no longer needed.
Try to build the project. You’ll see lots of errors, but don’t let these scare you. The compiler is telling you that some of your code uses the now-deleted global variables and functions. You need to fix that.
Start by updating input.swift. The readPosition
function checks the user’s input against the board to verify that the user has selected an empty position. The board is no longer stored in global variables, so you need to pass it as a parameter:
func readPosition(on board: Board) -> Int {
// ...
}
Additionally, make the following changes to readPosition
:
- Remove the
switch
statement. You no longer need it now that the positions are stored in an array. - Remove the forced unwrap from
Int(input)!
and use optional binding to handle input that isn’t a number. - Return an index between 0 and 8, not between 1 and 9. This avoids off-by-one errors in code that uses this index to read from the
positions
array.
After you make these changes, move on to game.swift. This is where you create and store an instance of Board
:
var board = Board()
It makes sense to store this here because the board is a part of the game.
Next, make the following changes:
- Replace all references to the now-removed global variables and functions with references to the properties and methods of
board
. - Reimplement
takeTurn
. You can simplify this function quite a bit now that you’re storing the board in an array. - Reimplement
currentPlayerHasWon
. In Tuples, you saw an implementation that uses tuples. Can you build a similar one that uses closures? - Remove the
selectedPosition
variable. Your new implementations oftakeTurn
andcurrentPlayerHasWon
should no longer need it.
Your code should now be back to a working state. Compile and run it to verify. If everything works, open game.swift to continue making changes.
Type Game
game.swift contains the following variables and functions:
currentPlayer
,gameIsOver
, andboard
track the state of the game.initializeGame
initializes this state.playGame
plays a game of Tic-Tac-Toe.takeTurn
,switchPlayers
, andcurrentPlayerHasWon
are helper functions used byplayGame
.
These variables and functions are related and suggest a type: a Game
. You’ll create this type now.
Add an empty Game
structure to game.swift:
struct Game {
}
Next, add properties board
, currentPlayer
, and isOver
:
struct Game {
var board: Board
var currentPlayer: String
var isOver: Bool
}
Initialize these properties using the code from initializeGame
:
struct Game {
// ...
init() {
board = Board()
currentPlayer = Bool.random() ? "O" : "X"
isOver = false
}
}
Next, add takeTurn
and currentPlayerHasWon
as methods. takeTurn
changes the state of the game, so it must be a mutating method.
Finally, add a mutating play
method using the code from playGame
. Make the following changes to this code:
- Fix any references to
gameIsOver
, which is now namedisOver
. - Replace the call to
switchPlayers
with a statement that uses the ternary operator (?:
). There’s no need to create a method for a task you only perform once and that only takes a single line of code. - Remove the call to
initializeGame
.
Type Game
is now complete. You can remove the other code from game.swift.
Before you can run your code, you need to update main.swift. Inside the main loop, create an instance of Game
and invoke its play
method to start a game:
var game = Game()
game.play()
Now that you have a Game
type, you can create a new instance for every game you want to play. You no longer have to reset the game so you can repeatedly call play
.
With these changes, you can compile and run your code again.
Type Input
So far, you’ve replaced the global variables and functions in board.swift and game.swift with properties and methods on types. But what about the global functions in input.swift? Should you move them into a type too?
Functions readPosition
and readBool
are different from the functions in board.swift and game.swift. They stand on their own and don’t depend on any state, so there’s nothing wrong with keeping them as global functions. However, you can avoid name conflicts with other global functions by moving them into a type as static methods.
Create a type Input
, and move readPosition
and readBool
into this new type:
struct Input {
static func readPosition(on board: Board) -> Int {
// ...
}
static func readBool(question: String) -> Bool {
// ...
}
}
Next, update any references to these methods. Because they’re static methods, you call them as Input.readPosition
and Input.readBool
. You never need to create any instances of type Input
. It only serves as a home for the static input methods.
Finally, rename your files to Board.swift, Game.swift, and Input.swift. It’s common practice to declare a single type per file and to name the file after this type. This makes navigating your project easier.
Encapsulation
Grouping related variables and functions into types not only adds structure to your code, but it also creates an opportunity to add encapsulation.
Encapsulation is an important aspect of software design. When designing types, you want to design them in such a way that they expose the least amount of information. What matters only is the functionality a type provides, not how it implements this functionality. Types shouldn’t know or care about another type’s internals.
A type can distinguish between the members it provides to other types and members that are part of its implementation. Collectively, the members a type provides to other types are known as its application programming interface (API). Swift’s API Design Guidelines promote clear and consistent naming of API members.
The private
keyword marks members as private and hides them from other types. You’ll use this keyword to improve the encapsulation of your types.
Game
First, consider type Game
. Only two of its members are used outside of the type: main.swift uses the initializer and the play
method. You can make all other members of Game
private to encapsulate them.
Encapsulation doesn’t limit your programming freedom. On the contrary, because you only expose init
and play
, you’re free to change the rest of the type as you see fit. As long as you don’t change the name or type of an exposed member, your changes will not affect other types.
Board
Next up is type Board
. Type Game
uses all of its members, so there isn’t much you can encapsulate. However, the positions
array deserves special attention.
Currently, positions
is fully exposed. Although Game
does need to read and modify individual positions, it should never replace the array as a whole. To protect against this, mark positions
as read-only using a variation of the private
keyword:
private(set) var positions: [String?]
This exposes positions
as a read-only property, with write access (set
) being private to type Board
.
Next, add a setPosition
method so that Game
can modify a single position on the board:
mutating func setPosition(_ position: Int, to player: String) {
positions[position] = player
}
This new design better communicates how you intend to use type Board
:
- Create an empty board with the initializer.
- Fill the positions one at a time with
setPosition
. - Call
reset
to start over.
This change to Board
triggers a compilation error. Resolve the error by changing the takeTurn
method in Game
to use setPosition
.
Input
Finally, there’s type Input
, which serves as a home for the static input methods. You never need to instantiate this type, yet it exposes an initializer, which is confusing:
let input = Input() // ?
Remove this confusion by declaring a private initializer:
private init() { }
This disables the automatic initializers and makes it impossible to create an instance of Input
outside of the type itself, which better communicates the purpose of the type.
Up next
This chapter showed you how to restructure an existing application using structures. In the next chapter, you’ll learn about a different category of types, enumerations, and improve the implementation of Tic-Tac-Toe even more.