Cocoa Internals - 컬렉션 클래스

컬렉션 클래스

Foundation 프레임워크는 여러 객체를 저장하거나 객체를 묶음 단위로 관리하기 위한 다양한 컬렉션 클래스를 제공하며, 이는 순서대로 저장하는 배열, 고유한 키 값으로 접근하는 사전(딕셔너리) 형태, 순서 없이 골라 담는 집합 형태, 배열을 변형한 형태 등을 포함한다.

순서가 있는 배열 컬렉션

NSArray / NSMutableArray

NSObject를 상속받는 어떠한 객체라도 위의 배열에 객체에 대한 레퍼런스를 저장할 수 있다.

배열에 비어 있는 상태를 표현하려면 [NSNull null] 형태의 빈 객체 인스턴스를 넣으면 된다.

불변 배열과 가변 배열

배열에 변경을 가해야 할 때뿐만 아니라, 배열이 너무 큰 경우에도 가변 배열을 사용하는 것이 좋다.

참조 객체는 해당 객체를 복사하는 것이 아닌, 해당 객체에 대한 소유권을 획득하여 참조하는 형태다. ARC가 아닌 환경에서는 -retain 메소드를 통해 소유권을 획득한다.

배열을 복사하고 싶다면 -initWithArray:copyItems와 같은 초기화 메소드를 사용한다.

탐색, 정렬, 필터링

탐색에 있어서 for-in 구문을 사용하는 빠른 탐색fast enumeration이 권장되며, -enumerateObjectsUsingBlock:을 사용하는 것도 좋다. 이는 블록 기반으로 작동한다.

정렬에 있어서 정렬 설명서sort descriptor를 사용한다. -sortedArrayUsingComparator:와 같은 블록 기반 메소드도 사용할 수 있다.

필터링에 있어서 NSPredicate 클래스를 사용하여 필터링 조건을 만든다. -filteredArrayUsingPredicate:와 같은 블록 기반 메소드를 사용할 수 있다.

널 가능성과 제네릭

iOS 9, macOS 10.11부터 컬렉션 클래스가 제네릭 타입을 지원하게 되었고, Swift와의 호환성을 위해 객체나 타입 변수가 nil 값을 가질 수 있는지에 대해 표시하는 널 가능성 지시어가 추가되었다.

  • _Nonnull
    • nil 값을 가질 수 없음을 의미한다.
  • _Nullable
    • nil 값을 가질 수 있음을 의미한다. 말하자면 Swift의 Int?와 동일한 의미를 갖는다.
  • _Null_resetable
    • nil 값을 가질 수 있으나 명시적으로 nil 값은 절대 되지 않음을 의미한다. 말하자면 Swift의 Int!와 동일한 의미를 갖는다.
  • _Null_unspecified
    • nil 여부를 판단하지 않음을 의미한다.
+ (instancetype _Nonnull)arrayWithArray:(NSArray<ObjectType>) * _Nonnull)anArray

+arrayWithArray: 메소드의 구현은 인자로 nil인 값이 들어올 수 없고 nil인 값을 반환할 수 없음을 의미한다.

기존에 NSArray가 제네릭 타입이 아니었을 때는 타입이 다른 객체를 얼마든지 넣을 수 있었으나 현재는 제네릭을 지원하므로 특정 클래스만 담는 컬렉션으로 활용 가능하다.

상속을 받아 사용하는 하위 클래스를 포함하여 제네릭으로 선언하고 싶으면 __kindof 지시어를 사용해야 한다. (NSArray<__kindof UIView*> *subviews)

배열 성능 특성

NSArray와 무비용 연결이 가능한 CFArray와의 성능 차이를 비교한 결과 다음의 것들을 얻을 수 있었다.

  • CFArray보다 NSArray가 성능상 우수하다.
  • 서브스크립트 또는 objectAtIndex:로 배열의 요소에 접근하는 것보다 for-in 구문을 사용한 빠른 탐색을 하는 것이 성능상 우수하다.

포인터 배열

NSPointerArray

포인터 배열을 사용하여 참조 객체를 약한 참조로 접근할 수 있다.

포인터 배열을 생성하는 방법은,

  1. 이미 정의되어 있는 메모리 관리 방식과 포인터 특성에 대한 선택 사항 고르기
  2. 포인터 함수 직접 지정하기

두 가지 방법이 있다.

방법1. 정해진 옵션 지정하기

-initWithOptions:

NSPointerFunctionsOptions 옵션에서 선택한다. 이는 메모리 관리 방식에 관한 것과 포인터의 특성에 관한 것으로 구분된다.

방법2. 함수 포인터 지정 방식

-initWithPointerFunctions

NSPointerFunctions를 활용하여 포인터에 있는 데이터를 다룰 함수 포인터를 지정하는 방식을 취한다.

