Devin.KR

컬렉션 - NSArray·NSDictionary·NSSet

개발자KR 조회 0

이 장에서 배우는 것

도서 대출 관리 도구는 어디서나 "여러 개"를 다룬다. 서가에 꽂힌 도서 여러 권, 대출 중인 책 목록, 책 제목별 재고, 대출한 회원의 집합이 그렇다. 앞 장에서 문자열과 숫자를 객체로 다루는 법을 익혔으므로, 이 장에서는 그 객체들을 묶어 두는 Foundation 의 컬렉션(collection) 클래스 세 가지를 다룬다. 순서가 있는 NSArray, 키로 값을 찾는 NSDictionary, 중복 없이 모아 두는 NSSet이다.

  • 리터럴(literal) @[], @{}로 배열과 딕셔너리를 한 줄에 만들고 첨자 문법으로 꺼낸다.
  • 불변 클래스와 가변 클래스를 구분하고, copy가 만드는 스냅샷의 성질을 안다.
  • 컬렉션이 객체만 담을 수 있는 이유를 설명하고, 숫자와 빈 값을 담는 방법을 안다.
  • 빠른 열거(fast enumeration) for ... in으로 순회하고, 순회 중 수정이 왜 막혀 있는지 안다.
  • sortedArrayUsingComparator:로 정렬하고, 경량 제네릭(lightweight generics) NSArray<NSString *>로 원소 타입을 적는다.

문제 상황

대출 도구에 도서 네 권을 넣고, 쪽수가 적은 책부터 보여 주고, 제목별 재고를 관리하고, 대출한 회원이 몇 명인지 세야 한다고 하자. C 언어의 배열로 풀면 막히는 곳이 많다. C 배열은 길이가 만들 때 정해지고, 한 가지 타입만 담으며, 길이 정보를 배열 자신이 갖고 있지 않다. 책 한 권은 제목(문자열)과 쪽수(정수)를 함께 가져야 하는데 C 배열 하나로는 이를 표현하기 어렵다. 재고를 제목으로 찾으려면 직접 검색 함수를 만들어야 하고, 같은 회원이 두 번 대출했을 때 한 명으로 세는 일도 직접 구현해야 한다.

Foundation 은 이 문제들을 클래스로 풀어 둔다. 길이가 자동으로 늘어나는 배열, 키로 찾는 사전, 중복을 걸러 주는 집합이 이미 있고, 그 안에 어떤 객체든 담을 수 있다. 이 장은 그 세 가지를 쓰는 방법과 함정을 다룬다.

배열과 딕셔너리: 리터럴과 첨자

리터럴로 만들기

@[ ]는 배열, @{ }는 딕셔너리를 만든다. 딕셔너리는 @{키: 값, ...} 형태이고, 키로는 보통 NSString을 쓴다.

NSArray *titles = @[@"Cocoa 기초", @"C 포인터"];
NSDictionary *book = @{@"title": @"Cocoa 기초", @"pages": @320};
NSString *first = titles[0];
NSNumber *pages = book[@"pages"];

꺼낼 때도 대괄호를 쓴다. 배열은 정수 위치, 딕셔너리는 키를 넣는다. 이 문법은 각각 objectAtIndex:와 objectForKey: 메시지를 보내는 것의 줄임 표기다. 범위를 벗어난 위치를 쓰면 프로그램이 예외(exception)를 일으키며 종료되므로, 위치는 count 이하인지 확인한 뒤 쓴다. 딕셔너리에서 없는 키를 찾으면 오류가 아니라 nil이 돌아온다.

리터럴로 만든 컬렉션은 항상 불변(immutable)이다. 만든 뒤에 원소를 더하거나 뺄 수 없다. Set 에는 리터럴이 없어서 [NSSet setWithArray:]나 [NSSet setWithObjects:..., nil]로 만든다.

왜 객체만 담을 수 있는가

C 배열의 칸은 타입 크기만큼 자리를 차지한다. int 배열의 칸은 4바이트, double 배열의 칸은 8바이트다. 반면 NSArray의 칸은 모두 같은 크기의 객체 포인터이고, 실제 객체는 힙(heap)에 따로 있다. 칸 하나가 가리키는 대상의 크기가 달라도 배열은 신경 쓰지 않는다. 문자열이든 숫자든 날짜든 포인터 크기만 같으면 한 배열에 섞어 담을 수 있다.

