Search

[Swift] Collection Types - Array

The Swift Programming Language Documents를 참고해서 정리했습니다. 여러 가지 생각들이 버무러져 있습니다.
contents
*이전 포스팅은 [Swift] Extensions 에서 보실 수 있습니다.

Collection Types

Swift에서는 Value 콜렉션을 저장하기 위한 3가지 기본 타입을 제공합니다.
Array : 순서가 지정된 값 콜렉션
Set : 고유값의 순서가 지정되지 않은 콜렉션
Dictionary : Key-Value 연결의 순서가 지정되지 않은 콜렉션
3가지 Collection은 저장할 수 있는 Value, Key 타입이 항상 명확합니다. 이는 잘못된 타입의 값을 실수로 Collection에 삽입할 수 없다는걸 의미합니다. 또한, Collection에서 값을 찾을 때 값이 가지는 타입에 대해 확신할 수 있음을 의미합니다.
Collection Type을 변수에 할당하게 되면, Collection 내부에 Item를 추가, 제거, 변경하는 방식으로 Collection를 변경할 수 있게 됩니다. 하지만, 상수에 할당하게 되면 Collection의 크기나 내용을 변경할 수 없게 됩니다.
var shoppingList: [String] = ["Six Eggs", "Milk", "Flour", "Bananas"] let genres: Set<String> = ["Rock", "Jazz", "Classical", "Hip Hop"]
Swift
복사
Collection를 초기화한 후에 변경할 필요가 없다면, 상수로 선언해서 immutable로 만드는 것이 좋습니다.
상수로 선언된 Collection은 코드를 쉽게 추론 가능하게 하고, Swift 컴파일러가 작성된 Collection의 성능 최적화 가능하게 만들어 줍니다.

Array

