[Swift 문법] 딕셔너리 (Dictionaries)

Yellowtoast·2023년 12월 11일
0

Swift

목록 보기
8/11
post-thumbnail

해당 글은 iOS 스터디를 위한 기본 문법을 정리한 글 입니다.
Advanced Swift (by Chris Eidhof) 책의 내용을 참고하여 작성하였습니다.

딕셔너리(Dictionaries)란?

또 다른 주요 데이터 구조는 딕셔너리(Dictionary)입니다. 딕셔너리(Dictionary)은 Value를 가진 고유한 Key를 가지고 있습니다. 딕셔너리는 순서가 정해져 있지 않으며, for문을 돌릴 때, Value-Key의 순서는 일정하지 않습니다.

배열에서 특정 요소를 검색하는 데 걸리는 시간은 배열의 크기에 따라 선형적으로 증가하는 반면, 키로 값을 검색하는 데는 평균적으로 일정한 시간이 걸립니다. 이 이유는 딕셔너리가 Hash Table 구조로 이루어져 있기 때문입니다. 해쉬 테이블에서는 해쉬값을 index로 사용하여 원하는 값의 위치를 한 번에 알 수 있습니다. (시간복잡도는 O(1))

스마트폰 앱의 '설정' 화면에 보여지는 데이터를 구성하는 예를 들어보겠습니다. 다음 예제에서는 설정 화면에 대한 모델 데이터로 딕셔너리(Dictionary)를 사용합니다.

아래에, defaultSettings이라는 이름으로 설정 목록을 구성하였습니다. 그리고 각 개별 설정에는 이름(사전의 키)과 값이 있습니다. 값은 Text, Number 또는 Bool과 같은 여러가지 종류의 데이터 유형이 될 수 있습니다. 이를 모델링하기 위해 연결된 값이 있는 Setting Enum을 사용합니다.

enum Setting { 
	case text(String) 
    case int(Int) 
    case bool(Bool)
}

let defaultSettings: [String: Setting] = [
	"Airplane Mode": .bool(false), 
    "Name": .text("My iPhone"),
]

defaultSettings["Name"] // Optional(Setting.text("My iPhone"))

딕셔너리(Dictionaries) vs 배열(Arrays)

딕셔너리에서는 [key]를 사용하여 설정 값을 가져옵니다. 딕셔너리에서 키로 값을 찾을 경우, Optional한 값을 반환합니다. 따라서 지정된 Key값이 존재하지 않으면 nil을 반환합니다. Array의 경우 범위를 벗어난 접근에 대해 프로그램 충돌을 발생시키는것과는 좀 다르죠.

왜 위와 같은 차이를 보일까요?

이는 배열 인덱스와 딕셔너리 키가 매우 다르게 사용되기 때문입니다. 배열 인덱스로 직접 작업해야 하는 경우는 매우 드뭅니다. 배열 인덱스는 보통 어떤 식으로든 배열에서 직접 파생되므로(예: 0..<array.count와 같은 범위에서) 잘못된 인덱스를 사용하면 프로그래머의 실수입니다.

반면에 딕셔너리 Key는 다른 소스에서 오는 경우가 매우 흔합니다. (예를 들면, userId로 특정 값을 찾는다고 한다면 해당 userId는 서버와 같은 다른 소스에서 받아오는 경우가 대부분입니다.)

또한 배열과 달리 딕셔너리는 희소합니다. '이름' 키 아래에 값이 있다고 해서 '주소' 키도 존재하는지 여부는 알 수 없습니다.

딕셔너리 변경하기

배열과 마찬가지로 let을 사용하여 정의한 딕셔너리는 불변합니다. 따라서 항목을 추가, 제거 또는 변경할 수 없습니다. 하지만 배열과 마찬가지로 var를 사용하여 변경 가능한 딕셔너리를 만들 수도 있습니다.

값 제거하기

딕셔너리에서 값을 제거하려면 [key]를 nil로 설정하거나 removeValue(forKey:)를 호출하면 됩니다. 후자는 또한 삭제된 값을 반환하거나 키가 존재하지 않는 경우 nil을 반환합니다. let으로 선언된 딕셔너리를 가져와서 변경하려면 아래와 같이 복사본을 만들어야 합니다.

