Devin.KR

프로토콜과 카테고리 - 기존 클래스 넓히기

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지는 클래스 하나가 자기 일을 혼자 처리하는 코드를 썼다. 실제 프로그램에서는 한 객체가 일을 하다가 다른 객체에게 알려야 하는 순간이 생긴다. 이때 알림을 받는 쪽이 어떤 클래스인지 모른 채로 "이런 메서드를 가진 객체"라는 조건만 걸어 두는 장치가 프로토콜이다. 이 장은 프로토콜과 이를 이용한 델리게이트 패턴, 이미 있는 클래스에 메서드를 더하는 카테고리, 클래스 안쪽에만 보이는 프로퍼티를 만드는 클래스 확장을 다룬다.

  • @protocol 로 메서드 약속을 선언하고 @required 와 @optional 의 차이를 설명한다.
  • 델리게이트 패턴을 직접 구현하고, 델리게이트를 weak 로 두는 이유를 말한다.
  • respondsToSelector: 로 선택 메서드를 안전하게 호출한다.
  • 카테고리로 NSString 에 메서드를 추가하고, 이름 충돌을 피하는 규칙을 지킨다.
  • 클래스 확장(익명 카테고리)으로 비공개 프로퍼티와 비공개 메서드를 만든다.

문제 상황

사내 도서 대출 관리 도구에 "대출 창구" 객체를 만들었다고 하자. 창구는 책을 빌려 주면 누군가에게 알려야 한다. 어떤 날은 화면에 로그를 찍어야 하고, 어떤 날은 대출 건수만 세면 되고, 나중에는 메신저로 알림을 보내야 할 수도 있다. 창구 클래스 안에 이 모든 경우를 if 문으로 넣으면 알림 방식이 하나 늘 때마다 창구 클래스를 고쳐야 한다.

반대로 창구가 로그 클래스를 직접 이름으로 부르면 창구는 로그 클래스 없이는 컴파일되지 않는다. 필요한 것은 "대출이 일어나면 이 메서드를 불러 줄 테니, 받을 객체가 알아서 처리하라"는 약속이다. 약속의 내용만 정해 두고 받는 쪽은 나중에 갈아 끼울 수 있어야 한다.

두 번째 문제는 NSString 이다. 책 제목은 앞뒤에 공백이나 줄바꿈이 섞여 들어오는 일이 많고, 화면에는 항상 《제목》 형태로 찍고 싶다. NSString 의 소스를 고칠 수는 없지만, 우리 프로젝트 안에서는 NSString 이 이 메서드를 가진 것처럼 쓰고 싶다. 세 번째 문제는 창구 내부의 재고 장부다. 장부를 헤더에 적으면 바깥 코드가 마음대로 건드린다. 구현 파일 안에서만 보이게 숨기고 싶다.

프로토콜: 메서드 약속을 타입으로 만든다

@protocol 선언

프로토콜(protocol)은 클래스가 아니다. 메서드 선언의 묶음에 이름을 붙인 것이고, 구현은 들어 있지 않다. 어떤 클래스가 그 프로토콜을 "따른다"고 선언하고 메서드를 구현하면, 그 클래스의 객체는 약속을 지키는 객체로 취급된다. 다른 언어의 인터페이스와 비슷한 개념이다.

@protocol LoanDeskDelegate <NSObject>
@required
- (void)loanDesk:(LoanDesk *)desk didLendTitle:(NSString *)title toMember:(NSString *)member;
@optional
- (void)loanDesk:(LoanDesk *)desk didRejectTitle:(NSString *)title reason:(NSString *)reason;
@end

이름 뒤의 <NSObject> 는 "이 프로토콜은 NSObject 프로토콜이 정한 기본 메서드(respondsToSelector: 등)를 포함한다"는 뜻이다. 이렇게 해 두면 프로토콜 타입의 변수에 respondsToSelector: 를 보내도 컴파일러가 불평하지 않는다. 메서드 첫 인자에 보낸 쪽 객체(여기서는 LoanDesk)를 넘기는 것은 Cocoa 의 관례다. 델리게이트 하나가 창구 여러 개의 알림을 받을 때 어느 창구인지 구분할 수 있다.