NSArray 의 칸은 모두 객체를 가리키는 포인터이므로 정수는 NSNumber 로 감싸야 한다.

컬렉션이 객체만 받는 이유는 하나 더 있다. 컬렉션은 담긴 객체를 계속 쓸 수 있도록 소유한다. 이 소유는 ARC 가 맡아 주고 메모리 관리를 다룬 장에서 본 규칙을 따른다. 소유와 해제는 객체에만 있는 개념이라서 int나 double 같은 C 값에는 적용할 수 없다. 그래서 숫자를 담으려면 @3, @(count + 1)처럼 NSNumber 객체로 감싸야 하고, 꺼낸 뒤에는 integerValue 같은 메시지로 C 값을 얻는다.

또 하나의 제약은 nil이다. nil은 "객체 없음"이라서 컬렉션 칸에 넣을 수 없다. @[@"a", 값이_nil인_변수]는 실행 중에 예외를 일으킨다. "빈 칸"을 표현해야 한다면 그 용도로 만들어진 싱글턴 객체 [NSNull null]을 넣는다. 꺼낼 때 item == [NSNull null]인지 비교하면 된다.

불변과 가변

각 컬렉션에는 불변 클래스와 그 하위 클래스인 가변(mutable) 클래스가 있다.

컬렉션 세 가지와 가변 짝
용도불변 클래스가변 클래스리터럴
순서 있는 목록NSArrayNSMutableArray@[ ]
키로 찾는 사전NSDictionaryNSMutableDictionary@{ }
중복 없는 모음NSSetNSMutableSet없음

가변 객체는 [NSMutableArray array]처럼 빈 상태로 만들어 addObject:, insertObject:atIndex:, removeObject:로 바꾼다. 가변 딕셔너리는 dict[키] = 값으로 넣는다. 리터럴이 불변이라는 점이 자주 헷갈리는 곳이다. NSMutableArray *list = @[...]처럼 받으면 컴파일러가 경고하고, 실행하면 addObject:를 보내는 순간 "그런 메시지를 이해하지 못한다"는 예외가 난다. 불변 컬렉션에서 가변 컬렉션을 얻으려면 mutableCopy를 쓴다.

반대로 copy는 가변 컬렉션을 불변 컬렉션으로 복사한다. 이때 복사되는 것은 컬렉션 자신, 즉 칸들뿐이다. 칸이 가리키는 객체는 복사하지 않고 같은 객체를 함께 가리킨다. 이를 얕은 복사(shallow copy)라고 한다. 아래 그림은 copy 직후와 removeObject: 후의 상태를 비교한 것이다.

copy 로 만든 snapshot 은 원본 loans 를 나중에 고쳐도 변하지 않는다.

순회와 정렬

빠른 열거 for in

컬렉션의 모든 원소를 차례로 방문하는 가장 흔한 방법이 for ... in이다.

for (NSString *title in titles) {
    printf("%s\n", [title UTF8String]);
}

변수 타입은 원소 타입을 적는다. NSArray는 저장된 순서대로 돌고, NSSet은 순서가 정해져 있지 않다. NSDictionary를 for in에 쓰면 키를 하나씩 돌려준다. 값은 dict[key]로 꺼낸다. 딕셔너리와 집합의 순회 순서는 실행 환경에 따라 다를 수 있으므로, 결과를 화면에 내보낼 때는 [[dict allKeys] sortedArrayUsingSelector:@selector(compare:)]처럼 키를 먼저 정렬하는 습관을 들인다. 그러면 출력이 매번 같아진다.

순회하는 동안 그 컬렉션 자체를 고치면 안 된다. 순회기가 내부 상태를 들고 있기 때문에, 고치면 "순회 중 변경되었다"는 예외가 난다. 지워야 할 원소는 따로 모은 뒤 순회가 끝난 다음에 지우거나, 복사본을 순회한다. 구체적인 코드는 뒤의 "자주 틀리는 것"에서 본다.

정렬

