[iOS] 弱参照を扱えるコレクション NSHashTable と NSMapTable | アドカレ2013 : SP #8

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

この記事の内容は、ARC を有効にしていることを前提としています。

NSHashTable と NSMapTable

コレクションと参照カウント

オブジェクトに対してアクセスしやすくしたい等の理由で、NSMutableDictionary 等のコレクションに他のオブジェクトがオーナーシップを持っているオブジェクトの参照を格納しておきたいケースがたまにあります。こういった場合、オーナーシップを持っているオブジェクトが対象のオブジェクトを解放したと同時にコレクションからも参照を削除するべきなのですが、削除処理の実装を忘れてしまったり処理が煩雑になったりしてしまいがちです。

NSArray, NSSet, NSDictionary 等のコレクションは、オブジェクトが追加されるタイミングでそのオブジェクトの参照カウントをインクリメント(retain)しています。逆に、オブジェクトが削除される、もしくはコレクション自体が破棄されるタイミングで参照カウントをデクリメント(release)しています。つまり、コレクションにオブジェクトが格納されている限り、そのオブジェクトが破棄されることは基本的にはありません。

この仕組みは通常は非常に便利です。しかし、冒頭の話のケースのようにただ参照を保持しておきたいだけである場合には、オーナーシップを持つオブジェクト側とコレクションの双方で参照を解放する必要があるため、ライフサイクルの管理が若干面倒になります。また、解放処理をきちんと実装しないとメモリリークを発生させてしまう恐れがあります。

強参照と弱参照

そこで役に立つのが弱参照です。弱参照はオブジェクトに対してオーナーシップを持たずに(参照カウントを増減させずに)参照を保持することを言います。変数宣言の際に __weak 修飾子を指定した場合がこれにあたります。

弱参照とは反対に、オブジェクトに対してオーナーシップを持って(参照カウントを増減させて)参照を保持することを強参照と言います。__strong もしくは __autoreleasing 修飾子を指定した場合がこれにあたります。

なお、修飾子をつけない場合、インスタンス変数宣言を行う際には __strong 修飾子を、ローカル変数宣言を行う際には__autoreleasing 修飾子を暗黙的に指定している扱いとなります。

弱参照コンテナとしての NSValue

さて、コレクションに対して弱参照でオブジェクトを追加したい場合の選択肢として、NSValue を利用する方法があります。NSValue の valueWithNonretainedObject: メソッドを利用することで、弱参照でコレクションにオブジェクトを追加することが可能です。しかし、この方法は NSValue という余計なコンテナにオブジェクトを入れた上で扱わなければいけないため、あまり使い勝手のいいものではありませんでした。

NSHashTable と NSMapTable

iOS 6 以降が動作しているデバイスでは、NSHashTable, NSMapTable という2つのコレクションを利用することができます。これらのコレクションは、基本的には NSMutableSet, NSMutableDictionary と同様の機能を提供するものですが、加えて下記の機能を提供します。

  • 弱参照によるオブジェクトの追加をすることができる
  • オブジェクトの追加時に自動的にインスタンスをコピーし、コピーしたインスタンスを追加することができる
  • 格納されたオブジェクトの同一性を評価する際のルールを選択することができる

NSHashTable, NSMapTable では、上記の通り弱参照によるオブジェクトの追加をすることができます。これらの存在は以前から知っていたのですが、iOS 5 以前のデバイスでは利用できないことがネックになってあまり利用していませんでした。しかし、最近では iOS 5 に対応する必要のあるアプリは少数派になってきたので、そろそろ本格的に利用しようと思い、概要をまとめてみました。

NSHashTable

NSHashTable は集合を扱うためのコレクションで、NSMutableSet に近い機能を提供します。ただし、NSHashTable は NSObject を直接継承しており、NSSet, NSMutableSet との型の関連性はありません。

コンビニエンスコンストラクタ

コンビニエンスコンストラクタとして、下記の静的メソッドが用意されています。

weakObjectsHashTable

追加されたオブジェクトを弱参照で保持する NSHashTable のインスタンスを返します。

hashTableWithOptions:

生成されるインスタンスに対して、格納されるオブジェクトに対するオプションを指定することができます。パラメータは、NSPointerFunctionsOptions 列挙型です。ただし、実際にパラメータとして渡すのは、後述の NSHashTableOptions 列挙型になります。

NSHashTableOptions 列挙型

NSPointerFunctionsOptions 列挙型の中から NSHashTable で利用するオプションとして有効な値のみを抜き出して、新たに列挙型として定義したものが NSHashTableOptions 列挙型です。