var userSettings = defaultSettings // let으로 선언된 딕셔너리인 defaultSettings
userSettings["Name"] = .text("Jared's iPhone") 
userSettings["Do Not Disturb"] = .bool(true)

값 변경하기

defaultSettings의 값은 변경되지 않았습니다. [key]를 통해 직접적으로 값을 할당하여 업데이트 할 수도 있지만, updateValue(_:forKey:) 메서드를 사용할 수 있습니다. 해당 함수는 업데이트 후 '변경되기 전' 값을 반환합니다.

let oldName = userSettings.updateValue(.text("Jane's iPhone"), forKey: "Name")
userSettings["Name"] // Optional(Setting.text("Jane\'s iPhone")) 
oldName // Optional(Setting.text("Jared\'s iPhone")) -> 변경 전 값을 가지고 있음

유용한 딕셔너리 함수들

merge(_:uniquingKeysWith:)

위에서 선언한 defaultSettings 딕셔너리를 overriddenSettings 딕셔너리와 결합하려면 어떻게 해야 할까요? 새롭게 설정한 딕셔너리가 defaultSettings에 선언되어있는 중복된 key의 값들을 변경해주는 동시에, defaultSettings에는 정의되지 않은 값들은 포함되어야 합니다. 기본적으로 두 개의 딕셔너리를 병합하고 병합되는 사전이 중복 키를 덮어씌우려면 어떻게 해야 할까요?

Dictionary에는 병합할 키-값 쌍과 두 값을 같은 키로 결합하는 방법을 지정하는 함수를 가져오는 merge(_:uniquingKeysWith:) 함수가 있습니다.

var settings = defaultSettings
let overriddenSettings: [String:Setting] = ["Name": .text("Jane's iPhone")] settings.merge(overriddenSettings, uniquingKeysWith: { $1 })
settings
// ["Name": Setting.text("Jane\'s iPhone"), "Airplane Mode": Setting.bool(false)]

위의 예에서는 두 값을 결합하는 정책으로 { $1 }를 사용하였습니다. { $1 }는, key가 모두 존재하는 경우 overriddenSettings의 값을 사용하여 덮어씌웁니다.

Dictionary(uniqueKeysWithValues:)

일련의 (키, 값) 쌍으로 새 사전을 구성할 수도 있습니다. 키가 고유하다는 것을 보장하는 경우 Dictionary(uniqueKeysWithValues:)를 사용할 수 있습니다. 그러나 하나의 키가 여러 번 존재할 수 있는 시퀀스가 있다면 아래와 같이 동일한 키에 대해 두 개의 값을 결합하는 함수를 제공해야 합니다.

예를 들어, Sequence에서 요소가 얼마나 자주 나타나는지 계산하려면 각 요소를 매핑하고 1과 결합한 다음 결과 value-frequency 쌍으로 딕셔너리를 만들 수 있습니다. 동일한 키에 대해 두 개의 값이 있는 경우(즉, 동일한 요소가 두 번 이상 표시되는 경우) +를 사용하여 빈도를 더하기만 하면 됩니다.

아래는, "hello"라는 String으로 각 알파벳 요소의 빈도수를 알려주는 딕셔너리를 자동으로 구성하는 함수입니다.
중복되는 키 값은 +를 사용하여 자동으로 요소들의 값을 더해줍니다.


extension Sequence where Element: Hashable { 
	var frequencies: [Element:Int] {
      let frequencyPairs = self.map { ($0, 1) } // 각 알파벳을 키로 하여 1을 value로 설정
      return Dictionary(frequencyPairs, uniquingKeysWith: +) // 동일한 키일 경우 value를 더함
	}
}
let frequencies = "hello".frequencies // ["e": 1, "h": 1, "l": 2, "o": 1] frequencies.!lter { $0.value > 1 } // ["l": 2]

mapValues

또 다른 유용한 메서드는 값에 대한 map입니다. 딕셔너리는 시퀀스이기 때문에 배열을 만드는 map 함수가 이미 있습니다. 하지만 때로는 딕셔너리 구조를 그대로 유지하면서 값을 다른 방식으로 변환하고 싶을 때가 있습니다.

mapValues 메서드가 바로 이 작업을 수행합니다. 이 함수는, map으로 구성된 딕셔너리 값들을 화면에 다양한 형태로 표출할 필요가 있을 경우 유용하게 사용될 수 있습니다.