프로토콜 안에서 LoanDesk 타입을 쓰는데, LoanDesk 클래스는 아직 선언되지 않았다. 그래서 위에 @class LoanDesk; 를 한 줄 적는다. "그런 이름의 클래스가 있다"고만 알려 주는 전방 선언(forward declaration)이다. 포인터로 쓰는 데는 이 정보로 충분하다.

@required 와 @optional

메서드는 두 구역으로 나눌 수 있다. 아무 키워드도 쓰지 않으면 @required 로 취급된다. 프로토콜을 따르겠다고 선언한 클래스가 @required 메서드를 구현하지 않으면 컴파일러가 경고를 낸다. @optional 메서드는 구현하지 않아도 경고가 없다. 대신 부르는 쪽이 "이 객체가 그 메서드를 구현했는가"를 확인한 뒤 불러야 한다.

@required 와 @optional 을 다루는 방식 비교
구분구현 안 하면부르는 쪽 확인알맞은 용도
@required컴파일 경고필요 없음없으면 동작이 성립하지 않는 알림
@optional경고 없음respondsToSelector: 로 확인있으면 좋은 부가 알림

확인하지 않고 @optional 메서드를 보내면 구현이 없는 객체에서 "unrecognized selector" 오류가 나며 프로그램이 멈춘다. 컴파일러는 프로토콜에 선언된 메서드이므로 이 호출을 막지 않는다. 이 점이 가장 흔한 실수이고, 뒤의 "실무에서 자주 틀리는 것"에서 다시 본다.

따르는 쪽과 확인하는 방법

클래스가 프로토콜을 따른다는 선언은 클래스 이름 뒤 꺾쇠에 적는다.

@interface PrintLogger : NSObject <LoanDeskDelegate>
@end

프로토콜 이름이 타입의 일부가 되기도 한다. id<LoanDeskDelegate> 는 "LoanDeskDelegate 를 따르는 어떤 객체"라는 뜻이다. 클래스는 묻지 않으므로 PrintLogger 든 다른 클래스든 상관없다. 실행 중에 확인하려면 conformsToProtocol: 에 @protocol(LoanDeskDelegate) 를 넘기면 된다. 메서드 하나 단위로 확인하려면 respondsToSelector: 에 @selector(메서드이름) 을 넘긴다. 메서드 이름은 인자 라벨과 콜론을 모두 이어 쓴 loanDesk:didRejectTitle:reason: 형태다.

델리게이트 패턴

델리게이트(delegate, 위임 대상)는 "내 일의 일부를 대신 처리하거나 알림을 받아 줄 객체"를 뜻한다. 구현은 세 단계다. 첫째, 알림 메서드를 프로토콜로 선언한다. 둘째, 알림을 보내는 클래스에 id<프로토콜> 타입의 delegate 프로퍼티를 둔다. 셋째, 일이 일어난 자리에서 delegate 에게 메시지를 보낸다.

LoanDesk 는 프로토콜 타입만 알고, 프로토콜을 따르는 PrintLogger 와 TallyOnly 는 서로 바꿔 끼울 수 있다.

그림처럼 LoanDesk 는 프로토콜만 알고, 받는 쪽이 PrintLogger 인지 TallyOnly 인지는 모른다. 그래서 알림 방식을 바꿔도 LoanDesk 는 고치지 않는다. 델리게이트가 아직 정해지지 않았거나 nil 이어도 괜찮다. Objective-C 에서 nil 에게 보낸 메시지는 아무 일도 하지 않고 넘어가기 때문이다. respondsToSelector: 도 nil 에는 NO 를 돌려준다.

delegate 는 weak 로 둔다

delegate 프로퍼티는 보통 weak 로 선언한다. 델리게이트가 창구를 strong 으로 들고 있고 창구도 델리게이트를 strong 으로 들고 있으면, 서로가 서로를 붙잡아 둘 다 해제되지 못한다. 이를 순환 참조(retain cycle)라고 한다. weak 는 객체를 붙잡지 않고 가리키기만 하며, 가리키던 객체가 해제되면 자동으로 nil 이 된다. 이 대신 우리가 알아 두어야 할 점이 하나 있다. 델리게이트 객체의 수명은 델리게이트를 지정한 쪽이 책임진다. 지역 변수에 담아 두면 그 변수가 살아 있는 동안 유지된다.

카테고리와 클래스 확장

카테고리: 기존 클래스에 메서드를 더한다