이는 다음의 함수 등을 포함한다.

  • hashFunction : 함수의 등가성을 판단하기 위함
  • isEqualFunction : 함수의 등가성을 판단하기 위함
  • descriptionFunction : 요소를 묘사하기 위함
  • acquireFunction : 메모리 할당을 위함
  • relinquishFunction : 메모리 해제를 위함

중첩된 배열 접근하기

NSIndexPath 객체를 사용하여 중첩된 배열의 참조 인덱스를 따라간다.

이전 iOS 버전의 UITableView에서 사용된 NSIndexPath에는 rowsection에 대한 정보가 없으며, 이는 UITableView에 대하여 카테고리를 통해 확장된 것들이다.

Swift 배열

Swift의 배열은 구조체 타입이며, 여러 프로토콜을 채택하여 확장되었다.

구현 방식에 따라 Array<Element>, ContiguousArray<Element>, ArraySlice<Element>로 나뉜다.

  • ContiguousArray는 메모리의 연속된 영역에 요소를 저장한다.
  • ArraySlice는 배열을 잘라서 일부 요소만 표현할 수 있도록 도와준다.
  • ArrayNSArray와 연결될 수 있다.

Swift의 배열은 일반적으로 value semantics를 갖고 있느나, 요소에 클래스 타입을 포함하는 경우 Objective-C와의 호환을 위해 reference semantics를 갖게 된다.

Swift의 배열은 Copy-on-Write를 지원한다.

  • 배열을 복사했을 때, 복사한 것과 복사된 것은 초기에 서로 같은 메모리를 참조한다.
  • 복사 이후 배열의 한 곳을 변경하는 시점에 실제 복사가 이루어진다.
    • 이는 배열 전체 길이만큼 영향을 주므로 O(N)의 복잡도를 갖게 된다.

Swift의 배열은 요소가 클래스이거나 @objc 프로토콜을 지원하는 타입인 경우 내부 NSArray에 저장된다. 이 떄문에 NSArray와 연결하는 복잡도는 O(1)이 된다.

요약

배열 객체의 성능 특성을 알고 시스템 자원을 효과적으로 사용하는 것은 중요하다.

포인터 배열의 특징을 이해하고 있다면 적합한 상황에 골라 사용할 수 있다.

고유한 키 값으로 접근하는 사전 컬렉션

딕셔너리Dictionary. 고유한 키와 키에 매칭하는 값을 갖는 자료 타입.

Cocoa에서는 서로 매칭되는 키와 값을 짝지어 하나의 개체entity라고 부른다.

동일한 키 값이 존재하면 안되므로, 키 값으로 사용하는 객체는 hashisEqual 메소드를 구현하여 등가성을 판단할 수 있어야 하고, 복사를 위한 NSCopying 프로토콜을 지원해야 한다.

Swift의 경우 Hashable 프로토콜을 준수하는 타입이 딕셔너리의 키 값으로 사용될 수 있다.

사전 활용 방법

불변 사전과 가변 사전

딕셔너리에 변경을 가해야 할 때뿐만 아니라, 딕셔너리가 너무 큰 경우에도 가변 배열을 사용하는 것이 좋다.

NSDictionary를 사용하여 불변 객체를 만들었다 할지라도, 그 요소가 가변 객체이면 그것에 대한 변경은 이루어질 수 있다.

참조 객체는 해당 객체를 복사하는 것이 아닌, 해당 객체에 대한 소유권을 획득하여 참조하는 형태다. ARC가 아닌 환경에서는 -retain 메소드를 통해 소유권을 획득한다.

딕셔너리를 복사하고 싶다면 -initWithDictionary:copyItems와 같은 초기화 메소드를 사용한다.

나만의 키 객체

딕셔너리의 키 값은 유일해야 하고, 그 값 비교를 위해 해시 방식을 지원해야 한다.

자신만의 객체를 키 값으로 사용하려 한다면 -hash-isEqual 메소드를 구현해야 한다.

키 객체의 해시 함수 결과가 충돌하지 않는 안전한 함수라면 개체 탐색, 추가, 삭제 모두 O(N)의 복잡도를 갖게 된다.

일반적으로 NSString(String)을 키 값으로 사용하므로 안정적인 NSString 해시 함수를 사용하게 된다.

키 객체는 반드시 NSCopying 프로토콜을 구현하여 내부적으로 복사가 일어나도록 해야 한다. 키-값 쌍으로 딕셔너리에 추가된 개체의 키 값은 변경되면 안되기 때문이다.

NSCache의 경우 키 객체에 대한 복사가 이루어지지 않는다.

사전 데이터 정렬

딕셔너리의 정렬 결과는 NSArray 배열 객체로 반환된다.

