Objective-C · 기본
Objective-C 문법과 Cocoa 기초
문자열과 숫자 객체 - NSString 과 NSNumber
NSString 리터럴과 불변성, NSMutableString, stringWithFormat, 비교(isEqualToString 과 ==), 부분 문자열·검색, NSNumber 와 박싱 @(값)
개발자KR · 원고 갱신
이 장에서 배우는 것
도서 대출 관리 도구는 어디서나 문자열과 숫자를 다룬다. 도서 번호, 제목, 회원 이름은 문자열이고 대출 일수와 연체료는 숫자다. Objective-C에서 이 둘은 C의 char *나 int가 아니라 Foundation 프레임워크의 객체인 NSString과 NSNumber로 표현한다. 이 장은 두 클래스를 쓰는 기본 방법과, 처음 쓰는 사람이 자주 걸려 넘어지는 지점을 다룬다. 앞 장에서 본 alloc과 init, ARC의 소유 규칙은 이미 알고 있다고 가정한다.
- 문자열 리터럴이 불변(immutable) 객체라는 것과
NSMutableString이 필요한 경우를 구분한다. stringWithFormat:으로 문자열을 조립하고 형식 지정자를 맞게 고른다.==와isEqualToString:이 무엇을 비교하는지 설명한다.- 부분 문자열을 잘라 내고
NSRange로 검색 결과를 읽는다. @(값)으로 C 값을NSNumber에 담고(박싱, boxing) 다시 꺼낸다.
문제 상황
사내 도서 대출 시스템이 총무팀이 만든 텍스트 한 줄을 넘겨받는다고 하자. 줄은 C-1042|Cocoa Basics|kim.minsu 처럼 앞뒤에 공백이 붙어 있고, 도서 번호·제목·회원 ID를 |로 이어 놓았다. 여기서 세 값을 뽑아 대출 기록으로 만들어야 한다. 반납 기한(14일)을 넘긴 일수에 하루 0.5의 연체료를 곱하는 계산도 필요하다.
C의 감각으로 접근하면 곧 막힌다. 문자열을 ==로 비교하면 내용이 같아도 다르다고 나온다. 받은 문자열을 고쳐 쓰려고 했더니 원본이 안 바뀐다. 반대로 한 곳에서 고쳤더니 다른 곳의 문자열까지 바뀐다. 이 장은 이 세 가지 현상의 원인을 객체 모델로 설명한다.
NSString: 불변 문자열 객체
리터럴과 불변성
큰따옴표만 쓴 "abc"는 C 문자열이고, 앞에 @를 붙인 @"abc"가 NSString 리터럴(literal, 코드에 직접 적은 값)이다. 변수 타입은 객체 포인터이므로 NSString *title = @"Cocoa";처럼 별표를 붙인다.
NSString은 한 번 만들면 내용을 바꿀 수 없다. uppercaseString, stringByReplacingOccurrencesOfString:withString:, stringByAppendingString: 같은 메서드는 원본을 고치지 않고 새 문자열을 돌려준다. 원본 변수는 그대로이므로, 결과를 받아 둘 변수를 따로 둬야 한다.
길이도 알아 둘 점이 있다. length는 바이트 수가 아니라 UTF-16 단위의 개수를 돌려준다. 한글 음절 하나는 길이 1이고 UTF-8로는 3바이트다. 그래서 @"도서 대출"의 length는 5이고, UTF-8 바이트 수는 13이다.
stringWithFormat: 으로 조립하기
stringWithFormat:은 C의 printf와 같은 방식으로 문자열을 만든다. 객체를 끼울 때는 %@를 쓴다. 이때 객체의 description 결과가 들어간다. 자주 쓰는 지정자는 다음과 같다.
| 지정자 | 받는 값 | 인자 예 | 비고 |
|---|---|---|---|
%@ | Objective-C 객체 | bookTitle | NSString, NSNumber 모두 가능 |
%ld | NSInteger | (long)days | long 으로 형변환해 넘긴다 |
%lu | NSUInteger | (unsigned long)len | length 가 이 타입이다 |
%.2f | double | fee | 소수 둘째 자리까지 |
%s | C 문자열 | "yes" | NSString 을 넣으면 안 된다 |
NSInteger는 64비트 환경에서 long이므로 %d가 아니라 %ld로 받고, 인자를 (long)으로 형변환한다. 지정자와 인자 타입이 어긋나면 컴파일러가 경고하고, 경고를 무시하면 엉뚱한 값이 찍힌다.
비교: 주소인가 내용인가
객체 변수가 가진 값은 객체가 놓인 곳의 주소다. 그래서 a == b는 두 변수가 같은 객체를 가리키는지 묻는다. 글자가 같은지는 묻지 않는다. 글자를 비교하려면 isEqualToString:을 쓴다. 순서를 알아야 하면 compare:가 NSOrderedAscending, NSOrderedSame, NSOrderedDescending 중 하나를 돌려준다. 대소문자를 무시하려면 caseInsensitiveCompare:를 쓴다.
구현이 값이 같은 문자열 객체를 재사용하는 경우가 있어서 ==가 우연히 맞는 값을 내기도 한다. 그 우연에 기대면 환경이 바뀔 때 결과도 바뀐다. 문자열 내용 비교에는 항상 isEqualToString:을 쓴다. 받는 쪽이 nil이면 메시지가 무시되고 NO(0)가 돌아온다. 그래서 비교 대상이 비어 있어도 프로그램은 죽지 않는다.
부분 문자열과 검색
검색의 기본은 rangeOfString:이다. 결과는 NSRange로, location(시작 위치, 0부터)과 length(길이)를 가진 구조체다. 찾지 못하면 location이 NSNotFound가 된다. 이 값은 아주 큰 정수이므로, 검사 없이 substringFromIndex:에 넘기면 범위 오류로 프로그램이 종료된다.
자르는 메서드는 세 가지다. substringToIndex:는 앞부분, substringFromIndex:는 뒷부분, substringWithRange:는 가운데를 돌려준다. NSMaxRange(range)는 location + length이므로 구분자 바로 다음 위치를 구할 때 쓴다. 앞뒤 공백은 stringByTrimmingCharactersInSet:에 [NSCharacterSet whitespaceCharacterSet]을 넘겨 걷어 낸다. 접두사·접미사만 확인하면 되는 경우에는 hasPrefix:와 hasSuffix:가 더 간단하다. 대소문자를 무시한 검색은 options:NSCaseInsensitiveSearch로 한다.
NSMutableString: 고칠 수 있는 문자열
같은 문자열에 조각을 여러 번 덧붙이거나 끼워 넣을 때는 NSMutableString이 낫다. NSString의 하위 클래스이고, appendString:, appendFormat:, insertString:atIndex:, replaceOccurrencesOfString:withString:options:range: 같은 메서드가 객체를 제자리에서 바꾼다.
하위 클래스라서 NSString * 변수가 가변 객체를 가리킬 수 있다. 이때 한 객체를 여러 변수가 함께 보고 있으므로, 어느 한 쪽이 바꾸면 다른 쪽에도 보인다. 그 시점의 내용을 고정해 두려면 copy로 불변 사본을 만든다. 프로퍼티의 속성 키워드를 다룬 장에서 문자열 프로퍼티에 copy를 권한 이유가 이것이다.
NSNumber: 숫자를 객체로 담기
이 책에서 곧 다룰 배열과 딕셔너리는 객체만 담을 수 있다. 그래서 int나 double 같은 C 값을 객체로 감싸 주는 클래스가 NSNumber다. 감싸는 일을 박싱이라 하고, 리터럴 @14, @0.5, @YES와 변수나 식을 감싸는 @(식) 형태가 있다. 괄호가 있어야 변수와 식을 쓸 수 있다.
NSNumber *limit = @14;
int count = 3;
NSNumber *boxed = @(count);
NSNumber *fee = @(count * 0.5);
꺼낼 때는 integerValue, doubleValue, boolValue 같은 메서드를 쓴다. 타입이 다르면 C의 변환 규칙을 따른다. @3.9의 integerValue는 반올림 없이 소수부를 버려 3이 된다. NSNumber 두 개에는 +나 <를 쓸 수 없다. 그것은 포인터 연산이기 때문이다. 값을 꺼내 C 타입으로 계산하거나, compare:와 isEqualToNumber:를 쓴다.
문자열에서 숫자를 읽는 쪽은 NSString이 직접 지원한다. @"21"의 integerValue는 21이고, @"3.5"의 doubleValue는 3.5다. 숫자로 읽을 수 없는 문자열은 0이 되므로, 0이 정상값인 입력이면 별도 검증이 필요하다. 반대로 NSNumber를 문자열로 바꿀 때는 stringValue나 %@를 쓴다.
결과 출력은 printf 로
이 책의 예제는 결과를 printf("%s\n", [문자열 UTF8String])으로 표준 출력에 내보낸다. NSLog가 더 짧아 보이지만, 로그용이기 때문에 현재 시각과 프로세스 이름·번호가 앞에 붙고 출력도 표준 오류로 나간다. 결과를 파일로 저장하거나 다른 도구에 넘길 때, 또는 정답과 비교할 때 시각이 매번 달라 결과가 흔들린다. UTF8String은 문자열 객체가 가진 C 문자열의 주소를 돌려주므로 그 객체가 살아 있는 동안에만 유효하다. 이 장처럼 바로 출력에 쓰는 용도로는 문제가 없다. 아래 코드는 이 호출을 Print라는 작은 함수로 묶어 쓴다.
완성 코드
파일은 main.m 하나다. 앞에서 설명한 동작을 한 번씩 실행해 보도록 구성했다.
#import <Foundation/Foundation.h>
static void Print(NSString *text) {
printf("%s\n", [text UTF8String]);
}
static const char *YesNo(BOOL value) {
return value ? "yes" : "no";
}
int main(void) {
@autoreleasepool {
// 1. 대출 한 줄 다듬기와 쪼개기
NSString *raw = @" C-1042|Cocoa Basics|kim.minsu ";
NSString *line = [raw stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]];
Print([NSString stringWithFormat:@"raw length %lu, line length %lu",
(unsigned long)[raw length], (unsigned long)[line length]]);
NSRange first = [line rangeOfString:@"|"];
NSString *bookId = [line substringToIndex:first.location];
NSString *rest = [line substringFromIndex:NSMaxRange(first)];
NSRange second = [rest rangeOfString:@"|"];
NSString *bookTitle = [rest substringToIndex:second.location];
NSString *member = [rest substringFromIndex:NSMaxRange(second)];
Print([NSString stringWithFormat:@"id=%@ title=%@ member=%@",
bookId, bookTitle, member]);
Print([NSString stringWithFormat:@"first word: %@",
[bookTitle substringWithRange:NSMakeRange(0, 5)]]);
// 2. 검색
NSRange none = [line rangeOfString:@"#"];
Print(none.location == NSNotFound ? @"'#' not found" : @"'#' found");
NSRange hit = [bookTitle rangeOfString:@"BASICS"
options:NSCaseInsensitiveSearch];
printf("search BASICS: location %lu, length %lu\n",
(unsigned long)hit.location, (unsigned long)hit.length);
Print([NSString stringWithFormat:@"id starts with C-: %s",
YesNo([bookId hasPrefix:@"C-"])]);
// 3. 불변성: 새 문자열을 돌려줄 뿐 원본은 그대로
NSString *upper = [bookTitle uppercaseString];
Print([NSString stringWithFormat:@"%@ -> %@", bookTitle, upper]);
NSString *spaced = [member stringByReplacingOccurrencesOfString:@"."
withString:@" "];
Print([NSString stringWithFormat:@"%@ / %@", member, spaced]);
// 4. 비교
NSString *literal = @"member-kim-minsu";
NSString *built = [NSString stringWithFormat:@"%@-%@-%@",
@"member", @"kim", @"minsu"];
printf("== : %s\n", YesNo(literal == built));
printf("isEqualToString: %s\n", YesNo([literal isEqualToString:built]));
NSComparisonResult order = [bookId compare:@"C-1100"];
printf("compare: %s\n",
order == NSOrderedAscending ? "ascending" : "not ascending");
printf("case-insensitive equal: %s\n",
YesNo([@"KIM.MINSU" caseInsensitiveCompare:member] == NSOrderedSame));
// 5. NSMutableString 과 copy
NSMutableString *report = [NSMutableString stringWithString:@"LOAN"];
[report appendFormat:@" %@ to %@", bookId, member];
[report insertString:@"[" atIndex:0];
[report appendString:@"]"];
NSUInteger replaced = [report replaceOccurrencesOfString:@"."
withString:@"_"
options:0
range:NSMakeRange(0, [report length])];
Print([NSString stringWithFormat:@"report: %@ (replaced %lu)",
report, (unsigned long)replaced]);
NSMutableString *draft = [NSMutableString stringWithString:@"draft"];
NSString *alias = draft;
NSString *frozen = [draft copy];
[draft appendString:@"-final"];
Print([NSString stringWithFormat:@"alias=%@ frozen=%@", alias, frozen]);
// 6. 형식 지정과 NSNumber
Print([NSString stringWithFormat:@"code L-%04ld", (long)42]);
NSNumber *loaned = @17;
NSNumber *limit = @(14);
NSNumber *rate = @0.5;
NSInteger overdue = [loaned integerValue] - [limit integerValue];
if (overdue < 0) {
overdue = 0;
}
double fee = overdue * [rate doubleValue];
NSNumber *feeBox = @(fee);
Print([NSString stringWithFormat:@"overdue %ld days, fee %@ (%.2f)",
(long)overdue, feeBox, fee]);
Print([NSString stringWithFormat:@"%@ over limit %@: %s", loaned, limit,
YesNo([loaned compare:limit] == NSOrderedDescending)]);
Print([NSString stringWithFormat:@"%@ equals limit: %s", @(14),
YesNo([@(14) isEqualToNumber:limit])]);
NSNumber *ratio = @3.9;
Print([NSString stringWithFormat:@"%@ as integer: %ld", ratio,
(long)[ratio integerValue]]);
Print([NSString stringWithFormat:@"parsed: %ld and %.2f",
(long)[@"21" integerValue], [@"3.5" doubleValue]]);
// 7. 한글 길이
NSString *korean = @"도서 대출";
printf("korean: length %lu, bytes %lu\n",
(unsigned long)[korean length],
(unsigned long)[korean lengthOfBytesUsingEncoding:NSUTF8StringEncoding]);
}
return 0;
}
줄별 해설
- Print, YesNo:
Print는 문자열 객체를 C 문자열로 바꿔 표준 출력에 한 줄 쓴다.YesNo는BOOL을"yes"와"no"로 바꾼다. 형식 문자열에는%s로 끼운다. - 1번 블록:
stringByTrimmingCharactersInSet:이 앞뒤 공백 네 칸을 걷어 내 33글자가 29글자가 된다. 첫|의 위치는 6이어서substringToIndex:가C-1042를 돌려준다.NSMaxRange(first)는 7이므로rest는 둘째 필드부터 시작한다. 같은 방식으로 한 번 더 잘라 제목과 회원 ID를 얻는다.NSMakeRange(0, 5)는 0번부터 5글자다. - 2번 블록:
#은 없으므로location이NSNotFound다.BASICS는 대소문자를 무시하면Cocoa Basics의 6번 위치에서 시작해 6글자다. - 3번 블록:
upper와spaced가 새 객체다. 출력에서bookTitle과member가 원래 값 그대로 있는 것을 확인할 수 있다. - 4번 블록:
built는 실행 중에 만든 별개의 객체이므로==가 no다.isEqualToString:은 글자를 비교해 yes다. 구현이 짧은 문자열을 재사용할 수 있어서, 예제는 일부러 16글자짜리 문자열을 썼다.compare:는C-10..과C-11..의 넷째 글자0과1에서 갈려 오름차순이 된다. - 5번 블록:
report에appendFormat:,insertString:atIndex:,appendString:을 차례로 적용해 객체 하나를 계속 고친다. 치환 메서드는 바꾼 횟수를NSUInteger로 돌려준다.alias는draft와 같은 객체를 가리키고,frozen은copy로 만든 별도의 불변 사본이다. - 6번 블록:
%04ld는 폭 4에 0을 채운 정수다. 연체 일수는 17 - 14 = 3이고, 음수가 되면 0으로 맞춘다.@(fee)로 박싱한 값은%@로 찍으면1.5,%.2f로 찍으면1.50이다. 마지막 두 줄은 문자열이 숫자로 변환되는 과정을 보여 준다. - 7번 블록: 한글 4글자와 공백 하나가 UTF-16 단위로 5, UTF-8로 13바이트다.
실행 결과
$ clang -fobjc-arc -Wall -framework Foundation main.m -o app
$ ./app
raw length 33, line length 29
id=C-1042 title=Cocoa Basics member=kim.minsu
first word: Cocoa
'#' not found
search BASICS: location 6, length 6
id starts with C-: yes
Cocoa Basics -> COCOA BASICS
kim.minsu / kim minsu
== : no
isEqualToString: yes
compare: ascending
case-insensitive equal: yes
report: [LOAN C-1042 to kim_minsu] (replaced 1)
alias=draft-final frozen=draft
code L-0042
overdue 3 days, fee 1.5 (1.50)
17 over limit 14: yes
14 equals limit: yes
3.9 as integer: 3
parsed: 21 and 3.50
korean: length 5, bytes 13
실무에서 자주 틀리는 것
내용 비교에 == 를 쓴다
틀린 코드는 이렇다. 환경에 따라 맞기도 하고 틀리기도 해서 테스트에서 못 잡는 경우가 많다.
if (inputId == storedId) {
// 같은 도서로 처리
}
고친 코드는 다음과 같다.
if ([inputId isEqualToString:storedId]) {
// 같은 도서로 처리
}
가변 문자열을 그대로 보관한다
외부에서 받은 NSString *이 실제로는 NSMutableString일 수 있다. 그대로 저장하면 호출한 쪽이 나중에 바꾼 내용이 내 객체 안에 들어온다.
_memberId = memberId; // 호출한 쪽이 고치면 같이 바뀐다
복사해서 보관하면 그 시점의 내용이 고정된다.
_memberId = [memberId copy]; // 불변 사본을 보관한다
NSInteger 를 %d 로 찍는다
틀린 코드는 64비트 환경에서 경고가 나고, 값이 큰 경우 잘못된 수가 찍힌다. 객체를 %d로 찍으면 더 심각해서 프로그램이 비정상 종료될 수 있다.
NSInteger days = 3;
NSString *s = [NSString stringWithFormat:@"%d days", days];
고친 코드는 %ld와 형변환을 쓴다. 객체는 %@로 찍는다.
NSString *s = [NSString stringWithFormat:@"%ld days", (long)days];
검색 실패를 확인하지 않고 자른다
구분자가 없는 줄이 한 줄만 섞여 있어도 프로그램이 범위 예외로 종료된다.
NSRange bar = [line rangeOfString:@"|"];
NSString *id = [line substringToIndex:bar.location];
먼저 NSNotFound를 확인하고 처리 방법을 정한다.
NSRange bar = [line rangeOfString:@"|"];
if (bar.location == NSNotFound) {
return nil; // 형식이 틀린 줄은 건너뛴다
}
NSString *bookId = [line substringToIndex:bar.location];
한눈에 보기
| 하려는 일 | 메서드 | 원본 변경 | 돌려주는 것 |
|---|---|---|---|
| 내용 비교 | isEqualToString: | 없음 | BOOL |
| 순서 비교 | compare: | 없음 | NSComparisonResult |
| 검색 | rangeOfString: | 없음 | NSRange |
| 자르기 | substringWithRange: 등 | 없음 | 새 NSString |
| 바꾼 사본 | stringByReplacingOccurrencesOfString:withString: | 없음 | 새 NSString |
| 덧붙이기 | appendString:, appendFormat: | 있음 | 없음 |
| 방향 | 쓰는 법 | 예 | 주의 |
|---|---|---|---|
| 리터럴 박싱 | @14, @0.5, @YES | @14 | 상수만 가능 |
| 식 박싱 | @(식) | @(count * 0.5) | 괄호가 필요하다 |
| 언박싱 | integerValue, doubleValue | [n integerValue] | 소수부는 버려진다 |
| 비교 | compare:, isEqualToNumber: | [a compare:b] | == 는 주소 비교다 |
자세한 메서드 목록은 NSString과 NSNumber 공식 문서에서 확인할 수 있다.
연습 문제
@"B-77|lee.jina"에서|뒤의 회원 ID를 꺼내는 코드를 쓰고,|가 없을 때를 처리하라.- 다음 코드가 두 줄에 각각 무엇을 출력하는지 쓰고 이유를 설명하라.
NSMutableString *m = [NSMutableString stringWithString:@"A"]; NSString *c = [m copy]; NSString *s = m; [m appendString:@"B"]; Print(c); Print(s); NSInteger total = 120;을"total 120"으로 출력하는 형식 문자열을 쓰고,%d를 쓰면 안 되는 이유를 말하라.- 반납 기한이 7일이고 대출 기간이
@10, 하루 연체료가@0.25일 때 연체료를NSNumber로 계산해%.2f로 출력하라.
정답과 해설
- 검색 후
NSNotFound를 먼저 확인한다.NSString *text = @"B-77|lee.jina"; NSRange bar = [text rangeOfString:@"|"]; if (bar.location == NSNotFound) { Print(@"invalid line"); } else { Print([text substringFromIndex:NSMaxRange(bar)]); // lee.jina }NSMaxRange(bar)가 구분자 바로 다음 위치이므로lee.jina가 나온다. - 첫 줄은
A, 둘째 줄은AB다.c는copy로 만든 별도의 불변 사본이라appendString:의 영향을 받지 않는다.s는m과 같은 객체를 가리키므로 바뀐 내용이 그대로 보인다. [NSString stringWithFormat:@"total %ld", (long)total]이다.NSInteger는 64비트 환경에서long과 같은 크기라서%d로 읽으면 크기가 맞지 않는다. 컴파일러 경고가 나고 큰 값에서는 결과가 틀어진다.- 값을 꺼내 C 타입으로 계산한 뒤 다시 박싱한다.
연체 일수는 3이고 3 × 0.25 = 0.75다.NSNumber *loaned = @10; NSNumber *rate = @0.25; NSInteger overdue = [loaned integerValue] - 7; if (overdue < 0) { overdue = 0; } NSNumber *fee = @(overdue * [rate doubleValue]); Print([NSString stringWithFormat:@"%.2f", [fee doubleValue]]); // 0.75NSNumber둘을 직접 빼거나 곱할 수는 없으므로 언박싱이 필요하다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.