메시지 보내기 - 대괄호 문법
이 장에서 배우는 것
Objective-C 에서 객체에게 일을 시키는 방법은 하나다. 객체에 메시지(message)를 보내는 것이다. 이 장에서는 그 표기법인 대괄호 문법을 처음부터 끝까지 익힌다. 앞 장에서 C 타입과 객체 타입을 구분했으므로, 이제 객체 타입 변수가 가리키는 대상에게 무엇을 어떻게 요청하는지 다룬다. 예제는 사내 도서 대출 관리 도구의 일부, 곧 도서 번호를 만들고 대출 목록을 쌓고 회원을 조회하는 작업이다.
[수신자 메시지]형태를 읽고 쓸 수 있다.- 콜론이 붙은 메시지 이름에서 인자의 개수와 순서를 알아낼 수 있다.
- 메시지를 중첩해 쓰고, 안쪽부터 평가되는 순서를 설명할 수 있다.
- 클래스 메서드와 인스턴스 메서드를 표기와 수신자로 구분할 수 있다.
nil에게 보낸 메시지의 결과를 예측하고,@selector와respondsToSelector:로 객체가 메시지를 받을 수 있는지 확인할 수 있다.
문제 상황
대출 관리 도구에 회원 조회 기능을 붙인다고 하자. 회원 번호로 이름을 찾고, 찾은 이름을 대문자로 바꾸거나 글자 수를 세어 화면에 보여 주는 단순한 작업이다. C 에서는 이름을 담은 포인터가 NULL 인지 먼저 확인하지 않으면 함수 안에서 프로그램이 곧바로 비정상 종료한다. 그래서 C 프로그래머는 호출 앞에 검사 코드를 습관처럼 넣는다.
Objective-C 는 호출 방식이 다르다. 함수를 부르는 것이 아니라 객체에 메시지를 보내고, 받는 쪽이 nil 이어도 프로그램이 멈추지 않는다. 이 성질은 편리하지만, 조회에 실패한 사실이 조용히 묻힐 수도 있다. 또 같은 대괄호 안에서 콜론이 어디에 붙는지, 클래스에 보내는 메시지와 객체에 보내는 메시지가 어떻게 다른지 모르면 코드를 읽는 것부터 막힌다. 이 장은 이런 지점을 하나씩 정리한다.
대괄호 문법의 기본 형태
수신자와 메시지
메시지 전송의 기본 모양은 [수신자 메시지] 다. 수신자(receiver)는 메시지를 받는 객체이고, 메시지는 그 객체에게 요청하는 동작의 이름이다. 아래 코드는 문자열 객체에 글자 수를 묻는다.
NSString *title = @"Objective-C 입문";
NSUInteger length = [title length];
title 이 수신자이고 length 가 메시지다. 이 메시지에 해당하는 코드는 수신자가 속한 클래스 안에 있으며, 어느 코드를 실행할지는 프로그램이 돌아가는 중에 정해진다. C 의 함수 호출처럼 컴파일할 때 호출 대상이 하나로 고정되지 않는다. 수신자가 메시지를 처리하는 방법을 모르면 실행 중에 오류가 난다. 이 점은 뒤에서 respondsToSelector: 와 함께 다시 본다.
이 책의 예제에서는 title.length 같은 점(dot) 표기를 쓰지 않는다. 점 표기는 프로퍼티라는 별도 기능과 묶여 있으므로 그 주제를 다루는 곳에서 설명한다. 이 장의 모든 호출은 대괄호로 쓴다.
인자가 있는 메시지와 콜론
메시지에 값을 함께 넘기려면 이름 뒤에 콜론(:)을 붙이고 그 뒤에 값을 쓴다. 콜론은 이름의 일부이며 인자가 하나 있다는 표시다.
BOOL ok = [title hasPrefix:@"Objective"];
인자가 둘 이상이면 이름이 여러 조각으로 나뉘고, 조각마다 콜론과 인자가 하나씩 붙는다. C 처럼 쉼표로 인자를 나열하지 않는다.
[loans insertObject:@"BK-000" atIndex:0];
이 메시지의 이름은 insertObject:atIndex: 전체다. insertObject: 와 atIndex: 가 따로 있는 두 메시지가 아니다. 인자가 이름 사이사이에 끼어 있어서 호출문을 영어 문장처럼 읽을 수 있고, 값이 무슨 역할인지 호출하는 쪽에서 바로 보인다. 아래 그림은 이 호출을 부분으로 나눈 것이다.
| 호출문 | 메시지 이름 | 인자 수 | 콜론 개수 |
|---|---|---|---|
[title length] | length | 0 | 0 |
[title hasPrefix:@"Obj"] | hasPrefix: | 1 | 1 |
[loans insertObject:x atIndex:0] | insertObject:atIndex: | 2 | 2 |
[title stringByReplacingOccurrencesOfString:a withString:b] | stringByReplacingOccurrencesOfString:withString: | 2 | 2 |
표에서 보듯 이름에 든 콜론의 개수가 곧 인자의 개수다. 코드를 읽다가 낯선 메시지를 만나면 콜론부터 센다.
중첩 메시지
메시지의 반환값은 객체일 수 있으므로, 그 결과에 곧바로 다음 메시지를 보낼 수 있다. 대괄호 안에 대괄호가 들어가는 이 형태를 중첩(nesting)이라 한다.
NSString *label = [[title uppercaseString] stringByAppendingString:@" (대출 가능)"];
평가는 안쪽 대괄호부터 한다. 먼저 [title uppercaseString] 이 대문자로 바꾼 새 문자열을 돌려주고, 그 문자열이 바깥 메시지 stringByAppendingString: 의 수신자가 된다. 인자 자리에도 메시지를 넣을 수 있다. 읽을 때는 가장 안쪽 대괄호를 찾아 거기서 바깥쪽으로 나오면 된다. 세 단계 넘게 겹치면 읽기 어려우므로 임시 변수에 나누어 담는 편이 낫다.
클래스 메서드와 인스턴스 메서드
메시지를 받을 수 있는 대상은 객체뿐이 아니다. 클래스 자체도 메시지를 받는다. 클래스 이름을 수신자 자리에 쓰면 클래스에게 보내는 메시지가 된다.
NSMutableArray *loans = [NSMutableArray array];
NSString *code = [NSString stringWithFormat:@"BK-%03d", 7];
클래스에 보내는 메시지에 대응하는 메서드를 클래스 메서드(class method)라 하고, 객체(인스턴스)에 보내는 메시지에 대응하는 메서드를 인스턴스 메서드(instance method)라 한다. 문서나 헤더에서는 선언 맨 앞의 기호로 구분한다. 클래스 메서드는 +, 인스턴스 메서드는 - 로 시작한다. 예를 들어 + array 는 NSMutableArray 클래스에 보내는 메시지이고, - count 는 만들어진 배열 객체에 보내는 메시지다.
| 구분 | 선언 기호 | 수신자 | 이 장의 예 |
|---|---|---|---|
| 클래스 메서드 | + | 클래스 이름 | [NSString stringWithFormat:...] |
| 인스턴스 메서드 | - | 객체를 가리키는 변수나 식 | [loans addObject:...] |
클래스 메서드는 주로 객체를 만들어 돌려주거나 객체 없이 할 수 있는 일에 쓰인다. 객체를 만드는 정식 절차인 alloc 과 init 은 메모리를 다룬 곳에서 따로 설명하므로, 여기서는 array 처럼 완성된 객체를 돌려주는 클래스 메서드만 쓴다. 두 종류를 혼동하면 [NSMutableArray addObject:@"x"] 처럼 클래스에 인스턴스 메서드를 보내는 오류가 나는데, 대개 컴파일 단계에서 걸린다.
nil 과 셀렉터
nil 에게 보낸 메시지
nil 은 어떤 객체도 가리키지 않는 객체 포인터 값이다. C 의 NULL 과 비슷하지만, nil 에게 메시지를 보내도 프로그램이 종료되지 않는다. 메시지는 아무 일도 하지 않고 지나가며, 값을 돌려받는 자리에는 0에 해당하는 값이 들어온다. 반환형이 객체면 nil, 숫자형이나 BOOL 이면 0 이다(NO 도 0 이다). 구조체를 돌려주는 메서드는 이 규칙에 기대지 않는 것이 좋다.
회원 조회에 실패한 경우가 전형적이다. 딕셔너리에서 없는 키를 찾으면 nil 이 나오고, 그 위에 length 를 보내면 0 이 나온다. 널 검사 없이도 안전하다는 점이 장점이다. 반대로 실패가 오류로 드러나지 않으므로, 조회 결과가 정말 있어야 하는 곳에서는 if (name == nil) 로 직접 확인해야 한다.
한 가지 경계가 있다. 메시지를 받는 쪽이 nil 인 것은 괜찮지만, nil 을 인자로 넘기는 것은 메서드마다 다르다. 배열의 addObject: 는 nil 인자를 받으면 예외를 일으켜 프로그램이 종료된다. 이 부분은 뒤의 틀리기 쉬운 사례에서 다시 다룬다.
셀렉터와 respondsToSelector:
메시지 이름은 실행 중에 문자열이 아닌 별도 타입 SEL 로도 다룰 수 있다. 이름을 @selector( ) 안에 쓰면 그 이름의 셀렉터(selector) 값이 만들어진다. 괄호 안에는 콜론까지 그대로 적는다.
SEL s = @selector(insertObject:atIndex:);
NSString *name = NSStringFromSelector(s);
셀렉터는 호출이 아니라 이름이다. 이 값만으로 메서드가 실행되지는 않는다. 셀렉터를 쓰는 가장 흔한 곳이 respondsToSelector: 다. 객체에게 이 이름의 메시지를 처리할 수 있는지 묻고 BOOL 로 답을 받는다.
if ([loans respondsToSelector:@selector(addObject:)]) {
[loans addObject:@"BK-003"];
}
수신자가 처리하지 못하는 메시지를 실행 중에 받으면 "unrecognized selector" 라는 예외가 나고 프로그램이 종료된다. 호출하기 전에 확인하면 이를 피할 수 있다. 수신자가 nil 이면 respondsToSelector: 자체도 nil 규칙에 따라 NO 를 돌려준다. 또 sortedArrayUsingSelector: 처럼 셀렉터를 인자로 받는 메서드도 있다. 이 장의 예제에서는 키를 정렬하는 데 @selector(compare:) 를 쓴다.
셀렉터로 메시지를 직접 보내는 performSelector: 도 있지만, 셀렉터가 변수이면 ARC 컴파일러가 반환형을 알 수 없어 경고를 낸다. 이 장에서는 쓰지 않는다. 메시지 이름을 확인하는 용도만 익히면 충분하다.
완성 코드
아래 프로그램은 앞에서 본 내용을 한 파일에 모았다. 클래스를 직접 정의하지 않고 Foundation 이 제공하는 NSString, NSMutableArray, NSDictionary 만 쓴다. 결과는 printf 로 표준 출력에 내보낸다. NSLog 는 앞에 시각과 프로세스 정보를 붙이고 표준 오류로 출력하므로, 결과를 파일로 저장하거나 다른 명령에 넘길 때 섞여 들어가 비교가 어려워진다. 그래서 이 책에서는 결과 출력에 쓰지 않는다. 컴파일은 clang -fobjc-arc -Wall -framework Foundation main.m -o app 로 한다.
main.m
#import <Foundation/Foundation.h>
static void show(NSString *label, NSString *value) {
printf("%s: %s\n", [label UTF8String], value ? [value UTF8String] : "(nil)");
}
static NSString *yesNo(BOOL flag) {
return flag ? @"YES" : @"NO";
}
int main(void) {
@autoreleasepool {
NSString *title = @"Objective-C 입문";
// 인자 없는 메시지와 인자 있는 메시지
NSUInteger length = [title length];
show(@"길이", [NSString stringWithFormat:@"%lu", (unsigned long)length]);
show(@"접두사 일치", yesNo([title hasPrefix:@"Objective"]));
show(@"교체", [title stringByReplacingOccurrencesOfString:@"입문" withString:@"기초"]);
// 중첩 메시지
show(@"중첩", [[title uppercaseString] stringByAppendingString:@" (대출 가능)"]);
// 클래스 메서드와 인스턴스 메서드
show(@"도서 번호", [NSString stringWithFormat:@"BK-%03d", 7]);
NSMutableArray *loans = [NSMutableArray array];
[loans addObject:@"BK-001"];
[loans addObject:@"BK-002"];
[loans insertObject:@"BK-000" atIndex:0];
show(@"대출 목록", [loans componentsJoinedByString:@", "]);
show(@"대출 건수", [NSString stringWithFormat:@"%lu", (unsigned long)[loans count]]);
// 딕셔너리는 키를 정렬해 출력
NSDictionary *members = @{@"m1": @"김하늘", @"m2": @"박도윤"};
NSArray *keys = [[members allKeys] sortedArrayUsingSelector:@selector(compare:)];
for (NSString *key in keys) {
show(key, [members objectForKey:key]);
}
// nil 에게 보낸 메시지
NSString *name = [members objectForKey:@"m9"];
show(@"m9 회원", name);
show(@"nil 길이", [NSString stringWithFormat:@"%lu", (unsigned long)[name length]]);
show(@"nil 대문자", [name uppercaseString]);
show(@"nil 접두사", yesNo([name hasPrefix:@"김"]));
// 셀렉터와 respondsToSelector:
show(@"셀렉터 이름", NSStringFromSelector(@selector(insertObject:atIndex:)));
SEL checks[3] = {@selector(count), @selector(addObject:), @selector(uppercaseString)};
for (int i = 0; i < 3; i++) {
printf("%s -> %s\n",
[NSStringFromSelector(checks[i]) UTF8String],
[yesNo([loans respondsToSelector:checks[i]]) UTF8String]);
}
printf("nil의 length -> %s\n", [yesNo([name respondsToSelector:@selector(length)]) UTF8String]);
}
return 0;
}
줄별 해설
show 함수. 레이블과 값을 레이블: 값 형태로 한 줄 출력하는 C 함수다. UTF8String 은 문자열 객체에서 C 문자열을 꺼내는 메시지다. 값이 nil 이면 UTF8String 이 널 포인터를 돌려주는데, 이를 %s 에 넘기면 안 되므로 삼항 연산자로 "(nil)" 을 대신 출력한다. 메시지는 nil 에게 안전하지만 C 함수 printf 는 그렇지 않다는 점을 보여 주는 부분이다.
yesNo 함수. BOOL 값을 YES, NO 문자열 객체로 바꾼다. 아래 출력 줄들이 모두 show 를 거치게 하려는 보조 함수다.
길이, 접두사, 교체. [title length] 는 인자 없는 메시지, hasPrefix: 는 인자 하나, stringByReplacingOccurrencesOfString:withString: 은 인자 둘이다. 글자 수 14 는 영문 11자(Objective-C), 공백 1자, 한글 2자의 합이다. %lu 에는 NSUInteger 를 unsigned long 으로 바꿔 넘겨 경고를 피한다.
중첩. 안쪽 uppercaseString 이 OBJECTIVE-C 입문 을 만들고, 바깥 stringByAppendingString: 이 뒤에 문구를 붙인다. 한글은 대소문자가 없어 그대로다.
클래스 메서드. [NSString stringWithFormat:...] 과 [NSMutableArray array] 는 클래스 이름을 수신자로 쓴다. %03d 는 정수 7 을 세 자리 0 채움으로 찍어 BK-007 을 만든다. 이어지는 addObject:, insertObject:atIndex:, componentsJoinedByString:, count 는 만들어진 배열 객체에 보내는 인스턴스 메서드다. 인덱스 0 에 끼워 넣은 BK-000 이 목록 맨 앞에 온다.
딕셔너리. @{...} 는 딕셔너리 리터럴이다. 딕셔너리는 순서가 정해져 있지 않으므로 allKeys 로 키를 꺼내 sortedArrayUsingSelector: 에 @selector(compare:) 를 넘겨 정렬한 뒤 출력한다. 이렇게 해야 실행할 때마다 출력이 같다. 딕셔너리 자체는 뒤에서 별도로 다룬다.
nil 부분. 키 m9 는 없으므로 objectForKey: 가 nil 을 돌려준다. 그 nil 에게 length 를 보내면 0, uppercaseString 을 보내면 nil, hasPrefix: 를 보내면 NO 가 나온다. 프로그램은 멈추지 않는다.
셀렉터 부분. SEL 은 셀렉터를 담는 타입이고, 배열에 셀렉터 세 개를 담았다. 반복문에서 respondsToSelector: 로 대출 목록 배열에게 묻는다. 배열은 count 와 addObject: 에 응답하고 문자열용 uppercaseString 에는 응답하지 않는다. 마지막 줄은 nil 에게 물은 결과가 NO 임을 보여 준다.
실행 결과
$ clang -fobjc-arc -Wall -framework Foundation main.m -o app
$ ./app
길이: 14
접두사 일치: YES
교체: Objective-C 기초
중첩: OBJECTIVE-C 입문 (대출 가능)
도서 번호: BK-007
대출 목록: BK-000, BK-001, BK-002
대출 건수: 3
m1: 김하늘
m2: 박도윤
m9 회원: (nil)
nil 길이: 0
nil 대문자: (nil)
nil 접두사: NO
셀렉터 이름: insertObject:atIndex:
count -> YES
addObject: -> YES
uppercaseString -> NO
nil의 length -> NO
실무에서 자주 틀리는 것
1. 인자를 쉼표로 나열한다
C 함수 호출에 익숙하면 인자를 쉼표로 이어 쓰기 쉽다. 메시지에서는 쉼표가 인자 사이를 잇지 못한다.
// 틀린 코드
[loans insertObject:@"BK-000", 0];
// 고친 코드
[loans insertObject:@"BK-000" atIndex:0];
메시지 이름은 insertObject:atIndex: 로 정해져 있으므로, 이름 조각을 모두 쓰고 조각마다 콜론 뒤에 인자를 붙인다. 이름에 든 콜론 개수를 세어 인자 개수와 맞추는 습관이 도움이 된다.
2. nil 결과를 C 함수에 바로 넘긴다
메시지는 nil 에게 안전하지만, 그 결과를 받은 C 함수까지 안전한 것은 아니다.
// 틀린 코드
NSString *name = [members objectForKey:@"m9"];
printf("%s\n", [name UTF8String]); // name 이 nil 이면 널 포인터가 %s 로 넘어간다
// 고친 코드
NSString *name = [members objectForKey:@"m9"];
if (name == nil) {
printf("회원 없음\n");
} else {
printf("%s\n", [name UTF8String]);
}
메시지 체인 중간에서 nil 이 생겨도 끝까지 조용히 지나가므로, 마지막에 C 함수나 다른 API 로 넘기는 곳에서 문제가 터진다. 조회 결과가 필수인 지점에서는 nil 을 직접 검사한다.
3. nil 을 컬렉션에 넣는다
수신자가 nil 인 것과 인자가 nil 인 것은 다르다.
// 틀린 코드
NSString *name = [members objectForKey:@"m9"];
[loans addObject:name]; // name 이 nil 이면 예외로 종료된다
// 고친 코드
NSString *name = [members objectForKey:@"m9"];
if (name != nil) {
[loans addObject:name];
}
배열과 딕셔너리는 nil 을 원소로 가질 수 없다. 컬렉션에 넣기 전에 값이 있는지 확인한다. 값이 없음을 표현해야 한다면 컬렉션 전용 널 객체를 쓰는데, 이는 컬렉션을 다루는 곳에서 설명한다.
4. @selector 에서 콜론을 빼먹는다
콜론은 이름의 일부이므로 빠지면 다른 이름이 된다.
// 틀린 코드
if ([loans respondsToSelector:@selector(addObject)]) { // 항상 NO
[loans addObject:@"BK-003"];
}
// 고친 코드
if ([loans respondsToSelector:@selector(addObject:)]) {
[loans addObject:@"BK-003"];
}
addObject 와 addObject: 는 서로 다른 셀렉터다. 앞의 것은 배열에 없으므로 조건이 거짓이 되고, 추가가 실행되지 않아도 오류가 나오지 않는다. 컴파일러가 이런 실수를 늘 잡아 주지는 않는다. -Wundeclared-selector 경고를 켜면 선언되지 않은 셀렉터를 알려 주므로 도움이 된다.
한눈에 보기
| 개념 | 표기 또는 규칙 | 주의할 점 |
|---|---|---|
| 메시지 전송 | [수신자 메시지] | 실행할 코드는 실행 중에 정해진다 |
| 인자 있는 메시지 | [수신자 이름조각:값 이름조각:값] | 콜론 개수가 인자 개수이고 쉼표는 쓰지 않는다 |
| 중첩 메시지 | [[a b] c] | 안쪽부터 평가되며 깊어지면 변수로 나눈다 |
| 클래스 메서드 | [클래스 메시지], 선언은 + | 객체에 보내지 않고 클래스에 보낸다 |
| 인스턴스 메서드 | [객체 메시지], 선언은 - | 클래스 이름에 보내면 오류다 |
| nil 수신자 | 결과는 nil, 0, NO | 인자가 nil 인 경우와 C 함수 전달은 별개다 |
| 셀렉터 | @selector(이름:), 타입은 SEL | 콜론까지 이름의 일부다 |
| 응답 확인 | [객체 respondsToSelector:셀렉터] | nil 에게 물으면 NO 다 |
더 자세한 내용은 Apple 의 Programming with Objective-C 의 객체 다루기 문서에서 확인할 수 있다.
연습 문제
[title stringByReplacingOccurrencesOfString:@"a" withString:@"b"]에서 수신자, 메시지 이름, 인자 개수를 각각 쓰시오.NSUInteger n = [[title uppercaseString] length];를 중첩 없이 두 문장으로 나누어 쓰고,title이@"Objective-C 입문"일 때n의 값을 쓰시오.- 다음 코드가 실행되면 무엇이 출력되는지, 그리고 프로그램이 종료되는지 쓰시오.
NSString *s = nil; printf("%lu\n", (unsigned long)[s length]); - 대출 목록 배열
loans가removeAllObjects메시지에 응답할 때만 그 메시지를 보내는 코드를 작성하시오. 셀렉터를 쓸 때 주의할 점도 한 줄로 쓰시오.
정답과 해설
- 수신자는
title, 메시지 이름은stringByReplacingOccurrencesOfString:withString:, 인자는 2개다. 이름에 콜론이 두 개 있고 각 콜론 뒤에 인자가 하나씩 온다. -
NSString *upper = [title uppercaseString]; NSUInteger n = [upper length];n은 14 다. 대문자로 바꿔도 글자 수는 달라지지 않는다. 안쪽 메시지의 결과를 변수에 담아 바깥 메시지의 수신자로 쓴 것이 중첩과 같은 동작이다. - 0 이 출력되고 프로그램은 종료되지 않는다. 수신자가
nil이므로length는 실행되지 않고 반환형이 숫자라서 0 이 돌아온다. 이 값을 받는 쪽에서 0 이 빈 문자열의 길이인지nil때문인지 구분할 수 없다는 점도 기억해 둔다. -
if ([loans respondsToSelector:@selector(removeAllObjects)]) { [loans removeAllObjects]; }이 메시지는 인자가 없으므로 이름에 콜론을 붙이지 않는다. 인자가 있는 메시지였다면
@selector(addObject:)처럼 콜론까지 적어야 한다.NSMutableArray는 이 메시지에 응답하므로 실제로는 조건이 참이다.