Devin.KR

Objective-C · 기본

Objective-C 문법과 Cocoa 기초

값과 타입 - C 타입과 객체 타입

C 기본 타입, NSInteger·CGFloat·BOOL(YES·NO), 객체 포인터와 *, id, nil 과 NULL, 형식 지정자(%ld·%@)

개발자KR · 원고 갱신

이 장에서 배우는 것

Objective-C는 C 언어 위에 만들어졌으므로 변수에 쓰는 타입도 두 갈래다. 하나는 int, double처럼 값 자체를 담는 C 타입이고, 다른 하나는 NSString *처럼 객체의 주소를 담는 객체 포인터 타입이다. 이 장은 두 갈래를 구분하는 법과, 값을 화면에 찍을 때 타입마다 다른 형식 지정자(format specifier)를 맞춰 쓰는 법을 다룬다. 사내 도서 대출 관리 도구에 들어갈 연체 일수, 연체료, 대출 중 여부, 도서 제목 같은 값을 변수에 담아 출력하는 작은 프로그램이 이 장의 결과물이다.

  • C 기본 타입의 종류와 크기, 그에 맞는 형식 지정자를 말할 수 있다.
  • NSInteger, CGFloat, BOOL(YES·NO)을 언제 쓰는지 설명할 수 있다.
  • 객체 변수에 *가 붙는 이유와, 두 변수가 같은 객체를 가리키는 상황을 그림으로 설명할 수 있다.
  • id, nil, NULL을 구분해 쓸 수 있다.
  • %ld와 %@로 값을 출력하고, 결과 출력에 NSLog를 쓰지 않는 이유를 설명할 수 있다.

문제 상황

대출 관리 도구에 연체 안내 기능을 넣는다고 하자. 회원 한 명의 연체 일수가 6이고 하루 연체료가 150.5원이면 연체료는 903.0원이다. 이 값을 계산해서 화면에 찍는 코드는 몇 줄 되지 않는데, 처음 쓰면 다음과 같은 일이 생긴다.

  • 연체 일수를 int로 둘지 long으로 둘지, Cocoa 코드에서 흔히 보이는 NSInteger는 무엇인지 알 수 없다.
  • 열람률을 읽은 쪽수 / 전체 쪽수로 계산했더니 항상 0이 나온다.
  • printf에 %d를 썼더니 컴파일러가 경고를 내거나 값이 이상하게 찍힌다.
  • 도서 제목을 담은 변수를 printf("%@", title)로 찍으면 원하는 글자가 나오지 않는다.
  • 값이 비어 있는 객체와 비어 있는 C 문자열 포인터를 어떻게 표현하는지 헷갈린다.

모두 "이 변수가 어떤 종류의 값인가"를 정확히 알면 풀리는 문제다. 아래에서 종류별로 정리한다.

C 타입과 Foundation 타입

C 기본 타입

Objective-C에서는 C의 기본 타입을 그대로 쓸 수 있다. 정수 타입은 담을 수 있는 범위에 따라 char, short, int, long, long long이 있고, 실수 타입은 float와 double이 있다. 크기는 플랫폼마다 다를 수 있다. 아래 표는 64비트 macOS와 64비트 Linux에서 흔한 값이며, 프로그램 안에서는 sizeof로 직접 확인할 수 있다.

C 기본 타입의 대표 크기와 printf 형식 지정자(64비트 환경 기준)
타입크기(바이트)형식 지정자도구에서의 예
char1%c등급 문자 'A'
short2%hd작은 번호 값
int4%d보유 권수
long8%ld서가 번호
long long8%lld아주 큰 식별 번호
float4%f쓰는 일이 드물다
double8%f도서 가격

float를 printf에 넘기면 C 규칙에 따라 double로 바뀌어 전달되므로 둘 다 %f를 쓴다. 소수 자릿수는 %.1f처럼 점 뒤에 적는다.

NSInteger, CGFloat, BOOL