배열은 순서가 지정된 값 콜렉션으로 가장 일반적으로 사용되는 데이터 타입 중에 하나입니다.
배열 안에는 동일한 타입의 값을 정렬된 형태로 저장할 수 있습니다. 따라서, 앱 데이터를 구성할 때 배열을 많이 사용하게 됩니다.
흔하게 사용하는 배열은 사실 Array말고도 2가지 타입이 더 있습니다.
Array
ContiguousArray
ArraySlice
타입들은 각자의 특징이 있고, 그 특징에 맞게 배열 타입을 선택해서 사용하면 됩니다. 3가지 타입의 특징은 아래에 작성해뒀으니 천천히 읽으면서 알아가봅시다.
일단, 흔하게 사용하는 배열의 선언 방식부터 알아봅시다.
배열은 총 3가지의 선언 방식이 존재합니다. ⓵ 기본형, ⓶ 축약형, ⓷ 타입 추론 입니다. Swift Programming Language에서는 3가지 선언 방식 중에서도 ⓶ 축약형을 선호한다고 명시합니다.
⓵ 기본형
var shoppingList = Array<String>()
Swift
복사
⓶ 축약형
var shoppingList: [String] = []
Swift
복사
⓷ 타입 추론
var shoppingList = ["Six Eggs", "Milk", "Flour", "Bananas"] // Compiler에서 [String] 타입으로 타입 추론
Swift
복사
배열을 어떻게 만드는지 알았으니 이제 해당 배열에 새로운 물건을 넣어보고 삭제해보고 특정 요소를 검색해보기도 해봅시다. 위에서 선언해둔 shoppingList를 사용해볼게요.
⓶ 축약형으로 빈 배열을 만들었습니다. 그리고 해당 배열에 방금 산 계란과 우유를 추가해볼게요.
shoppingList.append("계란 한 판") shoppingList.append("서울우유")
Swift
복사
이렇게 하나씩 넣을 수도 있지만, append(contentOf:_)를 사용해서 여러 개를 한 번에 넣을 수도 있습니다.
shoppingList.append(contentsOf: ["계란 한 판", "서울우유"])
Swift
복사
append(contentOf:_)와 동일한 기능을 +=로도 할 수 있습니다.
shoppingList += ["계란 한 판", "서울우유"]
Swift
복사
위의 방식은 추가 시에 마지막 요소 뒤로 요소를 추가하게 됩니다. 하지만, 특정 위치로 요소를 추가하고 싶을 수도 있습니다. “계란 한 판”과 “서울우유” 사이에 고구마를 넣고 싶다면, 어떻게 해야할까요?
해당 기능을 해주는 메서드가 바로 insert(at:_)입니다. 해당 메서드는 argument로 추가할 요소와 위치를 받습니다.
shoppingList.insert("고구마", at: 1)
Swift
복사
만약, 잘못된 index를 넣는다면 Array index is out of range 에러를 발생시킵니다. 따라서, index 사용 전에 배열의 count와 비교해서 해당 index가 유효한 index인지 확인하는 작업이 꼭 필요합니다.
append는 마지막 요소 뒤로 요소가 추가되기 때문에 중간에 있는 요소들을 뒤로 밀어낼 필요가 없지만, insert는 요소 추가를 위해서 argument로 보낸 index 이후의 index를 위한 공간을 만들기 위해서 다른 요소들을 뒤로 shift해야 합니다.
append는 별 다른 처리가 필요없기 때문에 시간복잡도가 O(1)이 들지만, insert는 요소들을 뒤로 미는데 드는 시간이 있기 때문에 index가 endIndex와 동일하지 않다면 O(n)의 시간복잡도가 듭니다.
추가를 했다면, 리스트에서 삭제도 해봅시다. 아까 추가했던 “고구마”를 사고 싶지 않아서 삭제하려고 합니다. insert처럼 특정 위치를 넣어서 삭제할 수 있습니다.
shoppingList.remove(at: 1)
Swift
복사
remove은 해당 요소를 반환하기 때문에 원한다면 해당 요소를 특정 프로퍼티에 저장해서 사용할 수도 있습니다.
removeinsert처럼 해당 공백을 채우기 위해서 뒤에 요소들을 앞으로 밀게 됩니다. 따라서, O(n)의 시간복잡도를 내게 됩니다.
그렇기 때문에, 마지막 요소를 제거하는 기능을 구현한다면 remove보다는 removeLast()를 사용하는 것이 좋습니다. removeLast는 remove(at: 마지막 index)와 동일한 기능을 하지만 시간 복잡도는 O(1)이 듭니다. 또한, 마지막 index가 무엇인지를 굳이 찾을 필요도 없어서 여러모로 좋습니다.
shoppingList.removeLast()
Swift
복사
하지만, removeFirst()는 Last와는 다르게 O(n)의 시간복잡도가 듭니다. 제거한 후에 뒤의 요소들을 앞으로 미는 작업이 필요해서 그런 것 같네요..
아까 있던 쇼핑 목록에 여러 항목들이 추가되었습니다. 해당 항목들을 나열해보고 싶네요.
shoppingList += ["계란 한 판", "서울우유", "고구마", "딸기", "바나나우유", "샐러드", "생크림 케이크", "목살", "요구르트", "치즈"]
Swift
복사
해당 기능은 for-in을 사용해서 구현할 수 있습니다. enumerated()를 사용하면 해당 요소의 index까지 tuple 형식으로 받을 수 있습니다.
// Basic for element in shoppingList { print(element) } // with enumerated() for (index, element) in shoppingList.enumerated() { print("\(index) \(element)") }
Swift
복사
지금보니 “초코우유”를 “바나나우유”로 잘못 쓴 거 같네요. 원하는 인덱스로 접근해서 원하는 값으로 변경할 수 있습니다.
shoppingList[4] = "초코우유"
Swift
복사
“초코우유”뿐만 아니라 “치킨텐더 샐러드”와 “치즈 케이크”도 쇼핑 목록에 잘못 들어가 있네요. 연속된 여러 요소를 수정하고 싶다면 값 범위를 지정해서 한 번에 변경할 수 있습니다.
shoppingList[4...6] = ["초코우유", "치킨 텐더 샐러드", "치즈 케이크"]
Swift
복사
만약, 쇼핑 목록에 있는 모든 요소의 개수를 보고 싶다면, count를 사용해서 배열 요소의 개수를 알 수 있습니다.
shoppingList.count // 10
Swift
복사

Array Capacity

