Devin.KR

Objective-C · 기본

Objective-C 문법과 Cocoa 기초

블록 - 값으로 다루는 코드 조각

블록 문법(^), typedef 로 블록 타입 이름 짓기, 변수 캡처와 __block, 블록 속 self 순환 참조와 weakSelf, enumerateObjectsUsingBlock

개발자KR · 원고 갱신

이 장에서 배우는 것

지금까지 코드는 "이름이 붙은 함수나 메서드"로만 존재했다. 블록(block)은 이름 없는 코드 조각을 변수에 담고, 인자로 넘기고, 나중에 실행할 수 있게 해 준다. 컬렉션을 훑을 때 어떤 조건으로 거를지, 한 건마다 무엇을 할지를 호출하는 쪽이 정해서 넘기는 방식이 대표적인 쓰임이다. 이 장에서는 블록을 쓰는 방법과 함께, 블록이 주변 변수를 붙잡는 규칙과 그 때문에 생기는 순환 참조를 다룬다.

  • 블록 리터럴 ^ 의 문법과 블록 변수 선언 방식을 읽고 쓴다.
  • typedef 로 블록 타입에 이름을 붙여 선언을 짧게 만든다.
  • 블록이 변수를 캡처하는 규칙과 __block 의 역할을 설명한다.
  • 블록 속 self 가 만드는 순환 참조를 알아보고 weakSelf 로 끊는다.
  • enumerateObjectsUsingBlock: 으로 배열을 훑고 도중에 멈춘다.

문제 상황

사내 도서 대출 관리 도구에 요구가 두 가지 들어왔다. 하나는 "대출 기간이 14일을 넘긴 건만 보여 달라"는 것이고, 다른 하나는 "대출 목록을 한 줄씩 보고서 형식으로 찍어 달라"는 것이다. 기준 일수는 내일 21일로 바뀔 수 있고, 출력 형식은 화면용과 파일용이 다를 수 있다.

조건마다 메서드를 하나씩 만들면 loansOverdue14, loansOverdue21 처럼 이름이 불어난다. 조건 자체를 인자로 넘길 수 있다면 메서드는 하나로 충분하다. 앞 장에서 프로토콜로 "해야 할 일의 약속"을 객체에 맡겼는데, 한 줄짜리 동작을 위해 클래스를 새로 만드는 일은 번거롭다. 블록은 이런 자리에서 쓴다.

C 에서는 함수 포인터가 비슷한 역할을 하지만, 함수 포인터는 주변 지역 변수를 들고 다닐 수 없다. 블록은 만들어진 자리의 변수를 함께 가지고 간다. 그 대가로 "무엇을 어떻게 붙잡는가"를 알아야 하며, 이 장의 절반은 그 규칙이다.

블록 문법과 타입 이름

^ 로 만드는 코드 조각

블록 리터럴은 ^ 로 시작하고, 매개변수 목록과 중괄호 본문이 따른다. 블록을 담는 변수는 함수 포인터의 * 자리에 ^ 를 쓴다.

int (^add)(int, int) = ^int(int a, int b) { return a + b; };
int sum = add(3, 4);   // 함수처럼 호출한다

포인터를 다룬 기본서에서 본 int (*fp)(int, int) 와 모양이 같고 * 만 ^ 로 바뀌었다고 보면 된다. 변수 이름은 ^ 바로 뒤 괄호 안에 있다. 반환 타입은 본문에서 추론되므로 생략할 수 있고, 매개변수가 없으면 ^{ ... } 로 쓴다. 매개변수가 없는 블록 타입은 void (^)(void) 로 적는 것이 안전하다.

블록 리터럴과 타입 표기의 형태
형태예뜻
전체 형태^int(int a) { return a * 2; }반환 타입과 매개변수를 모두 적는다
반환 타입 생략^(int a) { return a * 2; }return 문에서 타입을 추론한다
매개변수 없음^{ puts("hi"); }인자도 반환값도 없는 블록이다
변수 선언void (^job)(void)job 이 블록을 담는 변수다