sortedArrayUsingComparator:는 정렬된 새 배열을 돌려준다. 원본은 그대로다. 인자로는 두 원소를 받아 NSComparisonResult를 돌려주는 코드 조각을 넘기는데, 이를 블록(block)이라 한다. 블록의 문법과 의미는 이 책의 뒤에서 따로 다루므로, 여기서는 "^NSComparisonResult(타입 *a, 타입 *b) { ... } 모양으로 비교 규칙을 적는 자리"로만 읽어 두면 된다.

비교 블록이 돌려주는 값의 의미
반환값뜻정렬 결과
NSOrderedAscending왼쪽이 앞이다왼쪽을 먼저 둔다
NSOrderedSame같다순서를 따지지 않는다
NSOrderedDescending왼쪽이 뒤다오른쪽을 먼저 둔다

NSNumber와 NSString은 compare: 메시지가 이 세 값 중 하나를 돌려주므로 그대로 반환하면 오름차순이 된다. 내림차순은 왼쪽과 오른쪽의 위치를 바꿔 [right compare:left]로 적는다. 가변 배열에는 제자리에서 정렬하는 sortUsingComparator:도 있다.

경량 제네릭

배열 안에 무엇이 들었는지는 변수 타입만 봐서는 알 수 없다. NSArray *는 "아무 객체의 배열"이다. 이를 알리려고 NSArray<NSString *>처럼 꺾쇠 안에 원소 타입을 적을 수 있다. 딕셔너리는 NSDictionary<NSString *, NSNumber *>처럼 키 타입과 값 타입을 쉼표로 나눠 적고, 아무 객체나 허용하려면 id를 쓴다.

이 표기는 컴파일할 때 점검하는 용도에 그친다. NSMutableArray<NSString *>에 addObject:@3을 하면 컴파일러가 경고하지만, 경고를 무시하면 실행은 된다. 또 for in 변수와 꺼낸 값의 타입이 자동으로 정해져서, 별도 캐스트 없이 title.length 같은 메시지를 안전하게 보낼 수 있다는 이점이 있다. 이 책에서는 지역 변수와 메서드 선언에 가능한 한 제네릭을 적는다.

집합

NSSet은 같은 객체를 두 번 넣어도 한 번만 남기고, 포함 여부를 빠르게 확인한다. 같은 객체인지는 isEqual:로 판단하므로 문자열은 내용이 같으면 같은 원소다. 두 집합의 공통 원소는 가변 집합의 intersectSet:으로 구한다. 순서가 없으므로 첨자나 첫 번째 원소 같은 개념이 없고, 출력할 때는 allObjects를 정렬해서 쓴다.

어떤 컬렉션을 고를까
알고 싶은 것고를 것이유
n 번째 책은 무엇인가NSArray순서와 위치를 유지한다
제목으로 재고를 찾는다NSDictionary키로 바로 접근한다
이 회원이 대출한 적 있는가NSSet중복을 걸러 주고 포함 여부가 빠르다

완성 코드

아래는 도서·재고·회원 집합을 한 프로그램에서 다루는 main.m 한 파일이다. 결과는 NSLog 대신 printf로 낸다. NSLog는 줄 앞에 날짜, 시각, 프로세스 이름과 번호를 붙이고 표준 오류(stderr)로 내보낸다. 로그로는 쓸모가 있지만, 결과를 파이프로 넘기거나 파일로 저장해 비교할 때는 출력이 섞이고 시각이 달라져서 곤란하다. 그래서 NSString을 C 문자열로 바꿔 주는 UTF8String과 printf를 조합한 작은 함수 say를 만들어 쓴다.

main.m

#import <Foundation/Foundation.h>

static void say(NSString *line) {
    printf("%s\n", [line UTF8String]);
}

static NSString *joinedSorted(NSSet<NSString *> *set) {
    NSArray<NSString *> *items =
        [[set allObjects] sortedArrayUsingSelector:@selector(compare:)];
    return [items componentsJoinedByString:@", "];
}

