DEV Community

Cover image for Replace Conditional With Pattern-matching Refactoring
Danny L
Danny L

Posted on

Replace Conditional With Pattern-matching Refactoring

This is one of my favorite refactorings. It helps to group logic, making code easier to read and extend.

I'm writing this blog post to use as a bookmark.

Very often, I see code which looks like the following:

func fizzbuzz(i: Int) -> String {

  if i % 3 != 0 {
    if i % 5 != 0 {
      return "\(i)"
    } else {
      return "Buzz"      
    }
  } else if i % 5 != 0 {
    return "Fizz"
  } else {
    return "FizzBuzz"
  }
}



for i in 1...100 {
  print(fizzbuzz(i))
}
Enter fullscreen mode Exit fullscreen mode

It's quite a mental challenge to see all the possible states and predict what will hapen on given input number. Nested if's makes that part of the process even harder.
We have to find a good way to represent the base-case and make all of the illegal cases impossible.

Pattern matching is technique in which we compare the values (or in our example: tuple values).., in a way that we could deconstruct the complex data structure, and match it's internal values against pattern: SomeComplexDataStructure == (0, 0) ?.

Notice that we can use _ as wildcard symbol,
case(0, _) will match all values where i%3=0 and i%5=anyvalue

func fizzbuzz(i: Int) -> String {
  let someComplexDataStructure = (i % 3, i % 5) // Tuple complex data structure

  switch someComplexDataStructure {
    case (0, 0): "FizzBuzz"
    case (0, _): "Fizz"
    case (_, 0): "Buzz"
    case (_, _): "\(i)"
  }
}
Enter fullscreen mode Exit fullscreen mode

Lets up our game on another level,

Let's go crazy and make a Tic Tac Toe game, that stores all of it's internal state with enum's and uses pattern matching to display each player moves on the board, find who is the winner, ...

Disclaimer: I'll use the new SwiftUI syntax in to build the views (there is github download link bellow)

1

2

3

4

Here there are the building block types of our game:

Player represents the two opponents in the game (0 and X)
Filed represents the squares on the board: can be either empty or marked by one of the players
GameState represents which player turn is (playing), which player won the game or neither of the players have won (draw)

enum Player {
  case cross, circle
}

enum Field {
  case empty, marked(Player)
}

enum GameState {
  case playing(Player), winner(Player), draw
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI

We are going to use SwiftUI Views to build the basic ui for our little game.
SwiftUI is great way to quickly prototype interfaces, declaratively compose views and subviews...

import SwiftUI
import PlaygroundSupport

struct App: View {
  var title: String
  var body: some View {
    VStack {
      Text(title).foregroundColor(.gray)
      Game()
    }
  }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: App("TicTacToe"))
Enter fullscreen mode Exit fullscreen mode

We have App component that contains the Game component that contains Board > Board Row > Square...

lists

SwiftUI @State binding

The most beneficial thing about SwiftUI is the realtime binding between state variable's changes and redrawing/refreshing the component itself.

If we declare variable with @State modifier, every change that we make will refresh the subviews of our component:

struct Game: View {
  @State var board: [[Field]]
  @State var gameState: GameState

  mutating func restart() {
    self.board = ....
  }
  mutating func clickSquare(_ x: Int, y: Int) {
    self.board = updateBoard(board, gameState, id: "\(x) \(y)")
    self.gameState = updateGameState(board, gameState) 
  }

