iPhoneアプリ作成までの道のり10

10. 循環参照

前回はプロトコルデリゲートパターンを説明した。実は、プロトコルデリゲートパターンを使用するときに注意しなければならないことがある。それが循環参照である。これはプロトコルデリゲートパターンが抱えている欠陥というわけではない。Objective-C全体で共通の注意点である。

Objective-CにはGCも実装されているが、伝統的にはReferencing Counting方式のメモリ管理を行う。Referencing CountingはGCよりも遥かに高速だが、GCほど安全ではない。4章で説明した方針に従っている限りは、ほとんど問題になることはないのだが、この章のタイトルであるところの循環参照が起きた場合は、正しくカウンティングを行う事ができない。

この循環参照が特に起こりやすいのがプロトコルデリゲートパターンなのである。現実として、前章で示したコードスケッチにおいても循環参照が発生している。

循環参照とは、クラス間が相互に関連を持つことである。

前の章で言えば、MyGameクラスのインスタンスが、MyBattleSystemクラスのインスタンスを保持しており、同じMyBattleSystemクラスのインスタンスが、同じMyGameクラスのインスタンスを保持している。しかし、前の章のコードの以下の部分を良く見て欲しい。

@property(assign, nonatomic) id<MyBattleSystemDelegate> delegate;

この定義は明らかに4章で示したルールに違反している。オブジェクトを保持する場合は、通常retainを指定しなければならないところだが、実際にはこのassignは問題にならない。何故ならば、MyBattleSystemインスタンスの生存期間よりも、MyGameインスタンスの生存期間の方が長いことが確実だからである。delegateへの参照がたとえassignだとしても、参照先が失われることは起こりえないのだ。

結論を言えば、このように循環参照が起きてしまう場合には、「生存期間の長い方への参照をassignにする」という処方箋が有効である。

以下に、循環参照の問題をコード例で示す。

// 循環参照が起きてしまう単純なサンプル

#import <Foundation/Foundation.h>

@class Delegate;
@class View;

@interface Delegate : NSObject
{
    View* _view;
}
@property(retain, nonatomic) View* view;
@end

@implementation Delegate
@synthesize view = _view;

-(void)dealloc {
    self.view = nil;
    NSLog(@"Delegateのメモリが解放されます");
    [super dealloc];
}
@end

@interface View : NSObject
{
    Delegate* _delegate;
}
@property(retain, nonatomic) Delegate* delegate;
@end

@implementation View
@synthesize delegate = _delegate;
-(void)dealloc {
    self.delegate = nil;
    NSLog(@"Viewのメモリが解放されます");
    [super dealloc];
}
@end

// テスト用メイン関数
int main(int argc, const char * argv[])
{
    @autoreleasepool {
        Delegate* delegate = [[[Delegate alloc] init] autorelease];
        View* view = [[[View alloc] init] autorelease];
        delegate.view = view;
        view.delegate = delegate;
    }
    
    // autoreleaseしているのに解放されない!
    // これを循環参照という。基本的に、循環参照を起こさないようにクラスを設計すべき。
    // どうしても循環参照が生じてしまう場合は、生存期間が長い方に対する参照をassignにする。
    // assignにした場合は、dealloc内でのリリースも不要。
    // 逆に生存期間が短い方への参照をassignにしてもメモリ解放自体はうまくいくが、
    // 既に死んでいるオブジェクトへのアクセスが発生してしまう可能性を伴うことになる。
    // つまり、同時期に死ぬオブジェクトの循環参照ならば、どちらをassignにしても問題ない。
    //
    // Viewプロパティのdelegateをretainからassignに変えれば、以下のコードは正しく動く。
    // 一般的にプロトコルデリゲートパターンでは、循環参照が発生しやすく、
    // Viewのイベントに応答するのがDelegateであるからして、
    // 必然的にViewよりもDelegateの方が生存期間が長くなる。
    Delegate* delegate = [[Delegate alloc] init];

    @autoreleasepool {
        View* view = [[[View alloc] init] autorelease];
        delegate.view = view;
        view.delegate = delegate;
    }
    
    NSLog(@"%lu", delegate.view.retainCount);
    
    [delegate release];
    
    return 0;
}


尚、プロトコルデリゲートパターンにおいて、常に循環参照が発生するとは限らない。例えば、前章の例において、MyBattleSystemに設定するデリゲートと、MyBattleSystemに操作通知(move, attack)を指示するのが別のクラスである場合は、循環参照にはならない。循環参照にならない場合は、通常通り、retainで保持するべきである。