typedef 로 이름 짓기

블록 타입을 메서드 매개변수나 프로퍼티에 그대로 쓰면 선언이 읽기 어려워진다. 같은 타입을 여러 번 쓸 때는 typedef 로 이름을 붙인다.

typedef BOOL (^LoanFilter)(Loan *loan);
typedef void (^LineWriter)(NSString *line);

- (NSArray *)loansMatching:(LoanFilter)filter;
@property (nonatomic, copy) LineWriter writer;

타입 이름이 자리에 오면 선언은 일반 타입처럼 읽힌다. 블록 프로퍼티에는 copy 를 붙이는 것이 관례다. 블록은 처음에 스택에 만들어질 수 있고, 만든 함수가 끝나면 사라진다. copy 는 그것을 힙으로 옮겨 오래 쓸 수 있게 한다. ARC 에서는 강한 참조 변수에 대입할 때 대체로 알아서 복사되지만, 프로퍼티에는 의도를 드러내려고 copy 를 쓴다.

변수 캡처와 __block

블록 본문에서 바깥 지역 변수를 쓰면 블록이 그 변수를 캡처(capture)한다. 기본 규칙은 블록을 만드는 순간의 값을 복사하는 것이다.

NSInteger limit = 14;
LoanFilter overdue = ^BOOL(Loan *loan) { return loan.days > limit; };
limit = 30;   // 블록 안의 limit 은 여전히 14

복사본이므로 블록 안에서 바깥 변수를 바꿀 수도 없다. 바꾸려고 하면 컴파일 오류가 난다. 값을 공유해야 한다면 변수 선언에 __block 을 붙인다. 이 변수는 블록과 바깥 코드가 함께 가리키는 별도의 저장 공간에 놓인다.

일반 변수는 블록을 만들 때 복사되고, __block 변수는 블록과 바깥 코드가 한 저장 공간을 공유한다
변수 종류별 캡처 방식
변수 종류캡처 방식블록 안에서 대입바깥 변경이 보이는가
일반 지역 변수생성 시점 값 복사불가(컴파일 오류)보이지 않는다
__block 변수저장 공간 공유가능보인다
객체 포인터(일반)포인터 값 복사포인터 대입은 불가가리키는 객체의 변경은 보인다

마지막 줄을 주의해야 한다. NSMutableArray *list 를 캡처한 블록은 [list addObject:x] 를 __block 없이 할 수 있다. 포인터 값은 복사되었지만 같은 객체를 가리키기 때문이다. 반대로 list = nil 이나 list = [NSMutableArray array] 처럼 변수 자체에 대입하려면 __block 이 필요하다.

블록 속 self 와 순환 참조

블록 안에서 self 를 쓰면 블록이 self 객체를 강하게 붙잡는다. _name 처럼 인스턴스 변수를 직접 읽는 것도 self 를 통한 접근이어서 마찬가지다. 객체가 블록을 프로퍼티로 들고 있고 그 블록이 다시 객체를 붙잡으면, 둘이 서로를 붙들어 참조 횟수가 0 이 되지 않는다. 앞서 ARC 를 다룬 장에서 본 순환 참조(retain cycle)가 블록을 통해 생기는 것이다.

블록이 self 를 강하게 잡으면 서로 붙들려 해제되지 않고, weak 로 잡으면 해제된다

해법은 블록에 넘기는 쪽을 약한 참조로 바꾸는 것이다. 블록 밖에서 __weak 변수 weakSelf 를 만들고, 블록 안에서는 그것을 다시 지역 강한 변수 strongSelf 로 옮겨 쓴다. 약한 참조는 객체가 해제되면 자동으로 nil 이 되므로, 블록 실행 시점에 객체가 이미 없을 수 있다. 그래서 strongSelf 가 nil 인지 먼저 확인한다. 강한 변수로 옮기는 이유는 블록이 실행되는 동안 객체가 중간에 해제되지 않게 붙잡아 두기 위해서다.