let settingsAsStrings = settings.mapValues { setting -> String in 
	switch setting {
    case .text(let text): return text
    case .int(let number): return String(number)
    case .bool(let value): return String(value)
    }
}
settingsAsStrings // ["Name": "Jane\'s iPhone", "Airplane Mode": "false"]

참고: Apple Developer 문서 - Dictionary 에서 더 다양한 딕셔너리 함수들을 찾아볼 수 있습니다.

딕셔너리와 Hash

Hash란 무엇인가?

딕셔너리는 바로 해시 테이블로 구현되어 있습니다. 따라서 해시 테이블도 당연히 Key - Value로 값을 저장하게 됩니다.

해시 테이블은 내부적으론 배열로 구현되어 있는데요. 설명하자면 우리가 특정 Key - Value를 저장했을 때 Key를 해시함수를 통해 Hash하여, 해시된 결과값을 주소 값으로 하여 Value를 저장하게 됩니다.

Swift에서의 Hashable

swift에서는 대표적으로 딕셔너리가 해시 테이블로 구현되어 있습니다. 아래 코드와 같이 Dictionary는 Hashable Protocol을 준수하고 있습니다.

@frozen
struct Dictionary<Key, Value> where Key : Hashable

또한 Hashable Protocol은 Equatable Protocol을 준수하고 있습니다.

public protocol Hashable : Equatable { // Hashable Protocol의 정의
    var hashValue: Int { get }
    func hash(into hasher: inout Hasher)
}

구조체(Struct)와 열거형(Enum)의 경우 내부의 모든 프로퍼티가 Hashable하다면, Hash 함수를 구현하지 않고도, 선언만으로도 자동으로 Hashable하게 사용할 수 있습니다.

이는 Person 구조체 내부에 선언된 Int, String 형식의 프로퍼티가 전부 Hashable 프로토콜을 준수하고 있기 때문입니다.

struct Person: Hashable {
    var name: String // Hashable한 프로퍼티
    var age: Int // Hashable한 프로퍼티
}

Hashable한 요소들을 아는 것이 왜 중요할까요?

그 이유는 Hashable한 요소들은 Hash Table의 Key 값으로 사용 가능하기 때문입니다.

문자열(String), 정수(Integer), 부동 소수점(Double, Float), Bool 값 등 표준 라이브러리의 모든 기본 데이터 유형은 이미 Hashable 프로토콜을 준수합니다.

다시 말해, Hashable한 Struct나 Enum은 Dictionary나 Set의 Key 값으로도 사용할 수 있는 것이지요.

Hashable 직접 구현하기

하지만 Slass를 Hashable하게 만들거나, Struct의 == 를 판단하는 기준에서 일부 프로퍼티를 제외해야하는 경우도 있을 수 있습니다.

이럴경우, 선언한 타입이 Hashable 프로퍼티를 준수하도록 만든 뒤, == 함수와 hash 함수를 직접 구현해야 합니다. == 함수를 통한 동일성 검사 함수를 구현할 때 유의해야 하는 점이 있습니다.

그것은, 불변성이 유지되는 값을 사용하여 비교 함수를 구현해야 한다는 것인데요,

== 구현에 정의된 대로 동일한 두 인스턴스는 동일한 해시값을 가져야 합니다. 무한한 Cardinality(중복성)을 갖는 문자열과 같은 유형으로 비교하는 것이 합리적이겠죠?


class Human {
    let name = "Sodeul"
    let age = 28
}
 
extension Human: Hashable {
    static func == (lhs: Human, rhs: Human) -> Bool {
        return lhs.name == rhs.name && lhs.age == rhs.age
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(age)
    }
}
 
let myDict: [Human: Int] = [:]

Key값으로의 객체 사용에서 유의할 점

값 의미가 없는 유형(예: 가변 객체)을 딕셔너리의 키로 사용할 때는 각별히 주의해야 합니다. 특히나 변경 가능한 객체를 키로 사용할 때에는 더더욱 주의가 필요합니다. 객체를 사전 키로 사용한 후 hash함수나 ==함수 내부의 로직을 변경하는 방식으로 객체를 변경하면 해당 key 값으로는 딕셔너리에서 값을 찾을 수 없게 됩니다. 또한 딕셔너리가 잘못된 곳에 객체를 저장하여 내부 저장소가 손상될 수 있습니다.

profile
Flutter App Developer

0개의 댓글