Objective-C · 기본
Objective-C 문법과 Cocoa 기초
컬렉션 - NSArray·NSDictionary·NSSet
리터럴 @[] @{}, 불변과 가변, 객체만 담을 수 있는 이유, 빠른 열거(for in), 정렬(sortedArrayUsingComparator), 경량 제네릭 NSArray<NSString *>
개발자KR · 원고 갱신
이 장에서 배우는 것
도서 대출 관리 도구는 어디서나 "여러 개"를 다룬다. 서가에 꽂힌 도서 여러 권, 대출 중인 책 목록, 책 제목별 재고, 대출한 회원의 집합이 그렇다. 앞 장에서 문자열과 숫자를 객체로 다루는 법을 익혔으므로, 이 장에서는 그 객체들을 묶어 두는 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)에 따로 있다. 칸 하나가 가리키는 대상의 크기가 달라도 배열은 신경 쓰지 않는다. 문자열이든 숫자든 날짜든 포인터 크기만 같으면 한 배열에 섞어 담을 수 있다.
컬렉션이 객체만 받는 이유는 하나 더 있다. 컬렉션은 담긴 객체를 계속 쓸 수 있도록 소유한다. 이 소유는 ARC 가 맡아 주고 메모리 관리를 다룬 장에서 본 규칙을 따른다. 소유와 해제는 객체에만 있는 개념이라서 int나 double 같은 C 값에는 적용할 수 없다. 그래서 숫자를 담으려면 @3, @(count + 1)처럼 NSNumber 객체로 감싸야 하고, 꺼낸 뒤에는 integerValue 같은 메시지로 C 값을 얻는다.
또 하나의 제약은 nil이다. nil은 "객체 없음"이라서 컬렉션 칸에 넣을 수 없다. @[@"a", 값이_nil인_변수]는 실행 중에 예외를 일으킨다. "빈 칸"을 표현해야 한다면 그 용도로 만들어진 싱글턴 객체 [NSNull null]을 넣는다. 꺼낼 때 item == [NSNull null]인지 비교하면 된다.
불변과 가변
각 컬렉션에는 불변 클래스와 그 하위 클래스인 가변(mutable) 클래스가 있다.
| 용도 | 불변 클래스 | 가변 클래스 | 리터럴 |
|---|---|---|---|
| 순서 있는 목록 | NSArray | NSMutableArray | @[ ] |
| 키로 찾는 사전 | NSDictionary | NSMutableDictionary | @{ } |
| 중복 없는 모음 | NSSet | NSMutableSet | 없음 |
가변 객체는 [NSMutableArray array]처럼 빈 상태로 만들어 addObject:, insertObject:atIndex:, removeObject:로 바꾼다. 가변 딕셔너리는 dict[키] = 값으로 넣는다. 리터럴이 불변이라는 점이 자주 헷갈리는 곳이다. NSMutableArray *list = @[...]처럼 받으면 컴파일러가 경고하고, 실행하면 addObject:를 보내는 순간 "그런 메시지를 이해하지 못한다"는 예외가 난다. 불변 컬렉션에서 가변 컬렉션을 얻으려면 mutableCopy를 쓴다.
반대로 copy는 가변 컬렉션을 불변 컬렉션으로 복사한다. 이때 복사되는 것은 컬렉션 자신, 즉 칸들뿐이다. 칸이 가리키는 객체는 복사하지 않고 같은 객체를 함께 가리킨다. 이를 얕은 복사(shallow copy)라고 한다. 아래 그림은 copy 직후와 removeObject: 후의 상태를 비교한 것이다.
순회와 정렬
빠른 열거 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 *> | 컴파일 때만 점검 |
연습 문제
- 다음 코드는 경고를 낸다. 이유를 설명하고 고쳐 쓰라.
NSMutableArray<NSString *> *members = @[@"kim", @"lee"]; [members addObject:@"park"]; - 본문의 쪽수 정렬 블록을 고쳐 쪽수가 많은 책이 먼저 오도록 하라. 바꿀 부분은 어디인가.
NSArray<NSString *> *ids = @[@"a", @"b", @"a"];가 있을 때[NSSet setWithArray:ids].count와ids.count는 각각 얼마인가.- 가변 배열
loans에서 "Cocoa 기초"로 시작하는 원소를 모두 지우는 코드를for in으로 안전하게 써 보라.
정답과 해설
- 리터럴
@[ ]는 불변NSArray를 만드는데 변수는NSMutableArray이므로 타입이 맞지 않는다. 경고를 무시하고 실행하면addObject:단계에서 예외가 난다. 고친 코드는NSMutableArray<NSString *> *members = [@[@"kim", @"lee"] mutableCopy];이다. - 왼쪽과 오른쪽을 맞바꿔
[b compare:a]를 반환한다. 본문 블록에서return [a compare:b];한 줄만return [b compare:a];로 바꾸면 된다. 결과 순서는 알고리즘 노트, Cocoa 기초, 객체지향 입문, C 포인터다. - 집합은 같은 문자열을 한 번만 남기므로
[NSSet setWithArray:ids].count는 2이고, 배열의ids.count는 3이다. - 복사본을 순회한다.
순회 대상은for (NSString *title in [loans copy]) { if ([title hasPrefix:@"Cocoa 기초"]) { [loans removeObject:title]; } }copy가 만든 불변 사본이고 지우는 대상은 원본loans라서 순회 중 변경 예외가 나지 않는다.
컬렉션 클래스의 전체 메서드 목록은 Apple 의 NSArray 문서에서 확인할 수 있다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.