모든 블록에 이 처리가 필요한 것은 아니다. enumerateObjectsUsingBlock: 에 넘기는 블록처럼 호출이 끝나면 사라지는 블록은 self 를 써도 순환이 생기지 않는다. 위험한 경우는 객체가 블록을 오래 보관할 때다.

enumerateObjectsUsingBlock:

NSArray 의 enumerateObjectsUsingBlock: 은 요소마다 블록을 호출한다. 블록은 요소, 인덱스, 중단 플래그 포인터 BOOL *stop 을 받는다. 반복을 끝내려면 *stop = YES 를 대입한다. 블록 안의 return 은 이번 요소의 처리만 끝낼 뿐 반복을 멈추지 않는다. *stop = YES 를 대입한 뒤에도 그 블록의 나머지 코드는 끝까지 실행된다. 멈추는 것은 다음 요소부터다.

완성 코드

한 파일 main.m 으로 구성했다. 결과는 printf 로 표준 출력에 낸다. NSLog 는 시각과 프로세스 정보를 앞에 붙이고 표준 오류로 나가므로, 출력이 실행할 때마다 달라지고 파이프로 결과를 모을 때도 섞이지 않아서 결과 출력에는 쓰지 않는다.

main.m

#import <Foundation/Foundation.h>

@class Loan;
typedef BOOL (^LoanFilter)(Loan *loan);
typedef void (^LineWriter)(NSString *line);

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

@interface Loan : NSObject
@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, copy, readonly) NSString *member;
@property (nonatomic, assign, readonly) NSInteger days;
- (instancetype)initWithTitle:(NSString *)title
                       member:(NSString *)member
                         days:(NSInteger)days;
@end

@implementation Loan
- (instancetype)initWithTitle:(NSString *)title
                       member:(NSString *)member
                         days:(NSInteger)days {
    self = [super init];
    if (self) {
        _title = [title copy];
        _member = [member copy];
        _days = days;
    }
    return self;
}
@end

@interface LoanDesk : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) LineWriter writer;
- (instancetype)initWithName:(NSString *)name;
- (void)addLoan:(Loan *)loan;
- (NSArray<Loan *> *)loansMatching:(LoanFilter)filter;
- (void)installWriter;
- (void)report;
@end

@implementation LoanDesk {
    NSMutableArray<Loan *> *_loans;
}

- (instancetype)initWithName:(NSString *)name {
    self = [super init];
    if (self) {
        _name = [name copy];
        _loans = [NSMutableArray array];
    }
    return self;
}

- (void)dealloc {
    Print([NSString stringWithFormat:@"LoanDesk 해제: %@", _name]);
}

- (void)addLoan:(Loan *)loan {
    [_loans addObject:loan];
}

- (NSArray<Loan *> *)loansMatching:(LoanFilter)filter {
    NSMutableArray<Loan *> *result = [NSMutableArray array];
    for (Loan *loan in _loans) {
        if (filter(loan)) {
            [result addObject:loan];
        }
    }
    return result;
}

- (void)installWriter {
    __weak LoanDesk *weakSelf = self;
    self.writer = ^(NSString *line) {
        LoanDesk *strongSelf = weakSelf;
        if (strongSelf == nil) {
            return;
        }
        Print([NSString stringWithFormat:@"[%@] %@", strongSelf.name, line]);
    };
}

- (void)report {
    if (self.writer == nil) {
        return;
    }
    [_loans enumerateObjectsUsingBlock:^(Loan *loan, NSUInteger idx, BOOL *stop) {
        NSString *line = [NSString stringWithFormat:@"%lu. %@ - %@ (%ld일)",
                          (unsigned long)(idx + 1), loan.title, loan.member,
                          (long)loan.days];
        self.writer(line);
    }];
}
@end

