Devin.KR

문자열과 숫자 객체 - NSString 과 NSNumber

개발자KR 조회 0

이 장에서 배우는 것

도서 대출 관리 도구는 어디서나 문자열과 숫자를 다룬다. 도서 번호, 제목, 회원 이름은 문자열이고 대출 일수와 연체료는 숫자다. 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 결과가 들어간다. 자주 쓰는 지정자는 다음과 같다.

stringWithFormat: 에서 자주 쓰는 형식 지정자
지정자받는 값인자 예비고
%@Objective-C 객체bookTitleNSString, NSNumber 모두 가능
%ldNSInteger(long)dayslong 으로 형변환해 넘긴다
%luNSUInteger(unsigned long)lenlength 가 이 타입이다
%.2fdoublefee소수 둘째 자리까지
%sC 문자열"yes"NSString 을 넣으면 안 된다

NSInteger는 64비트 환경에서 long이므로 %d가 아니라 %ld로 받고, 인자를 (long)으로 형변환한다. 지정자와 인자 타입이 어긋나면 컴파일러가 경고하고, 경고를 무시하면 엉뚱한 값이 찍힌다.

비교: 주소인가 내용인가

객체 변수가 가진 값은 객체가 놓인 곳의 주소다. 그래서 a == b는 두 변수가 같은 객체를 가리키는지 묻는다. 글자가 같은지는 묻지 않는다. 글자를 비교하려면 isEqualToString:을 쓴다. 순서를 알아야 하면 compare:가 NSOrderedAscending, NSOrderedSame, NSOrderedDescending 중 하나를 돌려준다. 대소문자를 무시하려면 caseInsensitiveCompare:를 쓴다.

== 는 두 변수가 가리키는 주소를 비교하고 isEqualToString: 은 객체 안의 글자를 비교한다

구현이 값이 같은 문자열 객체를 재사용하는 경우가 있어서 ==가 우연히 맞는 값을 내기도 한다. 그 우연에 기대면 환경이 바뀔 때 결과도 바뀐다. 문자열 내용 비교에는 항상 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를 권한 이유가 이것이다.

가변 문자열을 가리키는 변수는 모두 바뀐 내용을 보고 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];

한눈에 보기

NSString 과 NSMutableString 의 자주 쓰는 메서드
하려는 일메서드원본 변경돌려주는 것
내용 비교isEqualToString:없음BOOL
순서 비교compare:없음NSComparisonResult
검색rangeOfString:없음NSRange
자르기substringWithRange: 등없음새 NSString
바꾼 사본stringByReplacingOccurrencesOfString:withString:없음새 NSString
덧붙이기appendString:, appendFormat:있음없음
NSNumber 박싱과 언박싱
방향쓰는 법예주의
리터럴 박싱@14, @0.5, @YES@14상수만 가능
식 박싱@(식)@(count * 0.5)괄호가 필요하다
언박싱integerValue, doubleValue[n integerValue]소수부는 버려진다
비교compare:, isEqualToNumber:[a compare:b]== 는 주소 비교다

자세한 메서드 목록은 NSString과 NSNumber 공식 문서에서 확인할 수 있다.

연습 문제

  1. @"B-77|lee.jina"에서 | 뒤의 회원 ID를 꺼내는 코드를 쓰고, |가 없을 때를 처리하라.
  2. 다음 코드가 두 줄에 각각 무엇을 출력하는지 쓰고 이유를 설명하라.
    NSMutableString *m = [NSMutableString stringWithString:@"A"];
    NSString *c = [m copy];
    NSString *s = m;
    [m appendString:@"B"];
    Print(c);
    Print(s);
  3. NSInteger total = 120;을 "total 120"으로 출력하는 형식 문자열을 쓰고, %d를 쓰면 안 되는 이유를 말하라.
  4. 반납 기한이 7일이고 대출 기간이 @10, 하루 연체료가 @0.25일 때 연체료를 NSNumber로 계산해 %.2f로 출력하라.

정답과 해설

  1. 검색 후 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가 나온다.
  2. 첫 줄은 A, 둘째 줄은 AB다. c는 copy로 만든 별도의 불변 사본이라 appendString:의 영향을 받지 않는다. s는 m과 같은 객체를 가리키므로 바뀐 내용이 그대로 보인다.
  3. [NSString stringWithFormat:@"total %ld", (long)total]이다. NSInteger는 64비트 환경에서 long과 같은 크기라서 %d로 읽으면 크기가 맞지 않는다. 컴파일러 경고가 나고 큰 값에서는 결과가 틀어진다.
  4. 값을 꺼내 C 타입으로 계산한 뒤 다시 박싱한다.
    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.75
    연체 일수는 3이고 3 × 0.25 = 0.75다. NSNumber 둘을 직접 빼거나 곱할 수는 없으므로 언박싱이 필요하다.

댓글 0

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

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