int main(void) {
    @autoreleasepool {
        NSArray<NSDictionary<NSString *, id> *> *books = @[
            @{@"title": @"Cocoa 기초", @"pages": @320},
            @{@"title": @"C 포인터", @"pages": @210},
            @{@"title": @"알고리즘 노트", @"pages": @450},
            @{@"title": @"객체지향 입문", @"pages": @280}
        ];
        say([NSString stringWithFormat:@"등록된 도서: %lu권",
             (unsigned long)books.count]);
        say([NSString stringWithFormat:@"첫 번째 도서: %@", books[0][@"title"]]);

        NSInteger totalPages = 0;
        for (NSDictionary<NSString *, id> *book in books) {
            totalPages += [book[@"pages"] integerValue];
        }
        say([NSString stringWithFormat:@"전체 쪽수: %ld", (long)totalPages]);

        NSArray<NSDictionary<NSString *, id> *> *byPages =
            [books sortedArrayUsingComparator:
                ^NSComparisonResult(NSDictionary<NSString *, id> *left,
                                    NSDictionary<NSString *, id> *right) {
                    NSNumber *a = left[@"pages"];
                    NSNumber *b = right[@"pages"];
                    return [a compare:b];
                }];
        for (NSDictionary<NSString *, id> *book in byPages) {
            say([NSString stringWithFormat:@"%@ - %@쪽", book[@"title"], book[@"pages"]]);
        }

        NSMutableArray<NSString *> *loans = [NSMutableArray array];
        [loans addObject:@"Cocoa 기초"];
        [loans addObject:@"C 포인터"];
        [loans insertObject:@"알고리즘 노트" atIndex:0];
        NSArray<NSString *> *snapshot = [loans copy];
        [loans removeObject:@"C 포인터"];
        say([NSString stringWithFormat:@"대출 중: %@ (%lu건)",
             [loans componentsJoinedByString:@", "], (unsigned long)loans.count]);
        say([NSString stringWithFormat:@"스냅샷: %@ (%lu건)",
             [snapshot componentsJoinedByString:@", "], (unsigned long)snapshot.count]);

        NSArray *shelf = @[@"1번 칸", [NSNull null], @"3번 칸"];
        for (id item in shelf) {
            NSString *text = (item == [NSNull null]) ? @"비어 있음" : item;
            say([NSString stringWithFormat:@"서가: %@", text]);
        }

        NSMutableDictionary<NSString *, NSNumber *> *stock = [NSMutableDictionary dictionary];
        stock[@"Cocoa 기초"] = @3;
        stock[@"C 포인터"] = @1;
        stock[@"알고리즘 노트"] = @2;
        stock[@"객체지향 입문"] = @0;
        stock[@"Cocoa 기초"] = @([stock[@"Cocoa 기초"] integerValue] - 1);

        NSArray<NSString *> *keys =
            [[stock allKeys] sortedArrayUsingSelector:@selector(compare:)];
        for (NSString *key in keys) {
            say([NSString stringWithFormat:@"재고 %@: %@권", key, stock[key]]);
        }
        NSInteger totalStock = 0;
        for (NSString *key in stock) {
            totalStock += [stock[key] integerValue];
        }
        say([NSString stringWithFormat:@"총 재고: %ld권", (long)totalStock]);
        NSNumber *missing = stock[@"없는 책"];
        say(missing == nil ? @"없는 책: 키 없음" : @"없는 책: 있음");

        NSMutableSet<NSString *> *borrowers = [NSMutableSet set];
        [borrowers addObject:@"kim"];
        [borrowers addObject:@"lee"];
        [borrowers addObject:@"kim"];
        say([NSString stringWithFormat:@"대출 회원: %@ (%lu명)",
             joinedSorted(borrowers), (unsigned long)borrowers.count]);
        say([borrowers containsObject:@"kim"] ? @"kim 대출 이력: 예"
                                              : @"kim 대출 이력: 아니오");

        NSSet<NSString *> *overdue = [NSSet setWithObjects:@"lee", @"park", nil];
        NSMutableSet<NSString *> *both = [borrowers mutableCopy];
        [both intersectSet:overdue];
        say([NSString stringWithFormat:@"연체와 겹치는 회원: %@", joinedSorted(both)]);
    }
    return 0;
}

줄별 해설