int main(void) {
    @autoreleasepool {
        int (^add)(int, int) = ^int(int a, int b) { return a + b; };
        Print([NSString stringWithFormat:@"add(3, 4) = %d", add(3, 4)]);

        __block int ticks = 0;
        void (^tick)(void) = ^{ ticks++; };
        tick();
        tick();
        tick();
        Print([NSString stringWithFormat:@"ticks = %d", ticks]);

        NSArray<Loan *> *all = @[
            [[Loan alloc] initWithTitle:@"Cocoa 입문" member:@"김하늘" days:5],
            [[Loan alloc] initWithTitle:@"알고리즘" member:@"이도윤" days:20],
            [[Loan alloc] initWithTitle:@"디자인 패턴" member:@"박지후" days:15],
            [[Loan alloc] initWithTitle:@"운영체제" member:@"김하늘" days:3]
        ];

        NSInteger limit = 14;
        LoanFilter overdue = ^BOOL(Loan *loan) { return loan.days > limit; };
        limit = 30;
        Print([NSString stringWithFormat:@"limit = %ld", (long)limit]);

        __block NSInteger totalDays = 0;
        __block NSString *firstLate = nil;
        [all enumerateObjectsUsingBlock:^(Loan *loan, NSUInteger idx, BOOL *stop) {
            totalDays += loan.days;
            if (firstLate == nil && overdue(loan)) {
                firstLate = loan.title;
            }
        }];
        Print([NSString stringWithFormat:@"총 대출 일수: %ld", (long)totalDays]);
        Print([NSString stringWithFormat:@"첫 연체: %@", firstLate]);

        LineWriter kept = nil;
        @autoreleasepool {
            LoanDesk *desk = [[LoanDesk alloc] initWithName:@"본관"];
            for (Loan *loan in all) {
                [desk addLoan:loan];
            }

            NSArray<Loan *> *late = [desk loansMatching:overdue];
            Print([NSString stringWithFormat:@"연체 건수: %lu", (unsigned long)late.count]);
            [late enumerateObjectsUsingBlock:^(Loan *loan, NSUInteger idx, BOOL *stop) {
                Print([NSString stringWithFormat:@"연체: %@ (%ld일)",
                       loan.title, (long)loan.days]);
            }];

            [desk installWriter];
            [desk report];
            kept = desk.writer;
        }
        kept(@"해제 뒤 호출");
        Print(@"해제 뒤 호출 완료");
    }
    return 0;
}

줄별 해설

  • @class Loan; 과 두 typedef: LoanFilter 가 Loan 을 매개변수로 쓰지만 Loan 의 정의는 아래에 있으므로 전방 선언으로 먼저 알린다.
  • Print: 문자열을 UTF-8 C 문자열로 바꿔 printf 로 찍는 도우미 함수다. 모든 결과 출력이 이것을 거친다.
  • Loan: 읽기 전용 프로퍼티 세 개를 가진 값 객체다. 초기화에서 문자열은 copy 로 보관한다.
  • loansMatching:: 받은 filter 블록을 일반 함수처럼 filter(loan) 으로 호출해 통과한 건만 모은다. 거르는 기준은 호출하는 쪽이 정한다.
  • installWriter: __weak 변수 weakSelf 를 블록 밖에서 만든다. 블록 안에서 strongSelf 로 옮기고 nil 이면 바로 돌아온다. 이렇게 하면 writer 블록이 데스크를 붙잡지 않는다.
  • report: _loans 를 enumerateObjectsUsingBlock: 으로 훑으며 한 줄씩 만들어 self.writer 에 넘긴다. 이 블록은 호출이 끝나면 사라지므로 self 를 직접 써도 순환이 생기지 않는다.
  • dealloc: 데스크가 실제로 해제되는 순간을 출력으로 확인하려고 넣었다.
  • main 앞부분: add 는 블록 변수 선언 문법을, ticks 는 __block 변수를 블록 안에서 증가시키는 예다. 세 번 호출하면 바깥의 ticks 도 3 이 된다.
  • limit: overdue 를 만들 때 14 가 복사된다. 이후 30 으로 바꿔도 블록은 14 로 판단하므로 연체 판정이 달라지지 않는다.
  • totalDays, firstLate: 블록 안에서 값을 대입하므로 __block 이 필요하다. overdue(loan) 처럼 블록 변수를 다른 블록 안에서 호출할 수도 있다. 첫 연체를 한 번만 기록하려고 firstLate == nil 을 조건에 넣었다.
  • 안쪽 @autoreleasepool: desk 의 수명을 이 중괄호 안으로 제한한다. 끝에서 kept = desk.writer 로 블록만 바깥에 남긴다.
  • kept(@"해제 뒤 호출"): 데스크가 이미 해제되었으므로 weakSelf 는 nil 이고 블록은 아무것도 출력하지 않는다. 블록이 데스크를 붙잡고 있었다면 데스크 해제 문구가 나오지 않았을 것이다.