enum {
   NSHashTableStrongMemory             = 0,
   NSHashTableCopyIn                   = NSPointerFunctionsCopyIn,
   NSHashTableObjectPointerPersonality = NSPointerFunctionsObjectPointerPersonality,
   NSHashTableWeakMemory               = NSPointerFunctionsWeakMemory
};
typedef NSUInteger NSHashTableOptions;

NSHashTableStrongMemory

オブジェクトを強参照で格納するオプションです。

NSHashTableCopyIn

オブジェクトの格納時にインスタンスをコピーし、コピーしたインスタンスを格納するオプションです。

NSHashTableObjectPointerPersonality

格納されたオブジェクトに対して、同一インスタンスである場合にのみ同一オブジェクトであるとみなすことを指定するオプションです。このオプションは、主に containsObject: の結果に影響します。

NSHashTableWeakMemory

オブジェクトを弱参照で格納するオプションです。

強参照と弱参照

弱参照

下記コードでは weakObjectsHashTable コンビニエンスコンストラクタを利用して、追加されたオブジェクトを弱参照で保持する NSHashTable のインスタンスを生成しています。

NSMutableSet *set = [NSMutableSet set];
NSHashTable *weakSet = [NSHashTable weakObjectsHashTable];

@autoreleasepool {
    NSObject *obj1 = [NSObject new]; // 参照カウント:1
    [set addObject:obj1]; // 参照カウント:2
    NSLog(@"%@", set.anyObject); // <NSObject: 0x8b49c70>

    NSObject *obj2 = [NSObject new]; // 参照カウント:1
    [weakSet addObject:obj2]; // 参照カウントが増えない
    NSLog(@"%@", weakSet.anyObject); // <NSObject: 0x8bcb170>
}

// obj1, obj2 ともに autoreleasepool ブロックから抜けたので、
// autoreleasepool による解放が実行されて参照カウントが1減少する

// 追加された obj1 の参照カウントは1なので、インスタンスが解放されていない
NSLog(@"%@", set.anyObject); // <NSObject: 0x8b49c70>
// 追加された obj2 の参照カウントは0なので、インスタンスが解放されている
NSLog(@"%@", weakSet.anyObject); // (null)

上記コードでは、@autoreleasepool ブロックを抜けると obj1obj2 の参照カウントが1減少します。obj1 は NSMutableSet に追加した際に参照カウントが1増加しているので @autoreleasepool ブロックを抜けても解放されません。これに対し、obj2 はNSHashTable に追加した際に参照カウントが変わらないので、@autoreleasepool ブロックを抜けると解放されます。

weakObjectsHashTable コンビニエンスコンストラクタは、hashTableWithOptions: コンビニエンスコンストラクタに弱参照のオプションを渡した場合と同等の動作をします。

NSHashTable *weakSet = [NSHashTable hashTableWithOptions:NSHashTableWeakMemory];

強参照

hashTableWithOptions: コンビニエンスコンストラクタに強参照を指定するオプション NSHashTableStrongMemory を渡すと、追加されたオブジェクトの参照カウントに対しては NSMutableSet と同様の動作をします。

NSMutableSet *set = [NSMutableSet set];
NSHashTable *strongSet = [NSHashTable hashTableWithOptions:NSHashTableStrongMemory];

@autoreleasepool {
    NSObject *obj1 = [NSObject new]; // 参照カウント:1
    [set addObject:obj1]; // 参照カウント:2
    NSLog(@"%@", set.anyObject); // <NSObject: 0x8dd2650>

    NSObject *obj2 = [NSObject new]; // 参照カウント:1
    [strongSet addObject:obj2]; // 参照カウント:2
    NSLog(@"%@", strongSet.anyObject); // <NSObject: 0x8d56420>
}

// obj1, obj2 ともに autoreleasepool ブロックから抜けたので、
// autoreleasepool による解放が実行されて参照カウントが1減少する

// 追加された obj1 の参照カウントは1なので、インスタンスが解放されていない
NSLog(@"%@", set.anyObject); // <NSObject: 0x8dd2650>
// 追加された obj2 の参照カウントは1なので、インスタンスが解放されていない
NSLog(@"%@", strongSet.anyObject); // <NSObject: 0x8d56420>

このオプション単体で利用する場合には NSMutableSet で代用できますので、後述の同一性に関するオプションとセットで利用することになると思います。

オブジェクトの同一性