say와 joinedSorted: say는 문자열 하나를 받아 printf로 한 줄 내보낸다. joinedSorted는 집합의 원소를 배열로 꺼내 정렬하고 ", "로 이어 붙인다. 집합은 순서가 없으므로 출력 전에 정렬해야 결과가 일정하다. @selector(compare:)는 "각 원소에 compare:를 보내 비교하라"는 뜻이다.

도서 배열: 바깥은 @[ ], 안쪽은 도서 한 권을 나타내는 @{ }다. 쪽수는 @320처럼 NSNumber로 감쌌다. 값에 문자열과 숫자가 섞이므로 값 타입은 id로 적었다. books[0][@"title"]은 첫 번째 도서를 꺼낸 뒤 그 딕셔너리에서 제목을 꺼낸다. count의 타입이 NSUInteger이므로 %lu와 unsigned long 캐스트를 짝지었다.

합계 순회: for in이 한 권씩 book에 넣어 준다. integerValue로 NSNumber를 C 정수로 바꿔 더한다. 320 + 210 + 450 + 280 이므로 1260 이다.

정렬: 블록은 두 도서를 받아 쪽수끼리 compare:한다. 쪽수가 적은 도서가 앞에 오는 오름차순이다. 결과는 새 배열 byPages에 담기고 books는 바뀌지 않는다.

가변 배열과 스냅샷: insertObject:atIndex:로 0번 위치에 끼워 넣어 순서는 알고리즘 노트, Cocoa 기초, C 포인터가 된다. copy로 불변 사본을 만든 뒤 C 포인터를 지우면, loans는 2건이고 snapshot은 3건으로 남는다.

NSNull: shelf의 가운데 칸은 비어 있다는 표시로 [NSNull null]을 넣었다. 원소 타입이 제각각이므로 변수는 id다. item == [NSNull null]은 포인터 비교인데, NSNull 객체가 프로그램에 하나뿐이어서 이렇게 비교해도 된다.

재고 딕셔너리: 첨자로 키마다 값을 넣는다. 같은 키에 다시 넣으면 값이 교체되므로, 현재 값에서 1을 뺀 숫자를 @(...)로 다시 감싸 넣어 한 권 대출을 표현했다. 출력은 키를 정렬한 배열로 돌렸다. 총 재고를 구하는 순회는 순서가 결과에 영향을 주지 않으므로 정렬하지 않았다. 없는 키 @"없는 책"은 nil을 돌려주므로 == nil로 확인한다.

집합: "kim"을 두 번 넣었지만 원소 수는 2다. mutableCopy로 가변 사본 both를 만들고 intersectSet:으로 연체 집합과 겹치는 원소만 남기면 "lee"만 남는다. borrowers 자체는 수정되지 않는다.

실행 결과

$ clang -fobjc-arc -Wall -framework Foundation *.m -o app
$ ./app
등록된 도서: 4권
첫 번째 도서: Cocoa 기초
전체 쪽수: 1260
C 포인터 - 210쪽
객체지향 입문 - 280쪽
Cocoa 기초 - 320쪽
알고리즘 노트 - 450쪽
대출 중: 알고리즘 노트, Cocoa 기초 (2건)
스냅샷: 알고리즘 노트, Cocoa 기초, C 포인터 (3건)
서가: 1번 칸
서가: 비어 있음
서가: 3번 칸
재고 C 포인터: 1권
재고 Cocoa 기초: 2권
재고 객체지향 입문: 0권
재고 알고리즘 노트: 2권
총 재고: 5권
없는 책: 키 없음
대출 회원: kim, lee (2명)
kim 대출 이력: 예
연체와 겹치는 회원: lee

실무에서 자주 틀리는 것

1. 리터럴에 nil 이 들어간다

변수가 nil일 수 있는데 그대로 리터럴에 넣으면 실행 중에 예외가 난다.

NSString *memo = nil;
NSArray *row = @[@"Cocoa 기초", memo];   // 실행 중 예외

빈 값을 표현해야 하면 NSNull로 바꿔 넣는다.

NSString *memo = nil;
NSArray *row = @[@"Cocoa 기초", memo ?: [NSNull null]];

a ?: b는 a가 nil(또는 0)이 아니면 a, 아니면 b를 쓰는 C 확장 문법이다.

2. 순회하면서 컬렉션을 고친다