실행 결과

$ clang -fobjc-arc -Wall -framework Foundation *.m -o app
$ ./app
add(3, 4) = 7
ticks = 3
limit = 30
총 대출 일수: 43
첫 연체: 알고리즘
연체 건수: 2
연체: 알고리즘 (20일)
연체: 디자인 패턴 (15일)
[본관] 1. Cocoa 입문 - 김하늘 (5일)
[본관] 2. 알고리즘 - 이도윤 (20일)
[본관] 3. 디자인 패턴 - 박지후 (15일)
[본관] 4. 운영체제 - 김하늘 (3일)
LoanDesk 해제: 본관
해제 뒤 호출 완료

실무에서 자주 틀리는 것

캡처한 일반 변수에 대입한다

블록 안에서 바깥 변수를 증가시키려는 코드는 컴파일되지 않는다. 복사본이라 바깥 값에 영향을 줄 수 없기 때문이다.

// 틀린 코드: variable is not assignable (missing __block type specifier)
int count = 0;
[all enumerateObjectsUsingBlock:^(Loan *l, NSUInteger i, BOOL *stop) {
    count++;
}];
// 고친 코드
__block int count = 0;
[all enumerateObjectsUsingBlock:^(Loan *l, NSUInteger i, BOOL *stop) {
    count++;
}];

프로퍼티에 담는 블록에서 self 를 그대로 쓴다

객체가 블록을 보관하고 블록이 그 객체를 붙잡는 구조다. 컴파일러가 순환 가능성을 경고하는 경우도 있지만, 간접적으로 이어지면 경고 없이 지나간다.

// 틀린 코드: desk 가 해제되지 않는다
self.writer = ^(NSString *line) {
    Print([NSString stringWithFormat:@"[%@] %@", self.name, line]);
};
// 고친 코드
__weak LoanDesk *weakSelf = self;
self.writer = ^(NSString *line) {
    LoanDesk *strongSelf = weakSelf;
    if (strongSelf == nil) { return; }
    Print([NSString stringWithFormat:@"[%@] %@", strongSelf.name, line]);
};

weakSelf 를 블록 안에서 여러 번 쓴다

약한 참조는 호출 사이에 nil 로 바뀔 수 있다. 첫 줄은 실행되고 둘째 줄은 아무 일도 하지 않는 반쪽 동작이 생길 수 있다.

// 틀린 코드
^{
    [weakSelf prepare];
    [weakSelf send];   // 그 사이 해제되면 prepare 만 실행된 꼴이 된다
}
// 고친 코드
^{
    LoanDesk *strongSelf = weakSelf;
    if (strongSelf == nil) { return; }
    [strongSelf prepare];
    [strongSelf send];
}

nil 블록을 호출한다

메서드가 선택적으로 받는 블록이 nil 일 수 있는데 그대로 호출하면 프로그램이 비정상 종료한다. 메시지를 nil 에 보내는 것은 안전하지만, 블록 호출은 그렇지 않다.

