In this part of the article series, I will continue exploring the F# language, a .NET based functional-first programming language, by using the example of the Tic Tac Toe game.
In Part 1, and Part2 of this article series, I talked about F# and began exploring it using the Tic Tac Toe game as an example.
In this part, I am going to continue exploring. Here is the link to the source code for this part:
https://github.com/ymassad/TicTacToeFSharp/blob/master/src/GameModule.fs
In the previous two articles, I explained all the types and values in the board module. In this one, I will talk about the game module (GameModule).
The board module contained types and functions that model the game board. For example, it contained the Board record that modeled the board itself. It also contained the writeBoard function that allows us to write the contents of the board to the console for the players to see. It also contained the anyLineIsFullOf function that tells us whether any of the 8 lines that a player needs to fill to win is full of Xs or Os.
The game module is on a higher level. It models types and functions that are on the level of playing the game. Here are the 4 types that are defined inside the game module:
type Player = PlayerX | PlayerO
type GameState = {Board: Board; CurrentPlayer: Player;}
type Winner = PlayerXWon | PlayerOWon | Draw
type PlayTurnResult =
| GameEnded of result: Winner * board: Board
| GameNotEnded of newGameState: GameState * message: string option
The Player type is a discriminated union to model a player. A Player can either be PlayerX or PlayerO.
The GameState type is a record. It models the state of the game. It needs to keep track of two things: the state of the board, and who is the current player. To do that, it has two elements: Board and CurrentPlayer.
Winner represents the final result of playing the game. Either player X will win, or player O will win, or no one will win.
PlayTurnResult is the result of playing a single turn. It is a discriminated union that has two cases: GameEnded and GameNotEnded. I talked about discriminated unions in the first part of this series. In that part, I only demonstrated simple discriminated union where the cases do not have any fields. The Player discriminated union above is an example of such simple discriminated unions. PlayTurnResult is a bit more complex. Each of the defined two cases has two fields. The GameEnded case, for example, has two fields: result of type Winner, and board of type Board. This means that to construct a GameEnded value, we must specify the winner and the ending board.
When the game does not end as a result of playing a turn, the GameNotEnded value will include a new state of the game (modeled by using the GameState type), and an optional message to display to the user.
These four types are used as inputs and outputs for the functions defined next.
let getWinner board =
if anyLineIsFullOf board CellStatus.HasX then
Some PlayerXWon
elif anyLineIsFullOf board CellStatus.HasO then
Some PlayerOWon
elif isFull board then
Some Draw
else
None
The getWinner function takes a board and returns Winner option (to remind you, in F#, T option means Option<T> which represents an optional value). It uses the anyLineIsFullOf function from BoardModule to see if any line (of the 8 lines) of the board is full of Xs or Os, and will return PlayerXWon or PlayerOWon correspondingly. If no line is full of Xs or Os, the function checks if the board is full, in which case it returns Draw. Otherwise, the function returns None.
“Some” is one of the two cases of the Option discriminated union. “Some PlayerXWon” creates a value of “Winner option” that has a value. This is in contrast to “None” which returns the other case of “Winner option” which doesn’t contain any value. “Some” is an example of a discriminated union that has a field. “None” doesn’t have any fields.
let switchPlayer player =
if player = PlayerX then PlayerO else PlayerX
The switchPlayer function uses a simple conditional expression to switch players. “switch” here means returning a value of the “switched” player. No state modification happens here.
The next function is the playTurn function. This function has the following signature:
It takes the current game state (which includes the board and the current player), row and column indexes selected by the user, and it returns a PlayTurnResult.
Here is the code for this function:
let playTurn state rowIndex columnIndex =
let cellValue = getCell rowIndex columnIndex state.Board
if cellValue <> CellStatus.Empty then
GameNotEnded (state, Some "Cell is not empty")
else
let newCellValue = if state.CurrentPlayer = PlayerX then HasX else HasO
let updatedBoard = updateCell state.Board rowIndex columnIndex newCellValue
let winResult = getWinner updatedBoard
match winResult with
| Some result -> GameEnded(result, updatedBoard)
| None -> GameNotEnded(
{Board = updatedBoard;
CurrentPlayer = switchPlayer state.CurrentPlayer},
None)
The function starts by getting the value at the selected cell. Then it checks that the selected cell is empty. If it is not empty, it returns GameNotEnded using the unmodified game state, and the message "Cell is not empty". Notice the syntax used to construct a GameNotEnded value and provide values for its fields.
If the cell value is empty, we calculate the value to put inside the cell based on the current player. Then we update the board with the new cell value. Then we check if there is a winner. We use pattern matching here to check the return value of the getWinner function which is of type Winner option. I talked about pattern matching in part 2 of the series. Here however, one of the two discriminated union cases, the Some case, has a field. This is why the pattern we use to match the Some case is “Some result”. This gives us access to the value stored inside Some in case there is a winner. result will hold the winner in this case. And in this case, we return GameEnded passing the winner and the updated board as field values.
In the case there is no winner, we return GameNotEnded. To create the updated game state, we call switchPlayer to switch the player, then we construct a new GameState by using the updated board and the switched player value. We pass None for the message field of GameNotEnded.
The next function is readIntFromConsole:
let rec readIntFromConsole readConsole writeConsole message validate =
let handleInvalid () =
writeConsole "Invalid value"
readIntFromConsole readConsole writeConsole message validate
writeConsole message
let userInput: string = readConsole ()
match Int32.TryParse userInput with
| true, i ->
if validate i then
i
else
handleInvalid ()
| _ ->
handleInvalid ()
This function helps us read an integer value from the console. It displays a message to the user first to ask the user to supply a value, and it also validates the user input and asks again if the input is not valid.
The function takes the following four parameters and returns int:
- readConsole of type (unit -> string) is a function parameter used inside readIntFromConsole to read a string from the console.
- writeConsole of type (string -> unit) is a function parameter used inside readIntFromConsole to write a string to the console.
- message of type string is the message that will be displayed to the user asking them to supply a value.
- validate of type (int -> bool) is a function parameter used inside readIntFromConsole to determinate whether a value supplied by the user is within the range of accepted values. You will see how this is used once we discuss the playTurn2 function (there, we want to make sure the user selects 1 or 2 or 3).
First, the handleInvalid nested function is defined. I will talk about it in a moment.
Then, writeConsole is used to print message to the console.
Then, we have the following line:
let userInput: string = readConsole ()
Here, we are calling readConsole. Because readConsole takes unit as input, we pass () as a parameter. () is the only value of type unit. We assign the result of calling readConsole to userInput.
Notice here that we have explicitly specified the type of userInput to be string. If we ask F# to infer the type of userInput by removing “: string”, F# will not able to infer the type and we would get the following compilation error:
A unique overload for method 'TryParse' could not be determined based on type information prior to this program point. A type annotation may be needed. Candidates: Int32.TryParse(s: ReadOnlySpan<char>, result: byref<int>) : bool, Int32.TryParse(s: string, result: byref<int>) : bool
Because userInput is passed as an argument to the Int32.TryParse method, F# tries to find the type of userInput by checking the type of the first parameter of the TryParse method. Because TryParse has overloads, the first parameter of TryParse can be string or ReadOnlySpan<char>.
The other way F# could have inferred the type of userInput is through the type of readConsole. Currently, we are not specifying an explicit type for readConsole which means we are asking F# to infer the type of readConsole. Had we specified the type of readConsole explicitly, we wouldn’t have needed to specify the type of userInput explicitly. Here is how the first line of the function would look like if we did:
let rec readIntFromConsole (readConsole: unit -> string) writeConsole message validate =
For me, specifying the type of userInput explicitly (instead of readConsole) seemed like the better option.
Another thing to note about the call to Int32.TryParse is that we pass a single argument to it. Int32.TryParse has two parameters: the string we want to parse, and an out parameter to receive the result. Here is how the signature looks from C#:
public static bool TryParse (string s, out int result);
There are multiple ways to call .net methods with out parameters from F#. But the most convenient way is to have the out parameters become part of the return value of the method. F# does this automatically for us. So, the following expression:
Int32.TryParse userInput
has the type bool * int. This is a tuple of bool and int. bool is the original return type of TryParse, and int comes from the result out parameter of TryParse.
This is why the first pattern tested in the code above is the following:
true, i
This is a tuple pattern. This pattern will be matched if the first element of the tuple is true, that is, if TryParse returned true. For the second element of the tuple, we have i in the pattern. There is nothing special about the name i, we could have chosen any name here. i will become a value accessible in the result expression for the pattern. The result expression of this pattern is a conditional expression:
if validate i then
i
else
handleInvalid ()
Here, we call the validate function (passed as a parameter) on i. If validation is successful (validate returns true), we return i. Otherwise we call the handleInvalid nested function.
The other pattern is _. This is the wildcard pattern. It will match anything. Patterns in a match expression are examined in turn. This means that the first pattern is matched first. Therefore, if the return value of TryParse is not true, the second pattern (_) will be examined and it will match because it is the wildcard pattern. Because this case means that TryParse returned false, we call handleInvalid (and return its return value).
Here is the definition of the handleInvalid nested function:
let handleInvalid () =
writeConsole "Invalid value"
readIntFromConsole readConsole writeConsole message validate
This function simply writes “Invalid value” to the console and then calls the readIntFromConsole function recursively so that the user will try again.
By default, functions cannot call themselves recursively. The readIntFromConsole function is defined with “let rec” instead of “let” so that it can call itself recursively.
The toIndex function looks like this:
let toIndex i =
match i with
| 1 -> One
| 2 -> Two
| 3 -> Three
| _ -> raise (Exception "Invalid value")
It converts an integer to an Index (a type from the board module). This code should be familiar to you now, except for the usage of the raise function. The raise function allows us to throw exceptions. The following expression:
(Exception "Invalid value")
constructs a new System.Exception object using the constructor that takes a message parameter of type string. Notice how we don’t need to use the new keyword here. F# has support for OOP but that is outside the scope of this article.
Here is the playTurn2 function (formatted for this article):
let playTurn2 state readConsole writeConsole =
let writeLine line = writeConsole (line + Environment.NewLine)
let validateIndex i = i > 0 && i < 4
let playerSymbol = if state.CurrentPlayer = PlayerX then "X" else "O"
writeLine ("Player " + playerSymbol + "'s turn")
let row = readIntFromConsole
readConsole
writeLine
"Please specify row:"
validateIndex
|> toIndex
let column =
readIntFromConsole
readConsole
writeLine
"Please specify column:"
validateIndex
|> toIndex
playTurn state row column
This function ultimately calls playTurn, but it gets the row and column indexes first by asking the user. Based on the knowledge you gained on F# in this article series, you should be able to read this function and understand it.
The following is the playGame function:
let playGame readConsole writeConsole =
let writeLine line = writeConsole (line + Environment.NewLine)
let rec next state =
match playTurn2 state readConsole writeConsole with
| GameEnded (winner, board) ->
writeBoard board writeConsole
writeLine "Game ended"
let winMessage = match winner with
| PlayerXWon -> "Player X won"
| PlayerOWon -> "Player O won"
| Draw -> "No winner!"
writeLine winMessage
| GameNotEnded (newState, message) ->
writeBoard newState.Board writeConsole
match message with
| Some m -> writeLine m
| _ -> ()
next newState
next {Board = emptyBoard; CurrentPlayer = PlayerX}
Again, you should now be familiar with the syntax used in this function. One thing I want to point at is the patterns used to match the PlayTurnResult discriminated union cases. Notice how GameEnded and GameNotEnded are matched and how their fields (winner, board, newState, message) are made available in the corresponding result expressions.
The playGame function takes two function parameters: readConsole and writeConsole and returns unit.
This function is responsible for the whole thing. It will start with an empty board and it will play multiple turns until the game ends.
The playGame function defines a nested function called next, and then simply calls it with an initial GameState: the board is empty, and the current player is player X. This is on the last line of the playGame function.
The “next” function calls playTurn2, and depending on the result, it either recursively calls itself again if the game is not ended, or it displays some information to the user and exits if the game is ended.
I want to remind you that () used in the above code is of type unit and is the only value of type unit.
You should be able to go through the code and see exactly what is happening and why.
Now, the Program.fs file contains the following:
open System
open GameModule
[<EntryPoint>]
let main argv =
playGame Console.ReadLine Console.Write
0 // return an integer exit code
Here, a main function is defined. Notice how the EntryPoint attribute is applied on this function. Because this attribute is applied, the argv parameter of the main function will be inferred to have the type string array. If we remove the EntryPoint attribute, the type of the argv parameter will become a’ (a type parameter). As you might expect, argv for an entry point function will contain the command line parameters.
This main function will be called if we execute the application. This function simply calls the playGame function passing the Console.ReadLine and Console.Write methods as parameters.
We didn’t have to specify the module of playGame when referencing it because GameModule is opened above using the open keyword.
There is a lot more to F# than this. But using the knowledge you learned so far, you can now start coding some F#. I encourage you to do so. Take some coding problem and try doing it in F#. That is the best way to learn!
Conclusion:
F# is a .NET based functional-first programming language. In this article, I continued exploring the language by using the example of the Tic Tac Toe game. In this part, I talked about the game module which contains types and functions relating to playing the game. In this part, I showed an example of a discriminated union where some cases contained fields. I also showed an example of how to match over the cases of such discriminated union. I talked about how F# deals with functions from .NET that have out parameters. I talked about recursive functions in F#. I showed an example of F# type inference. I also talked about the EntryPoint attribute that is used on the main function.
This article was technically reviewed by Damir Arh.
This article has been editorially reviewed by Suprotim Agarwal.
C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.
We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).
Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.
Click here to Explore the Table of Contents or Download Sample Chapters!
Was this article worth reading? Share it with fellow developers too. Thanks!
Yacoub Massad is a software architect and works mainly on Microsoft technologies. Currently, he works at NextgenID where he uses C#, .NET, and other technologies to create identity solutions. He is interested in learning and writing about software design principles that aim at creating maintainable software. You can view his blog posts at
criticalsoftwareblog.com. He is also the creator of DIVEX (
https://divex.dev), a dependency injection tool that allows you to compose objects and functions in C# in a way that makes your code more maintainable. Recently he started a
YouTube channel about Roslyn, the .NET compiler. You can follow him on twitter @
yacoubmassad.