Foundation은 C 타입에 별명을 붙인 타입을 제공한다. Cocoa API의 매개변수와 반환값은 거의 이 별명으로 적혀 있어서 같은 이름으로 맞춰 쓰는 편이 자연스럽다.

Foundation이 제공하는 기본 값 타입
타입실제 모습출력할 때쓰임
NSInteger64비트에서 long, 32비트에서 int%ld + (long) 캐스트부호 있는 정수
NSUInteger64비트에서 unsigned long%lu + (unsigned long) 캐스트개수, 길이
CGFloat64비트에서 double, 32비트에서 float%f + (double) 캐스트실수 계산
BOOL1바이트 정수 또는 bool직접 문자열로 바꿔 출력참·거짓

NSInteger는 컴파일 대상 플랫폼에 따라 int일 수도 long일 수도 있다. 그래서 %d와 %ld 중 하나로 고정하면 한쪽 환경에서 형식이 어긋난다. 항상 (long)으로 바꿔서 %ld로 찍으면 어느 환경에서나 맞는다. CGFloat도 같은 이유로 (double)로 바꿔 %f로 찍는다. 이 규칙을 묶어서 그림으로 나타내면 다음과 같다.

NSInteger 값은 long 으로 캐스트한 뒤 %ld 로 찍으면 어느 환경에서나 형식이 맞는다.

BOOL에는 참을 뜻하는 YES와 거짓을 뜻하는 NO가 대응한다. YES는 1, NO는 0이다. 조건식 lateDays > 0의 결과를 BOOL 변수에 바로 담을 수 있다. printf에는 BOOL용 형식 지정자가 따로 없으므로, 이 장의 예제는 작은 함수로 "YES"나 "NO" 문자열을 골라 출력한다.

정수끼리 나누면 몫만 남는다는 C 규칙도 그대로 적용된다. 100 / 250은 0.4가 아니라 0이다. 한쪽을 먼저 CGFloat로 바꾸면 실수 나눗셈이 된다.

객체 타입: 포인터, id, nil

객체 포인터와 *

C에서 int *p = &copies;는 p가 copies의 주소를 담는다는 뜻이다. Objective-C의 객체도 같은 방식으로 다룬다. 객체는 메모리의 다른 곳에 만들어지고, 변수에는 그 객체의 주소만 들어 있다. 그래서 문자열 객체를 담는 변수는 NSString *title로 쓴다. NSString title처럼 *를 빼면 컴파일 오류다. 객체는 변수 안에 직접 들어갈 수 없기 때문이다.

변수가 값이 아니라 주소를 담으므로, 대입은 객체를 복사하지 않고 주소만 복사한다. NSString *alias = title;을 실행하면 변수 두 개가 같은 객체를 가리킨다. alias == title은 두 변수가 같은 주소를 담았는지 비교한다.

int 변수는 값을 직접 담고, 객체 변수는 주소를 담아서 두 변수가 객체 하나를 가리킬 수 있다.

선언할 때 변수 여러 개를 쉼표로 이으면 *는 바로 뒤의 이름에만 붙는다. NSString *a, b;에서 a는 포인터이고 b는 NSString 값이므로 컴파일 오류가 난다. 객체 변수는 한 줄에 하나씩 선언하는 편이 안전하다.

id

id는 "어떤 클래스인지 정하지 않은 객체 포인터"다. *를 붙이지 않는다. id item은 문자열 객체도, 숫자 객체도 담을 수 있다. 서로 다른 종류의 객체를 한 변수에서 다루어야 할 때 쓰며, 종류를 알고 있다면 NSString *처럼 구체적으로 적는 편이 컴파일러가 실수를 더 많이 잡아 준다. 객체가 실제로 어떤 클래스인지는 isKindOfClass:라는 메시지로 물어볼 수 있다. 메시지를 보내는 대괄호 문법은 다음 장에서 자세히 다루므로 지금은 "객체에게 질문하고 BOOL을 돌려받는다" 정도로만 읽으면 된다.