카테고리(category)는 소스를 고치지 않고 이미 있는 클래스에 메서드를 추가하는 문법이다. 클래스 이름 뒤 괄호에 카테고리 이름을 적는다.

@interface NSString (LoanFormat)
- (NSString *)loan_titleLabel;
@end

@implementation NSString (LoanFormat)
- (NSString *)loan_titleLabel {
    NSString *trimmed = [self stringByTrimmingCharactersInSet:
        [NSCharacterSet whitespaceAndNewlineCharacterSet]];
    return [NSString stringWithFormat:@"《%@》", trimmed];
}
@end

이 선언이 보이는 파일에서는 모든 NSString 객체, 리터럴 @"..." 까지 loan_titleLabel 메시지를 받는다. 추가된 메서드는 원래 있던 메서드와 똑같이 동작하며 self 도 쓸 수 있다. 이 장에서는 한 파일에서 다루지만, 파일을 나누면 카테고리 헤더를 import 한 곳에서만 보인다.

카테고리를 쓸 때 지킬 점이 둘 있다. 첫째, 메서드 이름에 접두어를 붙인다. 위에서 loan_ 을 붙인 이유다. 카테고리가 이미 있는 이름과 같은 메서드를 정의하면 어느 구현이 쓰일지 정해져 있지 않다. 우리가 모르는 사이에 프레임워크의 다음 버전이 같은 이름의 메서드를 추가할 수도 있으므로 접두어로 자기 영역을 표시해 둔다. 둘째, 카테고리는 메서드만 추가할 수 있고 인스턴스 변수를 추가할 수 없다. 객체가 차지하는 메모리 크기는 클래스를 만들 때 정해지기 때문이다. 그래서 카테고리에 프로퍼티를 선언해도 저장 공간이 생기지 않는다. 값을 계산해서 돌려주는 메서드는 문제없다.

클래스 확장: 이름 없는 카테고리

괄호 안을 비워 둔 카테고리를 클래스 확장(class extension)이라고 한다. 이름이 없는 대신 두 가지가 다르다. 확장은 그 클래스의 구현 파일(.m) 안에, @implementation 앞에 적는다. 그리고 인스턴스 변수와 프로퍼티를 추가할 수 있다. 컴파일러가 클래스를 만드는 시점에 함께 처리하기 때문이다.

공개 인터페이스, 클래스 확장, 카테고리는 선언 위치에 따라 보이는 범위가 다르다.

확장의 용도는 두 가지다. 하나는 비공개 프로퍼티다. 헤더에는 readonly 로 공개한 프로퍼티를 확장에서 readwrite 로 다시 선언하면, 바깥에서는 읽기만 되고 클래스 안에서는 쓸 수 있다. 다른 하나는 구현 파일 안에서만 쓰는 도우미 메서드 선언이다. 접근 제한이 문법으로 강제되는 것은 아니고, 선언이 바깥에 보이지 않을 뿐이다. 그래도 헤더만 보고 코드를 쓰는 사람에게는 충분한 경계가 된다.

프로토콜, 카테고리, 클래스 확장의 비교
도구선언 모양하는 일저장 프로퍼티
프로토콜@protocol 이름 … @end메서드 약속에 이름을 붙임해당 없음
카테고리@interface 클래스 (이름)기존 클래스에 메서드 추가불가
클래스 확장@interface 클래스 ()비공개 프로퍼티·메서드 선언가능

완성 코드

아래 코드는 한 파일 main.m 으로 컴파일된다. 결과는 printf 로 표준 출력에 찍는다. NSLog 는 시각과 프로세스 정보를 앞에 붙여 표준 오류로 내보내므로 실행마다 출력이 달라지고, 결과를 파이프로 넘기거나 파일로 저장할 때 섞이지 않는다. 그래서 이 책에서는 결과 출력에 NSLog 를 쓰지 않고, 문자열을 만든 뒤 UTF8String 으로 C 문자열로 바꿔 printf 에 넘긴다.

main.m

#import <Foundation/Foundation.h>

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

@class LoanDesk;

@protocol LoanDeskDelegate <NSObject>
@required
- (void)loanDesk:(LoanDesk *)desk didLendTitle:(NSString *)title toMember:(NSString *)member;
@optional
- (void)loanDesk:(LoanDesk *)desk didRejectTitle:(NSString *)title reason:(NSString *)reason;
@end

