Swift & Objective-C

Objective-C Associated Objects、カテゴリにプロパティを追加

カテゴリにはivarを追加できませんが、Associated Objectsで値を関連付けられます。オブジェクト横のサイドテーブルに保存される仕組み、カテゴリプロパティの実装、assignで注意すべきメモリポリシーをまとめます。

読了 5 分
Objective-C Associated Objects、カテゴリにプロパティを追加のカバー画像

「カテゴリには保存プロパティを追加できない」と説明しました。クラスのメモリレイアウトはコンパイル時に確定するため、後から読み込まれるカテゴリにivarを挿入する余地がないからです。

しかし実務では、カテゴリにプロパティが存在するコードを見かけます。その秘密がAssociated Objectsです。ivarなしでオブジェクトに値を関連付けるランタイム機能です。

カテゴリの保存プロパティ問題に対する公式の回避策であり、Objective-Cランタイムがオブジェクトの外部に隠し持つ補助ストレージです。


仕組み:オブジェクト横に付くサイドテーブル

Associated Objectsの中心となる関数は2つです。

objc_setAssociatedObject(対象オブジェクト、キー、値、メモリポリシー);
objc_getAssociatedObject(対象オブジェクト、キー);

値はオブジェクトのivar領域には保存されません。ランタイムがグローバルに管理する**関連テーブル(サイドテーブル)**に、「オブジェクトのアドレス → {キー: 値}」として記録されます。

オブジェクト本体のメモリレイアウトは1バイトも変わらないため、コンパイル時に確定したレイアウト制約と衝突しません。

対象オブジェクトが解放されると、ランタイムは関連テーブルから該当項目を探し、ポリシーに従って一緒に処理します。寿命を自動管理できるため、実務でも利用できます。

objc_setAssociatedObjectがランタイムのグローバル関連テーブルに値を記録する仕組みの図
本体のレイアウトはそのまま、値は横のテーブルに保存

カテゴリプロパティの完全実装

カテゴリでプロパティを宣言すると、コンパイラはgetter/setterの宣言だけを生成し、保存領域は作りません。その空白をAssociated Objectsで埋めます。

#import <objc/runtime.h>

@interface UIView (BadgeCount)
@property (nonatomic, strong) NSNumber *badgeCount;
@end

@implementation UIView (BadgeCount)

- (NSNumber *)badgeCount {
    return objc_getAssociatedObject(self, @selector(badgeCount));
}

- (void)setBadgeCount:(NSNumber *)badgeCount {
    objc_setAssociatedObject(self, @selector(badgeCount),
                             badgeCount,
                             OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

@end

キーに@selector(badgeCount)を使うのが慣例です。キーは値ではなくポインタアドレスで比較されるため、アプリ全体で一意のアドレスなら何でも使えます。

セレクタはランタイムで一意性が保証され、static変数を別途宣言する必要もないため最もすっきりしています。static void *kBadgeKey = &kBadgeKey;のような古典的な方法も今なお有効です。

文字列リテラルをキーにするのは危険です。同じ内容でも、コンパイル単位が異なるとアドレスが変わる可能性があります。

「確実に保存したのにnilになる」謎はここで起きます。


メモリポリシー:assignだけ注意

4番目の引数がプロパティの属性に対応します。

ポリシー 対応する属性
OBJC_ASSOCIATION_RETAIN_NONATOMIC strong, nonatomic
OBJC_ASSOCIATION_COPY_NONATOMIC copy, nonatomic
OBJC_ASSOCIATION_RETAIN / COPY 上記と同じだがatomic
OBJC_ASSOCIATION_ASSIGN assign — weakではない

注意点はASSIGNだけです。名前はweakに見えますが、自動的にnilにはならないunsafe_unretainedです。

関連オブジェクトが先に解放されると、ダングリングポインタが残ります。アクセスした瞬間にクラッシュします。

weakの意味が必要なら、値をweakプロパティでラップしたオブジェクトをRETAINで関連付ける回避策が必要です。

特定の関連値を削除するにはnilをsetします。objc_removeAssociatedObjectsはそのオブジェクトの関連値をすべて消す強力な手段なので、基本的に使う場面はありません。


実践的な用途と限界

実務でよく見る組み合わせは次のとおりです。

  • UIKitクラスの拡張:UIViewにバッジ数、UIButtonにクロージャベースのアクションハンドラ、UIViewControllerに分析用の画面名を追加
  • デリゲートからブロックへの変換ラッパー:デリゲートオブジェクトを元のオブジェクトに関連付け、寿命を連動させるパターン(Alamofire以前のネットワークカテゴリでよく使われました)
  • スウィズリングとの組み合わせ:スウィズリングで差し込んだロジックに状態の保存場所が必要なときに使います。ランタイム系の2つの手法が実務で併用される理由です。

ただし限界は守るべきです。Associated Objectsはあくまで補助データのための経路です。

オブジェクトの主要な状態が関連テーブルに分散すると、コード全体を把握しにくくなります。サブクラスを作れるならivarが正解です。

所有していないクラスに付加情報を持たせる必要がある場合にだけ、この手段を使うべきです。

Swift extensionのstored property制約を回避する場合にも同じ関数を使えます(NSObject系に限る)。ただしSwiftでは、プロトコルと専用の保存型の組み合わせやcompositionで解決する方が自然です。

旅行バッグにカラビナでポーチを取り付けるAssociated Objectsの比喩イラスト
バッグの外側にポーチを掛ける方式です

まとめ

  • Associated Objectsはオブジェクト本体ではなく、ランタイムのサイドテーブルに値を保存します。そのためカテゴリでも機能します。
  • キーはポインタアドレスで比較されます。@selectorの再利用が慣例で、文字列リテラルは危険です。
  • メモリポリシーのASSIGNはweakではなくunsafe_unretainedです。ダングリングによるクラッシュに注意してください。
  • 対象オブジェクトが解放されると、関連値も自動的に処理されます。
  • 用途は、所有していないクラスに補助データを付ける範囲に限ります。主要な状態を置くと、設計上の危険信号です。

ランタイムシリーズの次回はNSProxyです。メッセージフォワーディングの記事で予告した、NSObjectではないもう1つのルートクラスが「純粋な代理人」としてどう動くのかを掘り下げます。

あわせて読みたい