 Apple Lover Developer & Artist

영속적인 디자인에 현대의 공감을 채워넣는 공방입니다

 Apple/Stanford iOS Programming (SwiftUI)

Lecture 4 Review Part2: Memorize Game Logic

singularis7 2021. 8. 20. 00:00
반응형

Varieties of Types

enum

열거형(enumeration) 은 일종의 구조체와 클래스와 같으며 이산적인 값만을 갖고 있다. 예시를 보면 쉽게 이해될 것이다.

enum FastFoodMenuItem {
    case hamburger
    case fries
    case drink
    case cookie
}

어느 패스트푸드 판매점은 위와 같이 4가지의 품목만 판매한다. 햄버거, 감자튀김, 음료 및 쿠키가 그 예시일 것이다. 옵션이 특정 범위로 정해져 있는 경우 enum 을 사용하면 이산적인 값만을 갖고 있다는 특징을 살려서 훨씬 더 직관적으로 활용할 수 있게된다. 

enum 또한 struct 타입과 마찬가지로 value 타입이며 이러한 사유로 함수의 인자나 다른 변수로 넘길 때 값복사가 발생된다. 

Swift에서 Enum의 대표적인 특징은 각 상태에 대하여 관련 데이터를 가질 수 있다는 점이다. (공식 문서에는 바코드를 예시로 설명하고 있다) 위의 예시에서 햄버거의 패티 개수를 담거나 주문순서 등을 각 유형에 데이터로 담고 있다. 사용하는 방법은 다음과 같다

// Enum 의 어느 케이스와 연결된 데이터가 존재하는 경우의 예시
let menuItem: FastFoodMenuItem = FastFoodMenuItem.hamburger(patties: 2)
// 일반적 사용에 대한 예시
let otherItem: FastFoodMenuItem = FastFoodMenuItem.cookie

// Swift의 Type Inference 기능을 활용한 예시
let menuItem = FastFoodMenuItem.hamburger(patties: 2)
// 타입이 명시되어 있는 경우 case 를 축약하여 적을 수 있는 예시
let otherItem: FastFoodMenuItem = .cookie

그렇다면 enum 이 어떤 상태에 머무르고 있는지 확인하는 방법은 무엇일까? 바로 switch 구문을 활용하는 것이다.

위 경우 enum이 햄버거 상태에 있기 때문에 burger 을 출력하게 된다. switch 문에서의 case 에서도 enum type.anycase 이런 식으로 언급하지 않고도 Swift 의 타입 추론 기능을 활용하여 ".anycase" 와 같이 간결한 형태의 선언 활용도 가능하다.

잠깐 switch 문에서 특수한 경우에 대해 생각해보자 우리가 선언한 FastFoodMenuItem 에 포함된 모든 케이스에 대한 후 처리 내용이 case에 담기지 않았다면 어떻게 될까? 정답은 컴파일 오류가 발생한다. 모든 케이스를 명시하는 것도 솔루션이 될 수 있지만 다른 방법도 존재한다. "default" 키워드를 사용하여 나머지 모든 예외 사항에 대해 어떻게 처리할지 설명해주면 된다!

위 경우 enum 의 상태가 햄버거나 감자튀김이 아니리면 무조건 other 을 콘솔창에 출력시킨다. switch 구문은 enum 외에도 string 같은 다른 기본 타입에서도 활용가능하니 기억해 두었다가 요긴하게 써먹도록 하자!

swift 에서 제공하는 switch와 나에게 익숙한 C 스타일의 switch 에 재미 있는 차이점이 있다. C에서 switch 구문을 사용할 때 case가 끝나는 시점에 break 을 걸어주지 않으면 나머지 case 에 대해서도 연이어서 검사를 진행한다. 하지만 swift에서는 break를 명시하지 않아도 기본적으로 한 case에 걸리면 switch 구문을 탈출한다. C-style 로 사용하려면 Fallthrough 키워드를 사용하여 다음 키워드로 떨어뜨릴 수 있기는 하지만 권장되지 않는 방법이라고 한다.

enum의 상태가 갖고 있는 데이터 값을 활용하여 처리하고 싶다면 위와 같은 방법을 사용해 보는 것도 좋다.

때때로 enum 이 가진 여러 case 들을 열거해보고 싶을 수 있다. 이 때 enum에 CaseIterable 프로토콜을 붙여주면 enum에 정의된 모든 케이스에 대하여 반복문을 사용할 수 있게되며 출력하건 활용하건 나머지는 프로그래머의 마음이다 ㅎㅎ

Optional

An extremely important type in Swift. 스위프트에서 사용되는 가장 중요한 열거형 타입은 바로 Optional 이다. 다른 컴퓨터 언어를 배울 때 많이 보이지 않아서 어려웠던 개념의 시작은 enum 이었으며 값이 들어 있는 case 와 값이 들어있지 않은 case 로 구분되어있다. 옵셔널에도 여러가지 타입의 값이 담길 수 있어야 하므로 Dont' care 타입인 제네릭을 활용하여 정의되어있다.