nil과 NULL

아무것도 가리키지 않는 포인터에도 이름이 있다. 객체 포인터가 비어 있을 때는 nil, C 포인터(char *, int * 등)가 비어 있을 때는 NULL을 쓴다. 둘 다 내부적으로는 주소 0이지만, 읽는 사람에게 변수의 종류를 알려 주므로 구분해서 쓴다. 클래스 자체를 담는 변수에는 Nil을 쓰는데, 이 책에서 쓸 일은 거의 없다.

비어 있는 값을 나타내는 이름
이름쓰는 대상예출력
nil객체 포인터NSString *memo = nil;%@로 찍으면 (null)
NULLC 포인터const char *raw = NULL;%s에 넘기면 안 되므로 먼저 비교
NO, 0BOOL, 정수BOOL done = NO;값 그대로

Objective-C에서는 nil에게 메시지를 보내도 프로그램이 멈추지 않고, 반환 타입이 숫자라면 0을 돌려받는다. 이 장의 예제에서도 nil 문자열의 길이를 물어 0을 확인한다. 이 동작을 어떻게 활용하는지는 메시지를 다룰 다음 장에서 이어 설명한다. 반면 NULL 포인터를 %s에 넘기거나 역참조하는 것은 C의 정의되지 않은 동작이므로 반드시 먼저 비교해야 한다.

형식 지정자와 출력 함수

문자열을 만들 때는 [NSString stringWithFormat:@"..."]를 쓴다. 이 형식 문자열에서는 C의 지정자에 더해 객체를 위한 %@를 쓸 수 있다. %@는 객체의 설명 문자열을 끼워 넣는다. 문자열 객체는 자기 내용을, 숫자 객체는 숫자를 보여 준다. 한편 C의 printf는 %@를 모른다. 객체를 printf로 찍으려면 [title UTF8String]처럼 C 문자열(const char *)로 바꿔서 %s에 넘긴다. UTF8String이 돌려주는 포인터는 이 객체가 살아 있는 동안 쓰는 값이라, 곧바로 출력하는 용도로만 쓴다.

NSLog는 쓰지 않는다. NSLog는 출력 앞에 날짜, 시각, 프로세스 이름과 번호를 붙이고, 그 내용을 표준 출력이 아닌 표준 오류로 보낸다. 실행할 때마다 시각이 달라지므로 결과가 항상 같아야 하는 예제 출력에 어울리지 않고, 셸에서 >로 표준 출력만 파일에 저장하면 NSLog 내용은 빠진다. 이 책에서는 결과 출력을 printf("%s\n", [문자열 UTF8String])로 하고, NSLog는 진단 메시지를 남기는 용도로만 따로 다룬다. 지정자 전체 목록은 Apple의 문자열 형식 지정자 문서에서 확인할 수 있다.

자주 쓰는 형식 지정자와 넘겨야 하는 값
지정자받는 값쓸 수 있는 곳넘기는 모양
%dintprintf, stringWithFormat:copies
%ldlong둘 다(long)lateDays
%luunsigned long둘 다(unsigned long)[title length]
%fdouble둘 다(double)fee
%sconst char *둘 다[title UTF8String]
%@객체stringWithFormat: 같은 Foundation 함수만title
%%없음(퍼센트 기호)둘 다없음

완성 코드

아래 프로그램은 앞에서 설명한 타입을 한 번씩 사용한다. 파일 하나(main.m)로 되어 있다.

#import <Foundation/Foundation.h>

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

static const char *yesNo(BOOL flag) {
    return flag ? "YES" : "NO";
}