count말고 count와 비슷한 프로퍼티가 있습니다. capacity입니다. 출력해보면 count와 동일한 값을 출력합니다.
그렇다면, Capacity는 Count와 어떤 차이를 가지는 걸까요?
Capacity는 번역하면 용량이라는 뜻을 가집니다. 용량이라는 뜻에 걸맞게 새로운 Storage를 할당하지 않고 Array에 포함할 수 있는 총 element 수를 나타냅니다.
그렇기 때문에, shoppingList에서 요소를 몇개 빼내면 count와 capacity의 값에 차이가 발생합니다.
새로운 Storage를 할당하지 않고 Array에 포함할 수 있는 총 element 수이기 때문에 요소 몇개를 빼낸다고 해서 빼낸 수만큼 작아지는것이 아니고 이전에 할당한 크기만큼 가지고 있는거죠.
그럼, Capacity는 언제 늘어나는건가요?
Swift에서의 Array는 컨텐츠를 저장할 특정 양의 메모리를 예약해둡니다.
쇼핑 목록에도 10개만큼의 메모리를 예약해뒀기 때문에 쇼핑 목록에 10개까지는 capacity를 늘릴 필요없이 요소를 추가할 수 있었던겁니다. 하지만, 해당 용량을 초과하기 시작하면 더 큰 메모리 영역을 할당해야 합니다. 그리고 더 큰 메모리 영역을 할당받은 새로운 Storage에 item들을 복사해서 넣어 둡니다.
새로운 Storage는 이전 크기에 배수인 메모리를 할당받게 됩니다. 이렇게 되면, 재할당 시키는 작업에서 Cost가 들긴 하지만, Array가 커질 수록 Cost가 점점 더 적게 발생하게 됩니다.
정말 이전 크기에 배수로 Capacity를 설정하는지 확인하기 위해서 for문을 사용해서 shoppingList에 요소를 채워 보았습니다.
for _ in 0...1000 { let currentCapacity = shoppingList.capacity shoppingList += ["계란 한 판", "서울우유", "고구마", "딸기", "바나나우유", "샐러드", "생크림 케이크", "목살", "요구르트", "치즈"] if currentCapacity != shoppingList.capacity { print(shoppingList.capacity) } }
Swift
복사
capacity가 바뀔 때마다 출력을 해보니 이런 결과가 나왔습니다.
현재 capacity의 2배보다 약간 큰 capacity를 새로운 Storage에 할당하는걸 확인할 수 있었습니다.
새로운 Capacity를 가진 Storage로 옮기는데에 비용이 드는거 같은데, 애초에 원하는 Capacity로 설정할 순 없나요?
가능합니다. 저장해야 할 element 수를 대략 알고 있다면, reserveCapacity를 사용하여 중간 재할당을 방지할 수 있습니다.
reserveCapacity지정된 개수의 item를 저장할 수 있는 충분한 공간을 예약해줍니다.
그렇기 때문에, 배열이 커지면서 Capacity를 늘리기 위해 여러 번의 재할당을 진행하지 않아도 됩니다. 이미 충분한 공간이 예약되어 있기 때문이죠.
그렇다면, reserveCapacity로 배열의 용량을 정해두고 시작하는 것이 훨씬 효율적이겠네요?
reserveCapacity는 지정한 만큼의 공간만 할당하면서 과도한 할당을 피하지만 성능이 예상보다 나빠질 수 있습니다.
append메서드는 일정 시간의 성능을 달성하기 위해서 geometric allocation pattern를 따르는데 reserveCapacity는 추가하라고 지시한 만큼의 공간만 할당하고 더 이상 할당하지 않기 때문에 과잉 할당은 피할 수 있지만 일정 시간의 성능은 가질 수 없습니다.
예를 들어서, addTenQuadratic이라는 메서드에서 배열에 요소를 추가하기 전에 Capacity를 10개씩 선형으로 증가시킨다고 해보겠습니다.
func addTenQuadratic() { let newCount = values.count + 10 values.reserveCapacity(newCount) for n in values.count..<newCount { values.append(n) } }
Swift
복사
Capacity가 10개씩 선형으로 증가하는 것은 배수로 Capacity를 재할당하는 일반 방식보다 좋지 못합니다. reserveCapacity를 사용하기 보다, 해당 메서드없이 append하는게 훨씬 효율적입니다.
Swift에서는 효율적인 재할당 방식을 사용하기 때문에 100만개의 item를 추가한다고 해도, 20~30 이하의 재할당이 수행된다고 합니다. 즉, 배열이 매우 클 것으로 예상되고 성능에 민감한 부분에서 배열을 사용하는 경우가 아니라면 재할당 문제를 굳이 걱정할 필요가 없다는 겁니다.