NSHashTable は格納しているオブジェクトに対して、オブジェクトの同一性を評価する際のルールを設定するオプションを用意しています。

isEqual: メソッドの実装に従って同一性を判断する

オブジェクトの同一性に関するオプションを指定しない場合、NSObject プロトコルで定義されている isEqual: メソッドをオーバーライドすることによって定義されるオブジェクトの同一性に基づいて、オブジェクトが同一かどうかを判断します。したがって、isEqual:YES を返すオブジェクトが格納されている場合に、containsObject:YES が返されます。

下記コードでは、オブジェクトの同一性に関するオプションを指定しない場合の挙動をテストしています。なお、Entity クラスは、メンバ変数 identifier が同一文字列である場合に同一オブジェクトがであるとみなすよう、isEqual: をオーバーライドしているものとします。

NSHashTable *weakSet = [NSHashTable weakObjectsHashTable];

Entity *foo1 = [Entity entityWithIdentifier:@"foo"];
Entity *foo2 = [Entity entityWithIdentifier:@"foo"];
Entity *bar = [Entity entityWithIdentifier:@"bar"];

[weakSet addObject:foo1];

NSLog([weakSet containsObject:foo1] ? @"YES" : @"NO"); // YES
NSLog([weakSet containsObject:foo2] ? @"YES" : @"NO");  // YES
NSLog([weakSet containsObject:bar] ? @"YES" : @"NO"); // NO

foo1foo2 に関しては identifier に同一文字列が指定されているため、isEqual: メソッドで評価すると YES が返されます。このため、上記の NSHashTable は foo2 と等しいオブジェクトが既に追加されていると判断し、foo2 を引数とした containsObject: メソッドの呼び出しに対して YES を返します。

なお、オブジェクトが等しいかどうかの判断には、パフォーマンス上の理由から hash メソッドも利用されています。このため、containsObject: メソッドでの評価に対して正しい判断を得るためには isEqual:, hash の2つのメソッドが適切にオーバーライドされているオブジェクトである必要があります。

ポインタの指すアドレスによって同一性を判断する

一方、hashTableWithOptions: 呼び出し時に NSHashTableObjectPointerPersonality オプションを指定すると、同一インスタンス(ポインタの指すアドレスが同一)である場合にオブジェクトが等しいと判断します。したがって、同一インスタンスが既に追加されている場合にのみ、containsObject: で YES が返されます。

NSHashTable *weakSet = [NSHashTable hashTableWithOptions:NSHashTableWeakMemory | NSHashTableObjectPointerPersonality];

Entity *foo1 = [Entity entityWithIdentifier:@"foo"];
Entity *foo2 = [Entity entityWithIdentifier:@"foo"];

[weakSet addObject:foo1];

NSLog([weakSet containsObject:foo1] ? @"YES" : @"NO"); // YES
NSLog([weakSet containsObject:foo2] ? @"YES" : @"NO"); // NO

オブジェクトのコピー

また、NSHashTable にはオブジェクトの追加時にインスタンスをコピーするオプションも用意されています。

hashTableWithOptions: 呼び出し時に NSHashTableCopyIn オプションを指定すると、オブジェクトを追加する際にコピーされたインスタンスが追加されます。オブジェクトの追加にあたって、防御的コピーを行う必要がある場合に便利です。

NSHashTable *set = [NSHashTable hashTableWithOptions:NSHashTableCopyIn];

Entity *foo = [Entity entityWithIdentifier:@"foo"];

[set addObject:foo]; // foo がコピーされ、コピーされたインスタンスが追加される

// foo が指しているオブジェクトと set が保持しているオブジェクトは同一インスタンスではない
NSLog(@"%@", foo); // <Entity: 0x8c4e620> identifier: foo
NSLog(@"%@", set.anyObject); // <Entity: 0x8c865e0> identifier: foo

なお、格納するオブジェクトはコピーが作成されるため、NSCopying プロトコルを適切に実装しているオブジェクトである必要があります。

NSMapTable

NSMapTable は連想配列を扱うためのコレクションで、NSMutableDictionary に近い機能を提供します。ただし、NSMapTable は NSObject を直接継承しており、NSDictionary, NSMutableDictionary との型の関連性はありません。

コンビニエンスコンストラクタ

コンビニエンスコンストラクタとして、下記の静的メソッドが用意されています。

mapTableWithKeyOptions:valueOptions:

生成されるインスタンスに対して、key や value として格納されるオブジェクトに対するオプションを指定することができます。1番目のパラメータは key として格納されるオブジェクトに対するオプション、2番目のパラメータは value として格納されるオブジェクトに対するオプションを指定します。どちらのパラメータも NSPointerFunctionsOptions 列挙型です。ただし、実際にパラメータとして渡すのは、後述の NSMapTableOptions 列挙型になります。

strongToStrongObjectsMapTable

key, value ともに強参照する NSMapTable のインスタンスを返します。

weakToStrongObjectsMapTable

key を弱参照、value を強参照する NSMapTable のインスタンスを返します。

strongToWeakObjectsMapTable

key を強参照、value を弱参照する NSMapTable のインスタンスを返します。

weakToWeakObjectsMapTable

key, value ともに弱参照する NSMapTable のインスタンスを返します。

NSMapTableOptions 列挙型

NSPointerFunctionsOptions 列挙型の中から NSMapTable で利用するオプションとして有効な値のみを抜き出して、新たに列挙型として定義したものが NSMapTableOptions 列挙型です。

enum {
   NSMapTableStrongMemory             = 0,
   NSMapTableCopyIn                   = NSPointerFunctionsCopyIn,
   NSMapTableObjectPointerPersonality = NSPointerFunctionsObjectPointerPersonality,
   NSMapTableWeakMemory               = NSPointerFunctionsWeakMemory
};
typedef NSUInteger NSMapTableOptions;

NSMapTableStrongMemory

key もしくは value として格納されたオブジェクトに対して強参照で保持するオプションです。

NSMapTableCopyIn

key もしくは value として格納されたオブジェクトに対してインスタンスをコピーし、コピーしたインスタンスを保持するオプションです。

NSMapTableObjectPointerPersonality

key もしくは value として格納されたオブジェクトに対して、同一インスタンスである場合に同一オブジェクトであるとみなすことを指定するオプションです。主に、key の重複判定の結果に影響します。

NSMapTableWeakMemory

key もしくは value として格納されたオブジェクトに対して弱参照で保持するオプションです。

強参照と弱参照

弱参照

下記コードでは strongToWeakObjectsMapTable コンビニエンスコンストラクタを利用して、value を弱参照で格納する NSMapTable のインスタンスを生成しています。

NSMutableDictionary *map = [NSMutableDictionary new];
NSMapTable *weakMap = [NSMapTable strongToWeakObjectsMapTable];

@autoreleasepool {
    NSObject *obj1 = [NSObject new]; // 参照カウント:1
    [map setObject:obj1 forKey:@"key"]; // 参照カウント:2
    NSLog(@"%@", map.allValues.firstObject); // <NSObject: 0x8c5e330>

    NSObject *obj2 = [NSObject new]; // 参照カウント:1
    [weakMap setObject:obj2 forKey:@"key"]; // 参照カウントが増えない
    NSLog(@"%@", [weakMap objectForKey:@"key"]); // <NSObject: 0x8c4e4c0>
}

// obj1, obj2 ともに autoreleasepool ブロックから抜けたので、
// autoreleasepool による解放が実行されて参照カウントが1減少する

// 追加された obj1 の参照カウントは1なので、インスタンスが解放されていない
NSLog(@"%@", map.allValues.firstObject); // <NSObject: 0x8c5e330>
// 追加された obj2 の参照カウントは0なので、インスタンスが解放されている
NSLog(@"%@", [weakMap objectForKey:@"key"]); // (null)

NSHashTable で弱参照を利用した場合と同様、参照カウントに影響を与えずに value を格納しています。

強参照

一方、strongToStrongObjectsMapTable コンビニエンスコンストラクタを利用すると、NSMutableDictionary と同様に強参照で value を格納することができます。

NSMutableDictionary *map = [NSMutableDictionary new];
NSMapTable *strongMap = [NSMapTable strongToStrongObjectsMapTable];

@autoreleasepool {
    NSObject *obj1 = [NSObject new]; // 参照カウント:1
    [map setObject:obj1 forKey:@"key"]; // 参照カウント:2
    NSLog(@"%@", map.allValues.firstObject); // <NSObject: 0x8b1b960>

    NSObject *obj2 = [NSObject new]; // 参照カウント:1
    [strongMap setObject:obj2 forKey:@"key"]; // 参照カウント:2
    NSLog(@"%@", [strongMap objectForKey:@"key"]); // <NSObject: 0x8b1ca70>
}

// obj1, obj2 ともに autoreleasepool ブロックから抜けたので、
// autoreleasepool による解放が実行されて参照カウントが1減少する