int main(void) {
    @autoreleasepool {
        // 1. C 기본 타입
        int copies = 3;
        long shelfNo = 12L;
        double price = 18500.5;
        char grade = 'A';
        printf("[C 타입] copies=%d shelfNo=%ld price=%.1f grade=%c\n",
               copies, shelfNo, price, grade);
        printf("[크기] int=%zu long=%zu double=%zu char=%zu\n",
               sizeof(int), sizeof(long), sizeof(double), sizeof(char));
        printf("[크기] NSInteger=%zu CGFloat=%zu BOOL=%zu\n",
               sizeof(NSInteger), sizeof(CGFloat), sizeof(BOOL));

        // 2. NSInteger 와 CGFloat
        NSInteger lateDays = 6;
        CGFloat feePerDay = 150.5;
        CGFloat fee = lateDays * feePerDay;
        printf("[NSInteger/CGFloat] lateDays=%ld fee=%.1f\n",
               (long)lateDays, (double)fee);

        NSInteger pages = 250;
        NSInteger readPages = 100;
        NSInteger wrongRatio = readPages / pages;
        CGFloat ratio = (CGFloat)readPages / pages;
        printf("[나눗셈] 정수=%ld 실수=%.1f\n", (long)wrongRatio, (double)ratio);
        printf("[진행률] %.0f%%\n", (double)(ratio * 100));

        // 3. BOOL
        BOOL isLate = lateDays > 0;
        BOOL isOnShelf = NO;
        printf("[BOOL] isLate=%s isOnShelf=%s\n", yesNo(isLate), yesNo(isOnShelf));
        if (isLate && isOnShelf == NO) {
            say(@"연체 중이며 서가에 없음");
        }

        // 4. 객체 포인터
        NSString *title = @"Objective-C 입문";
        NSString *alias = title;
        say([NSString stringWithFormat:@"[객체] title=%@ 길이=%lu",
             title, (unsigned long)[title length]]);
        printf("[포인터] alias==title: %s\n", yesNo(alias == title));

        // 5. id
        id item = @"사내 규정집";
        say([NSString stringWithFormat:@"[id] 문자열 담김: %@", item]);
        item = @42;
        say([NSString stringWithFormat:@"[id] 숫자 담김: %@", item]);
        printf("[id] 문자열인가: %s\n", yesNo([item isKindOfClass:[NSString class]]));

        // 6. nil 과 NULL
        NSString *missing = nil;
        const char *rawName = NULL;
        say([NSString stringWithFormat:@"[nil] missing=%@ 길이=%lu",
             missing, (unsigned long)[missing length]]);
        printf("[NULL] rawName=%s\n", rawName == NULL ? "NULL" : rawName);
    }
    return 0;
}

줄별 해설

  • #import <Foundation/Foundation.h>: NSString, NSInteger, CGFloat, BOOL 등이 선언된 Foundation을 읽어 들인다. #include와 달리 같은 파일을 두 번 읽지 않는다.
  • say: 문자열 객체를 받아 UTF8String으로 C 문자열로 바꾼 뒤 printf로 한 줄 출력한다. 결과를 표준 출력으로 보내는 통로를 한곳에 모은 것이다. 매개변수 NSString *text가 객체 포인터 선언의 예다.
  • yesNo: BOOL을 받아 "YES" 또는 "NO"를 돌려준다. 조건 연산자 ? :는 C와 같다.
  • int main(void)와 @autoreleasepool { ... }: 프로그램 시작점과, 객체의 메모리 관리를 위한 블록이다. 메모리 관리는 뒤에서 따로 다루므로 지금은 Foundation 코드를 감싸는 관용구로 본다.
  • 1번 묶음: int, long, double, char 변수를 만들어 %d %ld %f %c로 찍는다. 12L의 L은 long 리터럴 표시다. sizeof의 결과 타입은 size_t이고 지정자는 %zu다.
  • [크기] NSInteger=... 줄: 64비트 환경에서 NSInteger와 CGFloat가 8바이트, BOOL이 1바이트임을 확인한다.
  • 2번 묶음: lateDays * feePerDay에서 정수가 실수로 자동 변환되어 903.0이 된다. 출력할 때는 (long), (double) 캐스트로 지정자와 타입을 맞춘다.
  • wrongRatio와 ratio: 같은 100과 250으로 나누지만 앞은 정수 나눗셈이라 0, 뒤는 (CGFloat)readPages로 먼저 바꿔 실수 나눗셈을 하므로 0.4다. %.0f%%는 소수 없이 출력하고 뒤에 퍼센트 기호를 붙인다.
  • 3번 묶음: 비교식의 결과를 BOOL에 담고, isLate && isOnShelf == NO가 참이면 안내 문장을 출력한다.
  • 4번 묶음: @"..."는 문자열 객체를 만드는 리터럴이다. NSString *alias = title;은 객체를 복사하지 않고 주소만 복사한다. stringWithFormat: 안의 %@는 title을, %lu는 (unsigned long)으로 바꾼 길이를 받는다. "Objective-C 입문"은 영문·기호 11자, 공백 1자, 한글 2자라 길이가 14다.
  • 5번 묶음: id item에 문자열을 담았다가 @42(숫자 객체 리터럴)로 바꿔 담는다. 숫자 객체는 7장에서 자세히 다룬다. isKindOfClass:의 결과는 이제 숫자가 들어 있으므로 NO다.
  • 6번 묶음: nil 문자열은 %@로 (null)이 찍히고, 길이를 물으면 0이다. NULL C 포인터는 %s에 넘기기 전에 == NULL로 확인해서 안전하게 출력한다.
  • return 0;: 정상 종료를 알린다. 표준 입력은 읽지 않으므로 실행하면 곧바로 끝난다.