for (NSString *title in loans) {
    if ([title hasPrefix:@"C"]) {
        [loans removeObject:title];   // 순회 중 변경 예외
    }
}

복사본을 돌면서 원본을 고친다.

for (NSString *title in [loans copy]) {
    if ([title hasPrefix:@"C"]) {
        [loans removeObject:title];
    }
}

3. 리터럴을 가변 배열처럼 쓴다

NSMutableArray<NSString *> *list = @[@"Cocoa 기초"];   // 경고
[list addObject:@"C 포인터"];   // 실행 중 예외

리터럴은 불변이므로 mutableCopy로 바꾼다.

NSMutableArray<NSString *> *list = [@[@"Cocoa 기초"] mutableCopy];
[list addObject:@"C 포인터"];

4. 정렬 결과를 받지 않는다

[books sortedArrayUsingComparator:^NSComparisonResult(id a, id b) {
    return [a[@"pages"] compare:b[@"pages"]];
}];
// books 는 그대로다

sortedArray...로 시작하는 메서드는 새 배열을 돌려준다. 결과를 변수에 담는다. 같은 배열을 제자리에서 정렬하려면 가변 배열의 sortUsingComparator:를 쓴다.

NSArray *byPages = [books sortedArrayUsingComparator:^NSComparisonResult(id a, id b) {
    NSNumber *x = a[@"pages"];
    NSNumber *y = b[@"pages"];
    return [x compare:y];
}];

한눈에 보기

이 장의 문법과 메시지
하고 싶은 일쓰는 것비고
배열 만들기@[a, b]불변, nil 불가
딕셔너리 만들기@{k: v}불변, 키는 보통 NSString
원소 꺼내기arr[i], dict[key]범위 밖 위치는 예외
숫자 담기@3, @(expr)NSNumber 로 감싼다
빈 칸 표시[NSNull null]== 로 비교한다
가변 사본 / 불변 사본mutableCopy / copy얕은 복사
순회for (T *x in c)순회 중 수정 금지
정렬된 새 배열sortedArrayUsingComparator:원본은 그대로
원소 타입 적기NSArray<NSString *>컴파일 때만 점검

연습 문제

  1. 다음 코드는 경고를 낸다. 이유를 설명하고 고쳐 쓰라.
    NSMutableArray<NSString *> *members = @[@"kim", @"lee"];
    [members addObject:@"park"];
  2. 본문의 쪽수 정렬 블록을 고쳐 쪽수가 많은 책이 먼저 오도록 하라. 바꿀 부분은 어디인가.
  3. NSArray<NSString *> *ids = @[@"a", @"b", @"a"];가 있을 때 [NSSet setWithArray:ids].count와 ids.count는 각각 얼마인가.
  4. 가변 배열 loans에서 "Cocoa 기초"로 시작하는 원소를 모두 지우는 코드를 for in으로 안전하게 써 보라.

정답과 해설

  1. 리터럴 @[ ]는 불변 NSArray를 만드는데 변수는 NSMutableArray이므로 타입이 맞지 않는다. 경고를 무시하고 실행하면 addObject: 단계에서 예외가 난다. 고친 코드는 NSMutableArray<NSString *> *members = [@[@"kim", @"lee"] mutableCopy];이다.
  2. 왼쪽과 오른쪽을 맞바꿔 [b compare:a]를 반환한다. 본문 블록에서 return [a compare:b]; 한 줄만 return [b compare:a];로 바꾸면 된다. 결과 순서는 알고리즘 노트, Cocoa 기초, 객체지향 입문, C 포인터다.
  3. 집합은 같은 문자열을 한 번만 남기므로 [NSSet setWithArray:ids].count는 2이고, 배열의 ids.count는 3이다.
  4. 복사본을 순회한다.
    for (NSString *title in [loans copy]) {
        if ([title hasPrefix:@"Cocoa 기초"]) {
            [loans removeObject:title];
        }
    }
    순회 대상은 copy가 만든 불변 사본이고 지우는 대상은 원본 loans라서 순회 중 변경 예외가 나지 않는다.

컬렉션 클래스의 전체 메서드 목록은 Apple 의 NSArray 문서에서 확인할 수 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.