블록 - 값으로 다루는 코드 조각
이 장에서 배우는 것
지금까지 코드는 "이름이 붙은 함수나 메서드"로만 존재했다. 블록(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 변수 | 저장 공간 공유 | 가능 | 보인다 |
| 객체 포인터(일반) | 포인터 값 복사 | 포인터 대입은 불가 | 가리키는 객체의 변경은 보인다 |
마지막 줄을 주의해야 한다. NSMutableArray *list 를 캡처한 블록은 [list addObject:x] 를 __block 없이 할 수 있다. 포인터 값은 복사되었지만 같은 객체를 가리키기 때문이다. 반대로 list = nil 이나 list = [NSMutableArray array] 처럼 변수 자체에 대입하려면 __block 이 필요하다.
블록 속 self 와 순환 참조
블록 안에서 self 를 쓰면 블록이 self 객체를 강하게 붙잡는다. _name 처럼 인스턴스 변수를 직접 읽는 것도 self 를 통한 접근이어서 마찬가지다. 객체가 블록을 프로퍼티로 들고 있고 그 블록이 다시 객체를 붙잡으면, 둘이 서로를 붙들어 참조 횟수가 0 이 되지 않는다. 앞서 ARC 를 다룬 장에서 본 순환 참조(retain cycle)가 블록을 통해 생기는 것이다.
해법은 블록에 넘기는 쪽을 약한 참조로 바꾸는 것이다. 블록 밖에서 __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 은 중단 아님 |
연습 문제
NSString *하나를 받아NSInteger를 돌려주는 블록 타입에TitleScorer라는 이름을 붙이는typedef를 쓰고, 문자열 길이를 돌려주는 블록 변수scorer를 선언하라.- 아래 코드의 출력을 예측하라. 이어서
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)); - 대출 배열
all에서days가 10 이상인 건수를enumerateObjectsUsingBlock:으로 센 뒤count변수에 담는 코드를 쓰라. - 다음 클래스에서 순환 참조가 생기는 이유를 설명하고 고쳐 쓰라.
@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
정답과 해설
-
typedef NSInteger (^TitleScorer)(NSString *title); TitleScorer scorer = ^NSInteger(NSString *title) { return (NSInteger)title.length; };매개변수 이름은 타입 정의에서 생략해도 된다. 블록 리터럴의 반환 타입
NSInteger는 생략할 수 있지만,length가NSUInteger이므로 캐스팅해 타입을 분명히 했다. 첫 번째 출력은
6이다. 블록을 만들 때n이 1 로 복사되었으므로5 + 1이다.__block을 붙이면 블록이 바깥n과 저장 공간을 공유하므로 호출 시점의 10 을 읽어15가 나온다.-
__block NSInteger count = 0; [all enumerateObjectsUsingBlock:^(Loan *loan, NSUInteger idx, BOOL *stop) { if (loan.days >= 10) { count++; } }];블록 안에서
count에 대입하므로__block이 필요하다. 이 블록은 호출이 끝나면 사라지므로 약한 참조는 필요하지 않다. Clock이onTick블록을copy프로퍼티로 강하게 보관하고, 블록은 본문의self로Clock을 강하게 붙잡는다. 서로 붙들어 참조 횟수가 0 이 되지 않는다. 고친 코드는 다음과 같다.- (void)setup { __weak Clock *weakSelf = self; self.onTick = ^{ Clock *strongSelf = weakSelf; if (strongSelf == nil) { return; } strongSelf.ticks += 1; }; }