@interface NSString (LoanFormat)
- (NSString *)loan_titleLabel;
@end

@implementation NSString (LoanFormat)
- (NSString *)loan_titleLabel {
    NSString *trimmed = [self stringByTrimmingCharactersInSet:
        [NSCharacterSet whitespaceAndNewlineCharacterSet]];
    return [NSString stringWithFormat:@"《%@》", trimmed];
}
@end

@interface LoanDesk : NSObject
@property (nonatomic, weak) id<LoanDeskDelegate> delegate;
@property (nonatomic, readonly) NSUInteger lentCount;
- (void)addTitle:(NSString *)title copies:(NSUInteger)copies;
- (void)lendTitle:(NSString *)title toMember:(NSString *)member;
- (NSUInteger)copiesOfTitle:(NSString *)title;
- (NSArray<NSString *> *)sortedTitles;
@end

@interface LoanDesk ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *stock;
@property (nonatomic, readwrite) NSUInteger lentCount;
@end

@implementation LoanDesk

- (instancetype)init {
    self = [super init];
    if (self) {
        _stock = [NSMutableDictionary dictionary];
    }
    return self;
}

- (void)addTitle:(NSString *)title copies:(NSUInteger)copies {
    NSUInteger now = [self.stock[title] unsignedIntegerValue];
    self.stock[title] = @(now + copies);
}

- (void)lendTitle:(NSString *)title toMember:(NSString *)member {
    NSUInteger left = [self.stock[title] unsignedIntegerValue];
    if (left == 0) {
        if ([self.delegate respondsToSelector:@selector(loanDesk:didRejectTitle:reason:)]) {
            [self.delegate loanDesk:self didRejectTitle:title reason:@"재고 없음"];
        }
        return;
    }
    self.stock[title] = @(left - 1);
    self.lentCount += 1;
    [self.delegate loanDesk:self didLendTitle:title toMember:member];
}

- (NSUInteger)copiesOfTitle:(NSString *)title {
    return [self.stock[title] unsignedIntegerValue];
}

- (NSArray<NSString *> *)sortedTitles {
    return [self.stock.allKeys sortedArrayUsingSelector:@selector(compare:)];
}

@end

@interface PrintLogger : NSObject <LoanDeskDelegate>
@end

@implementation PrintLogger

- (void)loanDesk:(LoanDesk *)desk didLendTitle:(NSString *)title toMember:(NSString *)member {
    say([NSString stringWithFormat:@"[대출] %@ -> %@", [title loan_titleLabel], member]);
}

- (void)loanDesk:(LoanDesk *)desk didRejectTitle:(NSString *)title reason:(NSString *)reason {
    say([NSString stringWithFormat:@"[거절] %@ (%@)", [title loan_titleLabel], reason]);
}

@end

@interface TallyOnly : NSObject <LoanDeskDelegate>
@property (nonatomic, assign) NSUInteger lent;
@end

@implementation TallyOnly

- (void)loanDesk:(LoanDesk *)desk didLendTitle:(NSString *)title toMember:(NSString *)member {
    self.lent += 1;
}

@end

int main(void) {
    @autoreleasepool {
        say([@"  운영체제 개론\n" loan_titleLabel]);

        LoanDesk *desk = [[LoanDesk alloc] init];
        [desk addTitle:@"객체지향 설계" copies:1];
        [desk addTitle:@"알고리즘 산책" copies:2];
        [desk addTitle:@"네트워크 기초" copies:1];

        [desk lendTitle:@"알고리즘 산책" toMember:@"김하나"];
        say([NSString stringWithFormat:@"대리자 없이 대출: 누적 %lu건",
             (unsigned long)desk.lentCount]);

        PrintLogger *logger = [[PrintLogger alloc] init];
        desk.delegate = logger;
        BOOL loggerConforms = [logger conformsToProtocol:@protocol(LoanDeskDelegate)];
        say([NSString stringWithFormat:@"logger 준수: %@", loggerConforms ? @"예" : @"아니오"]);
        [desk lendTitle:@"객체지향 설계" toMember:@"이둘"];
        [desk lendTitle:@"객체지향 설계" toMember:@"박셋"];

        TallyOnly *tally = [[TallyOnly alloc] init];
        desk.delegate = tally;
        BOOL tallyRejects = [tally respondsToSelector:@selector(loanDesk:didRejectTitle:reason:)];
        say([NSString stringWithFormat:@"tally 거절 콜백 구현: %@", tallyRejects ? @"예" : @"아니오"]);
        [desk lendTitle:@"알고리즘 산책" toMember:@"최넷"];
        [desk lendTitle:@"알고리즘 산책" toMember:@"정다섯"];
        say([NSString stringWithFormat:@"TallyOnly 집계 %lu건, 데스크 누적 %lu건",
             (unsigned long)tally.lent, (unsigned long)desk.lentCount]);

        for (NSString *title in [desk sortedTitles]) {
            say([NSString stringWithFormat:@"%@: %lu권",
                 title, (unsigned long)[desk copiesOfTitle:title]]);
        }
    }
    return 0;
}