Swift의 딕셔너리의 정렬 결과는 키와 값을 튜플의 형태로 갖는 배열로 반환된다.

정렬 비교용 메소드를 만들어 넘기거나, NSComparator 타입 블록을 넘겨서 키 값을 정렬한다.

사전 데이터 필터링

블록을 사용하여 조건이 참인 키 값만 집합의 형태로 반환한다.

포인터 사전

NSMapTable

포인터 딕셔너리를 활용하여 약한 참조로 객체를 매칭하거나, 값에 객체가 아닌 포인터를 참조하고 싶거나, 키 값도 포인터를 참조하고 싶은 경우 사용할 수 있다.

포인터 딕셔너리를 생성하는 두 가지 방법이 있다.

NSMapTable 설정 옵션 지정

-initWithKeyOptions:valueOptions:capacity:

NSPointerFunctionsOptions 설정 값을 지정하며, 키와 값에 대해 각각 다른 옵션을 설정할 수 있다.

함수 포인터 방식

-initWithKeyPointerFunctions:valuePointerFunctions:capacity:

NSPointerFunctions를 사용하여 키에 대한 함수 포인터와 값에 대한 함수 포인터를 따로 지정한다.

사전 성능 특성

NSDictionaryCFDictionary와 무비용 연결될 수 있다. 이 둘은 성능상 동일한 복잡도를 갖는다.

빠른 탐색이 성능상 효과적이다.

Swift 사전

Dictionary는 구조체. 네이티브 저장 방식과 Cocoa 저장 방식을 모두 구현하고 있다.

네이티브 사전 저장 구조

  • 딕셔너리 내부의 여러 종류 타입의 값을 저장하기 위한 _VariantDictionaryStorage 데이터가 _NativeDictionaryStorageOwner 클래스를 참조한다.
  • _NativeDictionaryStorageOwner 클래스는 Swift 네이티브 타입을 저장할 때는 _NativeDictionaryStorageImpl 클래스를 참조하도록 내부 저장 구조를 바꾼다.
    • NativeDictionaryStorageOwner 클래스는 Objective-C 객체를 저장하기 위해 NSDictionary 클래스와 연결 가능하도록 NSDictionary를 상속받아 구현되었다.

Cocoa 연결 저장 구조

  • 딕셔너리 내부의 여러 종류 타입의 값을 저장하기 위한 _VariantDictionaryStorage 데이터에 있는 _CocoaDictionaryStorage 구조체가 NSDictionary 클래스를 참조한다.

네이티브 딕셔너리를 NSDictionary와 연결하려면 먼저 키와 값 모두 연결된 상태여야 한다.

Dictionary<K, V>.Index로 색인 접근이 가능하다. 이를 통해 바로 이전/다음 요소를 추가할 때 메모리를 재할당하지 않고 곧바로 저장할 수 있다.

하지만 여러 스레드에서 동시에 접근하는 경우나 딕셔너리 메모리 버퍼에 대한 경계 검사를 하는 경우에 이는 좋은 방법이 아니다.

요약

NSDictionary는 키와 값을 짝지어 다루는 데이터 구조.

키 값은 고유해야 하므로 키 값으로 사용될 수 있는 객체의 특성을 이해해야 한다.

키와 값을 객체가 아닌 포인터 타입으로 지정하려면 NSMapTable을 사용할 수 있다. (iOS에서는 키나 값에 객체가 아닌 포인터를 지정할 수 없다.)

순서가 없는 집합 컬렉션

NSSet, 집합. 순서 없이 동일한 객체를 중복 참조하지 않는 컬렉션.

컬렉션 내부 참조 객체의 존재 여부를 확인하는 경우에는 배열보다는 집합을 사용하는 것이 성능상 더 좋다.

집합 활용 방법

  • NSSet : 불변 집합. CFSetRef와 무비용 연결.
  • NSMutableSet : 가변 집합. CFMutableSetRef와 무비용 연결.
  • NSCountedSet : 동일한 객체가 중복해서 들어갈 수 있는 집합. CFBagRef와 무비용 연결.

가변 집합과 불변 집합

-hash-isEqual 메소드를 통해 객체 간 동일성을 판단한다.

집합에 변경을 가해야 할 때뿐만 아니라, 집합 요소가 너무 큰 경우에도 가변 집합을 사용하는 것이 좋다.

참조 객체는 해당 객체를 복사하는 것이 아닌, 해당 객체에 대한 소유권을 획득하여 참조하는 형태다. ARC가 아닌 환경에서는 -retain 메소드를 통해 소유권을 획득한다.

참조할 객체를 복사하려면 -initWithSet:copyItems와 같은 초기화 메소드를 사용할 수 있다.