// 틀린 코드
- (void)finishWithHandler:(void (^)(void))handler {
    handler();   // handler 가 nil 이면 충돌
}
// 고친 코드
- (void)finishWithHandler:(void (^)(void))handler {
    if (handler != nil) {
        handler();
    }
}

한눈에 보기

이 장의 핵심 요약
주제쓰는 법기억할 점
블록 리터럴^int(int a) { ... }이름 없는 코드 조각이며 호출은 block(인자)
블록 변수int (^name)(int)함수 포인터의 * 자리가 ^
타입 이름typedef BOOL (^F)(Loan *);프로퍼티·매개변수 선언이 짧아진다
블록 프로퍼티@property (copy) F f;관례적으로 copy
일반 변수 캡처생성 시점 값 복사대입 불가, 이후 바깥 변경은 안 보인다
__block__block int n;저장 공간을 공유하므로 대입이 가능하다
self 순환__weak + 지역 강한 변수객체가 블록을 오래 보관할 때 필요하다
열거enumerateObjectsUsingBlock:*stop = YES 로 중단, return 은 중단 아님

연습 문제

  1. NSString * 하나를 받아 NSInteger 를 돌려주는 블록 타입에 TitleScorer 라는 이름을 붙이는 typedef 를 쓰고, 문자열 길이를 돌려주는 블록 변수 scorer 를 선언하라.
  2. 아래 코드의 출력을 예측하라. 이어서 int n = 1; 을 __block int n = 1; 로 바꾸면 출력이 어떻게 되는지 쓰라.
    int n = 1;
    int (^plus)(int) = ^int(int x) { return x + n; };
    n = 10;
    printf("%d\n", plus(5));
    
  3. 대출 배열 all 에서 days 가 10 이상인 건수를 enumerateObjectsUsingBlock: 으로 센 뒤 count 변수에 담는 코드를 쓰라.
  4. 다음 클래스에서 순환 참조가 생기는 이유를 설명하고 고쳐 쓰라.
    @interface Clock : NSObject
    @property (nonatomic, copy) void (^onTick)(void);
    @property (nonatomic, assign) int ticks;
    - (void)setup;
    @end
    
    @implementation Clock
    - (void)setup {
        self.onTick = ^{ self.ticks += 1; };
    }
    @end
    

정답과 해설

  1. typedef NSInteger (^TitleScorer)(NSString *title);
    TitleScorer scorer = ^NSInteger(NSString *title) {
        return (NSInteger)title.length;
    };
    

    매개변수 이름은 타입 정의에서 생략해도 된다. 블록 리터럴의 반환 타입 NSInteger 는 생략할 수 있지만, length 가 NSUInteger 이므로 캐스팅해 타입을 분명히 했다.

  2. 첫 번째 출력은 6 이다. 블록을 만들 때 n 이 1 로 복사되었으므로 5 + 1 이다. __block 을 붙이면 블록이 바깥 n 과 저장 공간을 공유하므로 호출 시점의 10 을 읽어 15 가 나온다.

  3. __block NSInteger count = 0;
    [all enumerateObjectsUsingBlock:^(Loan *loan, NSUInteger idx, BOOL *stop) {
        if (loan.days >= 10) {
            count++;
        }
    }];
    

    블록 안에서 count 에 대입하므로 __block 이 필요하다. 이 블록은 호출이 끝나면 사라지므로 약한 참조는 필요하지 않다.

  4. Clock 이 onTick 블록을 copy 프로퍼티로 강하게 보관하고, 블록은 본문의 self 로 Clock 을 강하게 붙잡는다. 서로 붙들어 참조 횟수가 0 이 되지 않는다. 고친 코드는 다음과 같다.

    - (void)setup {
        __weak Clock *weakSelf = self;
        self.onTick = ^{
            Clock *strongSelf = weakSelf;
            if (strongSelf == nil) { return; }
            strongSelf.ticks += 1;
        };
    }
    
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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