NSLog(@"%@", map.allValues.firstObject); // <NSObject: 0x8b1b960>
NSLog(@"%@", [strongMap objectForKey:@"key"]); // <NSObject: 0x8b1ca70>

なお、weakToStrongObjectsMapTable もしくは weakToWeakObjectsMapTable でインスタンスを生成すれば、key が弱参照で格納される状態にすることができます。

オブジェクトの同一性

NSMapTable も NSHashTable と同様に、格納しているオブジェクトに対して、オブジェクトの同一性を評価する際のルールを設定するオプションを用意しています。

NSMapTable の場合、オブジェクトの同一性が問題になるのは key のみです。このため、これらのオプションを利用するのは key のみで、value に適用することはないと思います。

isEqual: メソッドの実装に従って同一性を判断する

オブジェクトの同一性に関するオプションを指定しない場合には、NSHashTable の場合と同様、オブジェクトの同一性は isEqual: メソッドの実装によって定義されます。

NSMapTable *weakMap = [NSMapTable strongToWeakObjectsMapTable];

Entity *foo1 = [Entity entityWithIdentifier:@"foo"];
Entity *foo2 = [Entity entityWithIdentifier:@"foo"];
Entity *bar = [Entity entityWithIdentifier:@"bar"];

[weakMap setObject:@"foo1" forKey:foo1];
// key にセットした foo2 は foo1 と同一の key とみなされるので、value が上書きされる
[weakMap setObject:@"foo2" forKey:foo2];
[weakMap setObject:@"bar" forKey:bar];

NSLog(@"%@", [weakMap objectForKey:foo1]); // foo2
NSLog(@"%@", [weakMap objectForKey:foo2]); // foo2
NSLog(@"%@", [weakMap objectForKey:bar]); // bar

ポインタの指すアドレスによって同一性を判断する

NSHashTable の場合と同様に、mapTableWithKeyOptions:valueOptions: 呼び出し時に NSMapTableObjectPointerPersonality オプションを指定すると、同一インスタンスである場合にオブジェクトが同一であると判断します。

NSMapTable *weakMap = [NSMapTable mapTableWithKeyOptions:NSMapTableObjectPointerPersonality
                                            valueOptions:NSMapTableWeakMemory];

Entity *foo1 = [Entity entityWithIdentifier:@"foo"];
Entity *foo2 = [Entity entityWithIdentifier:@"foo"];
Entity *bar = [Entity entityWithIdentifier:@"bar"];

[weakMap setObject:@"foo1" forKey:foo1];
// key にセットした foo2 は foo1 と同一インスタンスではないので同一の key とはみなされない
[weakMap setObject:@"foo2" forKey:foo2];
[weakMap setObject:@"bar" forKey:bar];

NSLog(@"%@", [weakMap objectForKey:foo1]); // foo1
NSLog(@"%@", [weakMap objectForKey:foo2]); // foo2
NSLog(@"%@", [weakMap objectForKey:bar]); // bar

オブジェクトのコピー

NSMapTable にもオブジェクトの追加時にインスタンスをコピーする NSMapTableCopyIn オプションが用意されています。

NSMapTable *map = [NSMapTable mapTableWithKeyOptions:NSMapTableStrongMemory
                                        valueOptions:NSMapTableCopyIn];

Entity *foo = [Entity entityWithIdentifier:@"foo"];

[map setObject:foo forKey:@"key"];

NSLog(@"%@", foo); // <Entity: 0x8b25480> identifier: foo
NSLog(@"%@", [map objectForKey:@"key"]); // <Entity: 0x8b1c5f0> identifier: foo

なお、NSMutableDictonary は key を格納する際に必ずインスタンスのコピーを格納しますが、NSMapTable はデフォルトでは key を格納する際にインスタンスのコピーをしません。このため、NSMutableDictionary のように key を格納する際にコピーする必要がある場合には、NSMapTableCopyIn オプションを key に指定することになります。

まとめ

NSHashTable, NSMapTable が提供する弱参照のオプションはとても使いやすいです。

ただし、弱参照などの特有の機能が必要でない場合には、下記の理由から NSMutableSet, NSMutableDictionary を利用することをお勧めします。

  • NSMapTable は添字アクセスのシンタックスシュガーが利用できない
  • NSSet, NSDictionary からの NSHashTable, NSMapTable への変換ができない
  • NSMutableSet, NSMutableDictionary に比べて実装されているメソッドがかなり少ない

Foundation Framework には便利なクラスがたくさんありますので、積極的に活用していきたいですね。