Code Refactoring
지난 강좌에 이어서 기존에 작성된 Code 들을 Review 해보는 시간을 가져보도록 하자! 기존에 작성된 Model 코드를 살펴보면 다음과 같은 게임 logic 구현부를 찾을 수 있다.
struct MemoryGame<CardContent> where CardContent: Equatable {
private(set) var cards: Array<Card>
private var indexOfTheOneAndOnlyFaceUpCard: Int?
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }),
!cards[chosenIndex].isFaceUp,
!cards[chosenIndex].isMatched{
// 이미 선택된 카드가 하나 존재하는 경우
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
// 선택된 카드가 하나도 없다면?
// 모든 카드를 뒤집는다.
for index in cards.indices {
cards[index].isFaceUp = false
}
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
cards[chosenIndex].isFaceUp.toggle()
}
}
}
작성되어있는 코드가 뭔가 아주 조밀하게 보인다. 위 코드를 Swift에 Computed Property 기능을 활용하여 좀더 개선해보고자 한다. 또한 위 방식으로 게임 logic이 작성하였을 때 적어도 테스트 해보는 동안은 코드가 잘 동작하는 것처럼 보였다. 그러나 추후에 버그를 유발할 수 있는 요소가 일부 존재하기 때문에 수정해야 한다.
대표적으로 동일한 카드 정보를 서로 다른 두 변수에 저장하고 있다는게 문제이다. 위 경우 indexOfTheOneAndOnlyFaceUpCard 변수가 이 경우에 해당된다. 두 변수간에 동기화를 제때에 수행해주지 않으면 두개의 FaceUp 카드가 존재하게 될 수도 있다. 버그가 발생하였다!
위 문제를 해결하는 방법은 데이터의 원천을 하나로 통일하여 동기화 문제를 깨버리는 것이다. 우리는 이 문제를 해결하기위해 Swift의 computed property 를 도입할 것이다.
private var indexOfTheOneAndOnlyFaceUpCard: Int? {
get {
// cards 배열에서 앞면을 보여주고있는 모든 카드를 가져온다.
// 이후에 그런 카드가 오직 하나만 존재하는지 확인해본다.
var faceUpCardIndices = [Int]()
for index in cards.indices {
if cards[index].isFaceUp {
faceUpCardIndices.append(index)
}
}
if faceUpCardIndices.count == 1 {
return faceUpCardIndices.first
} else {
return nil
}
}
set {
// 선택된 카드가 하나도 없다면?
// 내가 선택한 카드 하나의 카드를 제외한 나머지 카드를 뒤집는다.
for index in cards.indices {
if index != newValue {
cards[index].isFaceUp = false
} else {
cards[index].isFaceUp = true
}
}
}
}
하나의 변수에 대해서 클로져 inline function을 통해 매번 계산한 값을 받아오는 기능을 사용할 때도 있지만 무언가 설정값을 바꾸고 싶을때가 있을 수 있다. Swift는 computed property로 정의되는 변수에 대해서 get function과 set function 두 기능을 동시에 포함하여 정의할 수 있도록 허용해준다.
다만 set function을 정의할 때 dl 이 기능을 활용하여 사용자가 설정하고자 하는 설정값을 불러와야 하는 경우가 있을 수 있는데 이 때에는 Swift가 "newValue" 라는 키워드를 통해 사용자가 설정하고자하는 값을 불러올 수 있는 환경을 제공하고 있다.
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }),
!cards[chosenIndex].isFaceUp,
!cards[chosenIndex].isMatched
{
// 이미 선택된 카드가 하나 존재하는 경우
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
cards[chosenIndex].isFaceUp = true
} else {
indexOfTheOneAndOnlyFaceUpCard = chosenIndex
}
cards[chosenIndex].isFaceUp.toggle()
}
}
지난 몇가지 강의와 관련 과제물을 작성해보면서 paul hegarty 교수는 코딩에 있어서도 경제학을 고려해야 한다고 언급하고 있다. 시간복잡도 혹은 공간 복잡도(메모리)자원을 덜 쓰거나 더 적은 코드로 단순 명료하게 표현하는 등을 예시로 생각해볼 수 있을 것이다. 다시 코드를 리팩토링하고 있다는 관점에서 위와 같이 코드를 수정하였을 때 바람직한 방향이었을지 생각해보자!
사실 indexOfTheOneAndOnlyFaceUpCard 를 정의하는데 사용된 코드가 본래 게임 logic의 정의부 보다 더 많은 코드를 사용하고 있다. 이 측면에서 보면 분명 좋지 않은 코드일 것이다. 실제로 코드가 길어지는 이유는 함수형 프로그래밍의 장점을 살리지 않았기 때문이다.
놀랍게도 위 코드는 함수형 프로그래밍을 활용하면 단 두줄의 코드로 같은 논리를 표현할 수 있다고 한다. 비슷한 예시로 함수형 프로그래밍의 장점을 잘 보여주고 있는 choose 함수 구현부를 살펴보자!
chosenIndex = cards.firstIndex(where: { $0.id == card.id })
배열 자료형에서 제공하는 메서드 중 하나인 firstIndex를 활용하면 내부에서 For-loop을 바인딩하여 원하는 조건의 카드를 찾도록 도와주기 때문에 외부로 복잡한 For-loop 구문이 드러나지 않으며 우리가 원하는 chosenIndex를 얻기위해서 { $0.id == card.id } 형태의 인라인 함수를 firstIndex 함수에 전달하고 있기 때문에 함수형 프로그래밍에 해당된다.
Swift의 Sequence 프로토콜을 만족하는 친구들 (특히 콜렉션: 배열)은 filter, map, reduce 같은 메서드를 제공하고 있다. 이번의 경우 filter 메서드를 활용하여 computed property의 get 부분 코드를 단순 명료하게 개선해보도록 하자.
(참고: filter 메서드는 시간복잡도 O(n)을 만족한다)
filter 메서드는 어느 배열에서 isIncluded 조건을 만족하는 친구들을 배열에 담아 리턴해주는 기능을 제공해준다. get에 복잡한 For-loop을 filter 코드를 사용하여 아래와 같이 단순 명료하게 개선할 수 있다.
private var indexOfTheOneAndOnlyFaceUpCard: Int? {
get {
// cards 배열에서 앞면을 보여주고있는 모든 카드를 가져온다.
// 이후에 그런 카드가 오직 하나만 존재하는지 확인해본다.
let faceUpCardIndices = cards.indices.filter({ cards[$0].isFaceUp })
// 아래의 코드가 faceUpCardIndices.oneAndOnly 식으로 쓰일 수 있으면 좋을 것 같음!
if faceUpCardIndices.count == 1 {
return faceUpCardIndices.first
} else {
return nil
}
}
}
여전히 아래에 남은 조건문을 array가 제공하는 메서드 처럼 쓸 수 있으면 좋겠다는 생각은 하지만 array은 애플이 제공한 자료형 소스 코드이기 때문에 내가 기대하는 모든 메서드를 담고 있기에 한계점이 분명 존재할 것이다. 놀랍게도!!! Swift는 Extention 기능을 제공하여 기존에 이미 정의된 자료형에 대해 사용자가 원하는데로 기능을 붙일 수 있도록 환경을 구축해두었다.
extension Array {
var oneAndOnly: Element? {
if count == 1 {
return first
} else {
return nil
}
}
}
Array는 구조체로 정의되어있는데 Element라는 제네릭 변수를 통해 정의되어있다. 따라서 배열에 오직 하나의 값만 존재할 때 oneAndOnly가 리턴해야할 값은 Element 제네릭 타입이 되어야 할 것이다. 그중에서도 배열에 값이 존재하지 않거나 1개보다 많은 요소가 존재한다면 nil을 리턴할 것이기 때문에 리턴 타입은 반드시 Optional이어야만 한다.
이제 다시 코드를 리팩토링해보면 다음과 같이 두줄의 아름다운 코드가 탄생한다.
private var indexOfTheOneAndOnlyFaceUpCard: Int? {
get { cards.indices.filter({ cards[$0].isFaceUp }).oneAndOnly }
set { cards.indices.forEach { cards[$0].isFaceUp = ($0 == newValue)} }
}
set 기능도 get을 리팩토링한 것과 같은 논리로 리팩토링을 진행하였다. 자세히 보면 forEach 메서드가 사용된 것을 확인할 수 있다.
forEach 메서드는 어느 배열에서 for-loop을 도는 것과 같은 순서로 접근한 배열의 각 요소에 대하여 인자 body에 주어진 closure 처리를 진행한다. 이 원리를 통해 computed property의 set 기능을 이해해보자면 cards 배열의 수만큼 범위를 가진 indices 배열에서 각 인덱스를 뽑아온 후에 newValue로 보이는 chosenIndex와 같으면 isFaceUp을 true 다르면 false로 설정해준다.
이렇게 작성해서 얻은 이점은 무엇일까? 코드를 더 적게 작성했을 뿐만 아니라 코드를 읽기가 더 쉬워졌다. 실제로 위 코드는 Swift의 아래와 같은 세가지 기능을 보여주고 있다.
- get과 set 값이 모두 존재하는 computed property
- 기존에 존재하는 class나 struct에 특정 함수나 변수를 추가하는데 사용되는 Extention
- 코드 리팩토링을 통해 살펴본 functional programming의 예시
특히 Extention 은 프로토콜이 동작하도록 하는 fundamental이다. SwiftUI에서 View처럼 동작하도록 정의하기위해 View 프로토콜을 불러왔을 때 볼 수 있는 padding, foregroundcolor, font 등과 같은 친구들이 View 프로토콜에 대한 extention으로 정의되어있다.
' Apple > Stanford iOS Programming (SwiftUI)' 카테고리의 다른 글
Lecture 6 Review Part 1: Protocols Shapes (0) | 2021.08.25 |
---|---|
Lecture 5 Review Part3: Properties Layout @ViewBuilder (0) | 2021.08.23 |
Lecture 5 Review Part1: Properties Layout @ViewBuilder (0) | 2021.08.23 |
Lecture 4 Review Part2: Memorize Game Logic (0) | 2021.08.20 |
Lecture 4 Review Part1: Memorize Game Logic (0) | 2021.08.19 |