실행 결과

파일 이름을 main.m으로 저장하고 다음 명령으로 컴파일한 뒤 실행한다. 경고는 나오지 않아야 한다.

clang -fobjc-arc -Wall -framework Foundation main.m -o app
./app

예상 출력은 다음과 같다. 크기 출력은 64비트 환경 기준이다.

[C 타입] copies=3 shelfNo=12 price=18500.5 grade=A
[크기] int=4 long=8 double=8 char=1
[크기] NSInteger=8 CGFloat=8 BOOL=1
[NSInteger/CGFloat] lateDays=6 fee=903.0
[나눗셈] 정수=0 실수=0.4
[진행률] 40%
[BOOL] isLate=YES isOnShelf=NO
연체 중이며 서가에 없음
[객체] title=Objective-C 입문 길이=14
[포인터] alias==title: YES
[id] 문자열 담김: 사내 규정집
[id] 숫자 담김: 42
[id] 문자열인가: NO
[nil] missing=(null) 길이=0
[NULL] rawName=NULL

Linux에서 GNUstep으로 빌드할 때는 -framework Foundation 대신 gnustep-config가 알려 주는 옵션을 써야 한다. 이 책의 명령은 macOS 기준이다.

실무에서 자주 틀리는 것

NSInteger를 %d로 찍는다

틀린 코드:

NSInteger lateDays = 6;
printf("연체 %d일\n", lateDays);

64비트에서 NSInteger는 long이므로 %d와 크기가 맞지 않는다. -Wall이 형식 경고를 내고, 값이 큰 경우 잘못된 숫자가 찍힐 수 있다. 고친 코드:

printf("연체 %ld일\n", (long)lateDays);

정수끼리 나눈 뒤 실수에 담는다

틀린 코드:

NSInteger readPages = 100;
NSInteger pages = 250;
CGFloat ratio = readPages / pages;   // 0.0

나눗셈은 두 피연산자가 정수이면 정수로 계산된 뒤에 변환된다. 결과를 담는 변수의 타입은 계산 방식을 바꾸지 않는다. 고친 코드:

CGFloat ratio = (CGFloat)readPages / pages;   // 0.4

printf에 %@를 쓰거나 객체를 %s에 넘긴다

틀린 코드:

NSString *title = @"Objective-C 입문";
printf("%@\n", title);
printf("%s\n", title);

printf는 %@를 해석하지 못한다. %s는 C 문자열 주소를 기대하는데 객체 주소를 받으므로 엉뚱한 바이트를 읽는다. 고친 코드:

printf("%s\n", [title UTF8String]);

BOOL을 YES와 직접 비교하거나 정수를 그대로 담는다

틀린 코드:

NSInteger copies = 3;
BOOL hasStock = copies;
if (hasStock == YES) { /* ... */ }

어떤 환경에서는 BOOL이 1바이트 정수라서 3이 그대로 들어가고, 3 == YES는 거짓이 된다. 참이면서도 조건에 걸리지 않는 코드가 되는 것이다. 고친 코드는 조건식의 결과를 담고, 검사할 때는 변수 자체를 쓴다.

BOOL hasStock = copies > 0;
if (hasStock) { /* ... */ }

한눈에 보기

이 장에서 다룬 타입과 값의 요약
주제핵심출력주의
C 기본 타입값을 변수에 직접 담는다%d %ld %f %c크기는 플랫폼마다 다르다
NSInteger플랫폼에 맞는 정수 타입%ld + (long)%d 사용 금지
CGFloat플랫폼에 맞는 실수 타입%f + (double)정수끼리 나누지 않는다
BOOLYES·NO문자열로 바꿔 출력== YES 비교를 피한다
객체 포인터NSString *처럼 주소를 담는다%@(Foundation), UTF8String(printf)대입은 주소 복사다
id클래스를 정하지 않은 객체 포인터%@*를 붙이지 않는다
nil / NULL객체용 / C 포인터용 빈 값(null) / 먼저 비교NULL을 %s에 직접 넘기지 않는다

연습 문제

  1. NSInteger renewCount = 2; CGFloat fine = 450.0; NSString *member = @"김하나"; 세 변수를 한 줄 문자열 김하나 연장 2회 연체료 450.0으로 만들어 출력하는 문장을 써라. 결과는 표준 출력으로 보내야 한다.
  2. 다음 코드는 반납률을 0.75로 계산하려 하지만 0.0이 나온다. 이유를 설명하고 고쳐라.
    NSInteger returned = 3;
    NSInteger total = 4;
    CGFloat rate = returned / total;
    
  3. 다음 코드의 출력을 예측하라.
    NSString *memo = nil;
    id value = memo;
    printf("%s\n", value == nil ? "비어 있음" : "있음");
    value = @"대출";
    printf("%s\n", value == nil ? "비어 있음" : "있음");
    
  4. 다음 네 변수 중 nil로 초기화해야 할 것과 NULL로 초기화해야 할 것을 나누고, 이유를 한 줄로 써라: NSString *a;, const char *b;, id c;, char *d;

정답과 해설

  1. 정답:
    printf("%s\n", [[NSString stringWithFormat:@"%@ 연장 %ld회 연체료 %.1f",
                     member, (long)renewCount, (double)fine] UTF8String]);
    
    %@는 문자열 객체, %ld는 (long)으로 바꾼 NSInteger, %.1f는 (double)로 바꾼 CGFloat를 받는다. 앞에서 만든 say 함수가 있다면 say([NSString stringWithFormat:...])로 줄일 수 있다. NSLog는 표준 오류로 나가고 시각이 붙으므로 쓰지 않는다.
  2. returned와 total이 둘 다 정수라서 3 / 4가 정수 나눗셈으로 먼저 계산되어 0이 되고, 그 뒤에 0.0으로 변환되어 담긴다. 한쪽을 먼저 실수로 바꾸면 된다: CGFloat rate = (CGFloat)returned / total; 이렇게 하면 0.75가 된다.
  3. 출력은 비어 있음, 있음 두 줄이다. memo가 nil이라 id 변수 value도 처음에는 nil이다. 문자열을 대입하면 nil이 아니게 된다. id 변수는 객체 포인터이므로 nil과 비교할 수 있다.
  4. a와 c는 객체 포인터(NSString *, id)이므로 nil, b와 d는 C 문자열 포인터(const char *, char *)이므로 NULL이다. 값은 같은 0이지만 변수의 종류를 이름으로 드러내는 관례다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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