줄별 해설

say 함수. 문자열을 받아 printf 로 한 줄 출력하는 C 함수다. static 은 이 파일 안에서만 쓰겠다는 뜻이다. 결과를 찍는 곳이 모두 이 함수를 거치므로, 출력 방식을 바꾸고 싶으면 한 곳만 고치면 된다.

@class 와 @protocol. @class LoanDesk; 로 이름만 먼저 알리고 프로토콜을 선언한다. 프로토콜의 첫 메서드는 키워드 없이 @required 아래에 있어 반드시 구현해야 하고, 두 번째는 @optional 아래에 있다.

NSString 카테고리. @interface 와 @implementation 에 모두 NSString (LoanFormat) 을 적는다. 구현에서는 앞뒤 공백과 줄바꿈을 잘라 내고 《 》 로 감싼다. 이름에 loan_ 접두어가 붙어 있어 NSString 의 기존 메서드와 겹치지 않는다.

LoanDesk 공개 인터페이스. delegate 는 weak 이고 lentCount 는 readonly 다. 바깥 코드는 대출 건수를 읽을 수만 있다. 재고를 다루는 stock 은 여기에 없다.

LoanDesk 클래스 확장. 이름 없는 괄호 @interface LoanDesk () 안에서 stock 을 비공개 프로퍼티로 선언한다. lentCount 는 readwrite 로 다시 선언해 클래스 안에서 setter 를 쓸 수 있다. 확장은 @implementation 앞에 있어야 구현부가 이 선언을 볼 수 있다. 프로퍼티의 인스턴스 변수 _stock 은 자동으로 만들어진다.

init. 딕셔너리는 만들어 두어야 쓸 수 있으므로 init 에서 _stock 에 빈 NSMutableDictionary 를 대입한다. 초기화에서는 setter 대신 인스턴스 변수에 직접 대입하는 것이 관례다.

lendTitle:toMember:. 장부에 없는 제목이면 stock[title] 이 nil 이고 nil 에 보낸 unsignedIntegerValue 는 0 이 되어 재고 없음과 같은 경로를 탄다. 재고가 없으면 델리게이트가 didRejectTitle:reason: 을 구현했는지 respondsToSelector: 로 확인한 뒤에만 부른다. 재고가 있으면 수량과 누적 건수를 갱신하고 @required 메서드를 확인 없이 부른다. 델리게이트가 nil 이면 이 호출은 조용히 지나간다.

sortedTitles. 딕셔너리의 키 배열을 compare: 로 정렬한다. 딕셔너리는 순서가 정해져 있지 않으므로 결과를 찍을 때는 항상 키를 정렬해 결정적인 출력을 만든다.

PrintLogger 와 TallyOnly. PrintLogger 는 프로토콜의 두 메서드를 모두 구현하고, TallyOnly 는 @required 하나만 구현해 건수를 센다. 둘은 서로 무관한 클래스이고 LoanDesk 는 둘의 이름을 모른다.

main. 먼저 카테고리 메서드를 리터럴 문자열에 바로 보낸다. 델리게이트를 지정하기 전의 대출은 아무 출력 없이 건수만 올린다. 이어서 logger 를 지정해 대출과 거절을 확인하고, delegate 를 tally 로 바꿔 같은 창구가 다른 방식으로 알림을 처리하는 모습을 본다. tally 는 거절 메서드가 없으므로 정다섯의 대출 요청은 거절되지만 아무 출력도 없다. logger 와 tally 는 지역 변수에 담겨 main 이 끝날 때까지 살아 있으므로 weak 참조가 중간에 nil 이 되지 않는다.