옵셔널은 무엇을 위해 사용되는가? 때때로 값이 설정되지 않을 수 있는 변수를 정의할 때 사용된다. 예를 들면 nil (Optional.none) 이 들어가는 경우를 생각해볼 수 있을 것이다. 옵셔널을 이해하기 위해 한걸음 더 들어가보자!

옵셔널을 사용하는 방법이다. 처음에 선언한 hello는 String으로 정의된 옵셔널 타입이며, 값이 들어있지 않은 case 인 .none 상태에 있다. 두번째 hello 또한 마찬가지로 String으로 정의된 옵셔널 타입이며, 값이 들어있는 case 인 .some에 연관되어있는 데이터로 string 타입의 hello가 담겨 있다. 마지막은 다시 옵셔널의 값이 없는 상태로 설정하는 코드이다. nil 키워드가 사용되어있다!

옵셔널이 무엇이고 어떻게 정의되었는지 알았다면 사용하는 방법에 대해서도 생각해보게 될 것이다. 옵셔널을 사용하여 정의된 변수를 사용할 때 변수뒤에 "!" 표를 붙이면 강제로 데이터를 꺼내올 수 있다. 다만 데이터가 없을때, 즉 nil 상태일 때 프로그램이 동작함에 있어 오류를 야기시킬 수 있으니 값이 존재함이 확실할 때에만 사용하는 것이 권고된다.

좀더 안전한 방법은 옵셔널 바인딩을 사용하는 것이다. 옵셔널에 값이 존재하는지 검사하고 값이 존재하면 출력, 값이 존재하지 않으면 관련 예외 처리를 할 수록 도와주는 구문이기 때문에 추후에 프로그램 동작에 문제가 생기는 것을 예방할 수 있는 안전한 방법이다.

재미있는 구문도 몇가지 있다. 위와 같은 "??"(nil-coalescing operator)를 사용하면 ?? 왼쪽에 있는 옵셔널 x에 값이 들어있을때 x 의 내용을 y에 담고 x가 nil이면 ?? 우측의 "foo" 를 담도록 처리한다. 마치 삼항연산자와 매우 닮아 있는 느낌이 든다.

옵셔널로 정의된 무언가에 접근할 때 옵셔널 체이닝을 활용할 수 있다. 위 코드의 경우 x, foo(), bar 모두 옵셔널 값으로 이루어져 있는데 중간에 어느 누구도 nil이 아니라면 y에는 z값이 정상적으로 담기지만 중간에 어느 하나라도 nil이라면 y에도 nil 값이 담기게 된다.

Even more Memorize

계속해서 Memorize 게임을 만들어보자!

모델에 사용자가 선택한 카드가 존재하는지 확인하는 코드 구현이다. 사용자가 선택한 카드가 모델의 카드 배열에 존재하지 않을 수 있기 때문에 리턴 타입이 옵셔널로 설정되어있으며 존재하지 않을 경우 nil 을 리턴한다.

따라서 index 값을 찾는 기능을 사용하는 choose 함수에서 nil 이 들어올 것에 대비한 코드를 사용하여 안전하게 구현해야 할 필요가 있다. 바로 if let 구문을 활용한 옵셔널 바인딩을 활용하면 된다. 사실 위와 똑같은 기능을 구현하는 메서드를 Swift 기본 배열이 갖고 있으며 같은 기능을 다음과 같이 구현할 수 있다.

이제 카드를 뒤집는 로직이 완성되었지만 아직 목표가 달성된 것이 아니다. 우리는 Memorize 게임을 만드는 것이 목표이기 때문이다. 규칙을 생각해보자. 먼저 카드를 하나 뒤집고 다른 카드를 뒤집는다. 두 카드의 내용이 같다면 잘 뒤집은 것이고 다르다면 잘못 뒤집은 것이다. 잘 뒤집었다면 두개의 카드의 isMatched 값은 true가 되어야 하며 잘못 뒤집은 경우 다시 카드의 뒷면이 보이도록 뒤집어두어야 한다.

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 }) {
            // 이미 선택된 카드가 하나 존재하는 경우
            if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard,
               !cards[chosenIndex].isFaceUp,
               !cards[chosenIndex].isMatched
            {
                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()
        }
        
        print("\(cards)")
    }
}

모델이 제네릭으로 선언되어 있어서 제네릭 타입의 값끼리 비교 연산을 할 때 문제가 발생할 수 있다. 제네릭에는 String이나 Int 같은 기본형 타입만 오는게 아니라 사용자 정의 복잡한 구조체 타입 같은 것도 올 수 있기 때문이다. 따라서 제네릭에 대해 비교연산이 가능하다는 정보를 붙여주기 위해 where typevariable: Equatable 이라는 구문을 사용하였다. Equatable 프로토콜을 붙여주면 비교 연산이 가능한것처럼 동작하게끔 선언할 수 있다. 

Swift 에서 위 논리와 비슷하게 동작하는 사례가 있다. ForEach에는 어느 타입을 갖던 배열이 들어오면 그에 상응하는 VIew 를 생성해 줄 수 있는데 단 identifiable 해야 한다. 위 코드는 SwiftUI 의 사례와 연관지어서 생각해볼때 프로토콜 설계를 지향하는 Swift 를 맛볼 수 있었다.

반응형