Contiguous Array

이름 그대로, 연속적으로 저장된 배열입니다.
Contiguous Array는 item를 항상 인접한 메모리 영역에 저장하는 특수한 Array 입니다.
Element 타입이 Class 타입이거나 @objc 프로토콜일 경우에 메모리의 연속 영역에 저장할 수 있는 Array와는 다른 점입니다. 만약, Element 타입이 Class 거나 @objc 프로토콜이고 NSArray에 브리지해야하거나 Objective-C API를 사용해야 하는 것이 아니라면 Array보다는 Contiguous Array를 사용하는 것이 효율적이고 예측 가능한 성능을 가질 수 있습니다.
Element 타입이 Struct거나 Enum 타입인 경우에는 Array와 Contiguous Array의 효율성은 비슷해야 합니다.
근데, 왜 Array를 대체하지 못할까요?
Element 타입이 Class, @objc 프로토콜이 아니라면 Array와 Contiguous Array의 성능이 동일하기 때문에 굳이 Contiguous Array로 Array 타입을 대체할 필요가 없다고 느낀게 아닐까 싶습니다.
Array에 Element 타입으로 Class나 @objc 프로토콜 타입을 넣는 경우는 흔치 않기 때문이죠.
하지만, 해당 타입을 가지는 배열을 만든다고 한다면 Contiguous Array로 만드는 것이 성능이 향상된 효과를 얻을 수 있습니다.
Class 타입을 저장하는 Array, Contiguous Array의 성능을 비교했을 때, 2배 이상의 시간 차이를 보입니다.

ArraySlice

ArraySlice는 Array, ContiguousArray, ArraySlice 인스턴스의 Slice 입니다.
더 큰 Array에서 빠르고 효율적이게 작업을 수행할 수 있습니다. ArraySlice를 생성하면 새 Storage로 복사하는 대신 더 큰 Array Storage에 대한 View를 제공받습니다. 즉, Array와 같은 공간을 사용하게 됩니다. 따라서, Array와 ArraySlice는 동일한 인터페이스를 제공받고 동일한 작업이 가능해집니다.
예를 들어서, 이전에 만들었던 쇼핑 목록을 반으로 쪼개볼게요.
let mid = shoppingList.count / 2 let firstList = shoppingList[..<mid] let secondList = shoppingList[mid...]
Swift
복사
이 경우에 firstList와 secondList는 자체적으로 새 저장소를 할당받지 않습니다. shoppingList의 Storage에 표시됩니다.
이는 문제를 발생시킬 수 있습니다. ArraySlice가 Array Storage에 장기간 저장되어 있다면 Array의 수명이 끝난 후에도 해당 Array의 Storage에 대한 참조를 유지하고 있을 수 있습니다. 더 이상 접근할 수 없는 요소의 수명이 연장되면서 메모리, 객체 누수가 발생할 수 있습니다. 따라서, ArraySlice 인스턴스를 장시간 유지되도록 하면 안됩니다.
ArraySlice처럼 String에서도 SubString을 만들 때 동일한 Storage 문제가 있어서 장시간 사용을 권장하지 않는다고 합니다. 관련 내용에 대해 이전에 정리해둔게 있어서 링크 걸어두겠습니다.
ArraySlice 사용 시 메모리 관련 문제말고도 주의할 점이 있습니다. 바로, Index 입니다.
Array, ContiguousArray와 달리 ArraySlice는 시작 index가 항상 0이 아닙니다. Slice 되기 이전의 큰 Array의 index를 유지하기 때문에 시작 index가 달라집니다.
secondList.startIndex // 10
Swift
복사
따라서, Slice의 시작과 끝을 안전하게 참조하려면 특정 값 대신 startIndexendIndex를 사용하는 것이 좋습니다.

️ 참고 자료