실행 결과

$ clang -fobjc-arc -Wall -framework Foundation main.m -o app
$ ./app
《운영체제 개론》
대리자 없이 대출: 누적 1건
logger 준수: 예
[대출] 《객체지향 설계》 -> 이둘
[거절] 《객체지향 설계》 (재고 없음)
tally 거절 콜백 구현: 아니오
TallyOnly 집계 1건, 데스크 누적 3건
객체지향 설계: 0권
네트워크 기초: 1권
알고리즘 산책: 0권

Linux 의 GNUstep 환경에서는 -framework 옵션 대신 gnustep-config 가 알려 주는 플래그를 써야 한다. 이 책의 컴파일 명령은 macOS 기준이다. 프로토콜과 카테고리 문법의 사실 관계는 Apple 의 Working with Protocols 문서에서 확인할 수 있다.

실무에서 자주 틀리는 것

@optional 메서드를 확인 없이 보낸다

틀린 코드는 컴파일이 되지만 델리게이트에 따라 실행 중에 멈춘다.

if (left == 0) {
    [self.delegate loanDesk:self didRejectTitle:title reason:@"재고 없음"];
    return;
}

델리게이트가 TallyOnly 이면 이 줄에서 unrecognized selector 오류가 난다. 고친 코드는 보내기 전에 확인한다.

if (left == 0) {
    if ([self.delegate respondsToSelector:@selector(loanDesk:didRejectTitle:reason:)]) {
        [self.delegate loanDesk:self didRejectTitle:title reason:@"재고 없음"];
    }
    return;
}

delegate 를 strong 으로 선언하거나 임시 객체를 지정한다

두 가지가 있다. 첫째, strong 으로 선언하면 델리게이트가 창구를 들고 있는 구조에서 순환 참조가 생긴다.

@property (nonatomic, strong) id<LoanDeskDelegate> delegate;   // 틀림
@property (nonatomic, weak) id<LoanDeskDelegate> delegate;     // 고침

둘째, weak 로 선언했는데 아무도 붙잡지 않는 임시 객체를 지정하면 그 줄이 끝나는 즉시 객체가 해제되고 delegate 는 nil 이 된다. 알림은 오류 없이 사라지므로 원인을 찾기 어렵다.

desk.delegate = [[PrintLogger alloc] init];   // 틀림: 대입 직후 해제된다
[desk lendTitle:@"객체지향 설계" toMember:@"이둘"];   // 출력 없음
PrintLogger *logger = [[PrintLogger alloc] init];   // 고침: 변수가 붙잡는다
desk.delegate = logger;
[desk lendTitle:@"객체지향 설계" toMember:@"이둘"];

카테고리 메서드에 접두어를 붙이지 않는다

짧고 평범한 이름은 기존 메서드와 겹치기 쉽다. 아래 코드의 trimmed 가 이미 있는 이름이라면 원래 구현이 가려지고, 어느 쪽이 쓰일지 보장되지 않는다.

@interface NSString (LoanFormat)
- (NSString *)trimmed;          // 틀림: 같은 이름이 이미 있을 수 있다
@end
@interface NSString (LoanFormat)
- (NSString *)loan_trimmed;     // 고침: 프로젝트 접두어를 붙인다
@end

카테고리에 저장 프로퍼티를 선언한다

카테고리는 인스턴스 변수를 만들 수 없다. 프로퍼티를 선언해도 getter 와 setter 를 직접 구현해야 하고, 값을 보관할 곳이 없다.

@interface NSString (LoanTag)
@property (nonatomic, copy) NSString *tag;   // 틀림: 저장 공간이 없다
@end

값을 계산해서 돌려줄 수 있다면 메서드로 만든다. 값을 저장해야 한다면 카테고리가 아니라 우리 소유의 클래스를 만들고, 그 클래스의 클래스 확장에 프로퍼티를 둔다.

@interface NSString (LoanTag)
- (NSString *)loan_firstWord;    // 고침: 저장하지 않고 계산한다
@end

한눈에 보기