  var body: some View {
        Board(state, onRestart, clickSquare)
  }
}
Enter fullscreen mode Exit fullscreen mode

Board variable [[Field]]

The board variable is 2 dimentional matrix with all the board squares placed by x and y axis coordinate: Every square have value of either empty or marked by some of the players (X,O )

let y = 0, x = 1

board[y][x] = .marked(.cross)

matrix

When we click on a square, a function will set the board[y][x] = .marked(.cross) , then SwiftUI will refresh the views and show x under the square we clicked (because board is @State variable )

Let's go back to pattern matching!

But how we know if click on board in which square exatly to put X or should we put O alternatively?

I'm going to remind us the rules of TicTacToe:

We can place mark one of the remaing empty squares on the board

We can draw O or X inside it (depending on who's player turn is)

The updateBoard function does this calculation and changes the board variable's field on x, y coordinate.

func updateBoard(_ board: [[Field]], _ gameState: GameState, id: String) -> [[Field]] {
  return board.enumerated().map { (indy, row) in
    return row.enumerated().map { (indx, field) in
      if "\(indx) \(indy)" != id { return field }   

            //if the board current field == clicked square field

      switch (gamestate, field) {
        //if square is alredy marked -> return same field 
        (.playing(_), .marked(_)) => return field,
                //if the square is empty -> modify it -> as marked by X or O player
        (.playing(let player), .empty) => return .marked(player),
        //otherwise in every other case, like when game is won, no started.. square is empty
        (_, _) => return .empty
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Excelent the pattern matching will handle all of the other default cases for us for free!

Calculate the game winner, or next-turn player

In the next part we will calculate if the game is finished, who is the winner, or who's the next player turn is.

I'll break the updateGameState function in tree parts:

  1. Get the player for the next-turn

  2. Flatten the board 2dimensional matrix to 1dimentional array [[x0y0, x1y0, x2y0] [x0y1, x1y1, x2y1]] becomes (x0y0, x1y0, x2y0, x0y1, x1y1, x2y1)

  3. Find if the board contains 3 marked fields in row (horizontal, vertical, diagonal), and if so - change the gameState to .winner

We're placing mark on square on the board (either X or O), then we calculate - do we have any series of 3 squares in row, and if we have 3 squares in row gameState = winner
If we didn't finished playing the game (gameState != winner gameState != draw we don't have winner but it's not draw either) ,
we will continue playing and gameState = .playing(the other player)

func updateGameState(_ board: [[Field]], _ state: GameState) -> GameState {
  // 1. 
  func nextPlayer(state: GameState) -> GameStates {
    switch state {
      case .plaing(.cross): return .plaing(.circle)
      case .plaing(.circle): return .plaing(.cross)      
      default: return state
    }
  }

  // 2.
  let b = board.flatmap { $0 }
  let flatBoard = (b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9])

  // 3.
    switch flatBoard {
    //horizontal lines
    case (.marked(.X), .marked(.X), .marked(.X), _, _, _, _, _, _): return .winner(.cross)
    case (_, _, _, .marked(.X), .marked(.X), .marked(.X), _, _, _): return .winner(.cross)
    case (_, _, _, _, _, _, .marked(.X), .marked(.X), .marked(.X)): return .winner(.cross)
        case (.marked(.O), .marked(.O), .marked(.O), _, _, _, _, _, _): return .winner(.circle)
    case (_, _, _, .marked(.O), .marked(.O), .marked(.O), _, _, _): return .winner(.circle)
    case (_, _, _, _, _, _, .marked(.O), .marked(.O), .marked(.O)): return .winner(.circle)
    //vertical lines
    case (.marked(.X), _, _, .marked(.X), _, _, .marked(.X), _, _): return .winner(.cross)
    case (_, .marked(.X), _, _, .marked(.X), _, _, .marked(.X), _): return .winner(.cross)
    case (_, _, .marked(.X), _, _, .marked(.X), _, _, .marked(.X)): return .winner(.cross)
    case (.marked(.O), _, _, .marked(.O), _, _, .marked(.O), _, _): return .winner(.circle)
    case (_, .marked(.O), _, _, .marked(.O), _, _, .marked(.O), _): return .winner(.circle)
    case (_, _, .marked(.O), _, _, .marked(.O), _, _, .marked(.O)): return .winner(.circle)         
    // diagonal lines
    case (.marked(.X), _, _, _, .marked(.X), _, _, _, .marked(.X)): return .winner(.cross)
    case (_, _, .marked(.X), _, .marked(.X), _, .marked(.X), _, _): return .winner(.cross)
    case (.marked(.O), _, _, _, .marked(.O), _, _, _, .marked(.O)): return .winner(.circle)
    case (_, _, .marked(.O), _, .marked(.O), _, .marked(.O), _, _): return .winner(.circle)
    // default -> all fields filled -> draw
    case (.marked(_), .marked(_), .marked(_), .marked(_), .marked(.O), .marked(_), .marked(_), .marked(_), .marked(_)): return .draw
    // still unfilled fields -> switch to other player
    default: return nextPlayer(state)
  }
}
Enter fullscreen mode Exit fullscreen mode

In effect these 2 functions (updateGameState, updateBoard) handle the all of the possible business logic cases for the game: under 100-lines of code.

Conclusion

This is simple and natural refactoring.

Use this refactoring when you see data being mapped to other data or behavior.

One thing to take as lesson from here: Using data-driven in stead of event driven (if/else) architecture is almost always better.

Not every switch case is the same. Some times you can't easily convert a nested ifs to switch, take for example the updateGameState function -> tuple with 9 or more values is difficult to write and maintain.

Top comments (0)