집합 내부에 동일한 객체를 여러 번 참조하도록 하려면 NSCountedSet을 사용하는 것이 좋다.

집합 연산과 중복 가능 집합(NSCountedSet)

  • unionSet: : 합집합 연산
  • minusSet: : 차집합 연산
  • intersectSet : 교집합 연산

NSCountedSet의 경우 집합 연산은 다음과 깉이 이루어진다.

  • 합집합 연산의 경우, 중복된 항목이 있는 경우 해당 항목에 대한 카운트가 올라간다.
  • 교집합 연산의 경우, 양쪽에 적어도 하나만 있으면 교집합에 추가되고, 양쪽 모두에 중복해서 여러 개 있는 경우 카운트가 올라간다.
  • 차집합 연산의 경우, 집합 내부 카운트끼리 빼기가 되어 카운트가 1보다 큰 경우만 집합에 남는다.

포인터 집합

NSHashTable. 포인터 값을 집합 형태로 저장하기 위해 사용한다.

요소를 약한 참조하고 싶거나 객체가 아닌 포인터를 참조하고 싶은 경우 NSHashTable을 사용할 수 있다.

포인터 집합을 생성하는 두 가지 방법이 있다.

설정 옵션 지정

-initWithOptions:capacity:

NSPointerFunctionsOptions 설정 값을 지정한다. nil 포인터는 추가할 수 없다.

함수 포인터 지정

-initWithPointerFunctions:capacity:

NSPointerFunctions를 활용하여 함수 포인터를 넘겨준다.

사전 성능 특성

NSSet과 무비용 연결 가능한 CFBag와는 성능상 동일한 복잡도를 가진다.

요소 포함 여부(containsObject:)를 판단하는 경우 NSArray는 O(N2)의 복잡도를 보여주나 NSSet의 경우 O(N)의 복잡도를 보여주었다.

따라서 객체 포함 여부를 판단하는 경우에는 집합 계열 컬렉션을 사용하는 것이 좋다.

Swift 집합

Set. 배열이나 딕셔너라 타입보다 상대적으로 성능 특성이나 최적화에 대한 고려가 덜 되어 있다.

내부에 데이터 요소를 저장하는 _VariantStorage 열거 타입 변수를 가지고 있으며, 이것이 .native이면 네이티브 저장소를 만들고, .cocoa이면 NSSet 객체를 연결하여 참조한다.

NSSet과 연결하기 위해 as 연산자를 사용할 수 있으며, 집합에 저장하는 요소가 반드시 클래스 타입이거나 @objc 프로토콜 속성을 만족해야 한다.

이러한 경우 NSSet과 연결할 때의 복잡도는 O(1)이며, 집합의 요소가 클래스 타입이 아니거나 @objc 프로토콜 속성을 만족하지 않는 경우 O(N)의 복잡도를 갑는다.

NSSetSet과 연결하는 복잡도는 O(1)인데, 복사된 불변 객체를 Swift 네이티브 집합에 참조하도록 하는 과정만 거치기 때문이다.

요약

NSSet 계열 집합 컬렉션은 순서 없이 여러 객체를 집합에 넣고 포함되어 있는지를 판단하기 위한 데이터 구조로 많이 사용된다.

NSCountedSet을 사용하여 집합에 객체가 여러 번 들어가도록 할 수 있다.

NSHashTable을 사용하여 객체가 아닌 포인터 타입을 집합에 보관하도록 할 수 있다. (iOS에서는 포인터 타입을 사용할 수 없다.)

집합 변형 컬렉션

배열 인덱스를 저장하는 인덱스 집합

NSIndexSet. 집합의 특성을 그대로 보유하며, 고유한 인덱스 값의 집합이다. 각 인덱스는 NSRange 구조체를 활용한다.

인덱스 집합 활용 방법

불변 객체 NSIndexSet과 가변 객체 NSMutableIndexSet을 제공한다.

-addIndex:-addIndexsInRange:처럼 Int 또는 NSRange를 사용하여 집합에 인덱스 값이나 범위를 추가할 수 있다.

순서가 있는 집합

NSOrderedSet. 집합의 특성과 배열의 특성을 모두 갖는다.

Core Data 내부적으로 이것을 사용한다고 알려져 있다.

배열처럼 순차 접근이 가능하고, 집합이므로 포함 연산에 용이하다. 포함 연산의 경우 NSSet과 비교하여 약 10%의 성능 부하가 있다.

요약

NSIndexSetNSArray 내부의 특정 객체들에 대한 인덱스를 저장하기 적합하다. NSArrayNSIndexSet을 만들어내는 메소드를 가지고 있다.

NSOrderedSet은 배열의 특성과 집합의 특성을 모두 가지고 있으며 NSArrayNSSet보다 성능이 조금 좋지 않다.