이 장에서 쓴 문법과 의미
문법의미주의이 장의 예
@protocol … @end메서드 약속 선언구현을 담지 않는다LoanDeskDelegate
@required / @optional필수 / 선택 메서드 구역키워드가 없으면 @required대출 알림 / 거절 알림
클래스 <프로토콜>프로토콜을 따른다는 선언필수 메서드가 없으면 경고PrintLogger
id<프로토콜>프로토콜을 따르는 객체 타입delegate 는 weakdesk.delegate
respondsToSelector:메서드 구현 여부 확인@optional 호출 전에 사용거절 알림 전
@interface 클래스 (이름)카테고리접두어, 저장 프로퍼티 불가loan_titleLabel
@interface 클래스 ()클래스 확장.m 안, @implementation 앞stock, lentCount

연습 문제

  1. LoanDeskDelegate 를 따른다고 선언했지만 didLendTitle:toMember: 를 구현하지 않은 클래스를 컴파일하면 어떻게 되는가. didRejectTitle:reason: 을 구현하지 않으면 어떻게 되는가.
  2. ISBN 문자열에서 하이픈을 모두 없앤 문자열을 돌려주는 NSString 카테고리 메서드 loan_isbnDigits 를 작성하라. 접두어 규칙을 지켜야 한다.
  3. LoanDesk 의 클래스 확장에 비공개 메서드 - (BOOL)hasStockForTitle:(NSString *)title 을 선언하고, lendTitle:toMember: 에서 쓰도록 구현을 고쳐라.
  4. delegate 프로퍼티를 strong 으로 바꾸고, 델리게이트가 창구를 strong 프로퍼티로 들고 있다고 하자. 어떤 일이 벌어지는가.

정답과 해설

1번. 앞의 경우 컴파일러가 "프로토콜의 메서드가 구현되지 않았다"는 취지의 경고를 낸다. 이 책의 규칙은 경고 없이 컴파일되어야 하므로 반드시 구현해야 한다. 뒤의 경우는 @optional 이라 경고가 없다. 다만 부르는 쪽이 respondsToSelector: 로 확인하는 코드가 있어야 실행 중 오류를 피한다.

2번. 아래와 같이 작성한다.

@interface NSString (LoanFormat)
- (NSString *)loan_isbnDigits;
@end

@implementation NSString (LoanFormat)
- (NSString *)loan_isbnDigits {
    return [self stringByReplacingOccurrencesOfString:@"-" withString:@""];
}
@end

접두어 loan_ 이 붙어 있어 기존 메서드와 겹치지 않는다. 같은 이름의 카테고리를 한 파일에서 여러 번 선언하는 것은 피하고, 이미 있는 LoanFormat 카테고리 안에 메서드를 추가한다.

3번. 확장에 선언을 더하고 구현에서 사용한다.

@interface LoanDesk ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *stock;
@property (nonatomic, readwrite) NSUInteger lentCount;
- (BOOL)hasStockForTitle:(NSString *)title;
@end

- (BOOL)hasStockForTitle:(NSString *)title {
    return [self.stock[title] unsignedIntegerValue] > 0;
}

- (void)lendTitle:(NSString *)title toMember:(NSString *)member {
    if (![self hasStockForTitle:title]) {
        if ([self.delegate respondsToSelector:@selector(loanDesk:didRejectTitle:reason:)]) {
            [self.delegate loanDesk:self didRejectTitle:title reason:@"재고 없음"];
        }
        return;
    }
    NSUInteger left = [self.stock[title] unsignedIntegerValue];
    self.stock[title] = @(left - 1);
    self.lentCount += 1;
    [self.delegate loanDesk:self didLendTitle:title toMember:member];
}

hasStockForTitle: 는 헤더에 없으므로 바깥 코드의 자동 완성에 나타나지 않는다. 같은 파일 안에서 확장은 @implementation 앞에 있어야 하는 점도 확인한다.

4번. 창구는 델리게이트를, 델리게이트는 창구를 서로 붙잡는 순환 참조가 생긴다. 바깥에서 두 객체에 대한 참조를 모두 놓아도 서로의 참조 때문에 소유 개수가 0 이 되지 않아 해제되지 않는다. 메모리는 프로그램이 끝날 때까지 남는다. 델리게이트 쪽 참조를 weak 로 두면 이 고리가 끊어진다.

댓글 0

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

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