連想配列の扱い方1

Javaプログラマ向け、Objective-Cにおける連想配列の扱い。

Objective-Cjava.util.Mapのような連想配列を扱いたければ、多くの場合、NSDictionaryを使うのが手っ取り早いだろう。

例えば、単純な文字列と整数値のマッピングを行ってみる。

Java

public class Test {
    public static void main(String[] args) {
        java.util.Map<String, Integer> map = new java.util.HashMap<>();
        map.put("key012", 3);
        map.put("key123", 4);
        map.put("key345", 5);

        for (java.util.Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.format("key:%s, value:%d\n", entry.getKey(), entry.getValue());
        }
    }
}

これと似たようなコードをObjective-Cでは以下のように記述することができる。

int main(int argc, const char** argv)
{
    @autoreleasepool {
        NSMutableDictionary* map = [NSMutableDictionary dictionary];
        [map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];
        [map setObject:[NSNumber numberWithInt:4] forKey:@"key123"];
        [map setObject:[NSNumber numberWithInt:5] forKey:@"key345"];
        
        for (NSString* key in [map keyEnumerator]) {
            NSLog(@"key:%@, value:%d", key, [[map objectForKey:key] intValue]);
        }
    }
}

上記のJavaObjective-Cのコードは、場合によっては、「同じ機能」と言えるかもしれない。だが、残念ながら、上記2つのサンプルコードには多くの違いがある。

以下に主な相違点を示す。


格納するオブジェクトの型が違う

JDK5以降では、当然のようにコレクションに格納する型をジェネリクスで指定するので、

java.util.Map<String, Integer> map = new java.util.HashMap<>();

と記述したなら、Mapに格納するキーはString型でなければならないし、値はInteger型でなければならない。

一方、Objective-Cでは、NSDictionaryに格納するオブジェクトの型は、キー、値ともに、id型である。つまり、昔のJavaのコレクションフレームワークのように、オブジェクトであれば何でも入れられる。

Objective-Cのid型は、どのようなオブジェクト型にも暗黙でキャスト可能なので、NSDictionaryからオブジェクトを取るたびにキャストしなければならないという煩わしさはない。ただしそれは、コンパイル時に型チェックが行われないということであり、危険といえば危険である。


Auto Boxingのあり、なし

JDK5以降では、当然のようにAuto Boxingがサポートされている。
最近のJavaプログラマは、Auto Boxingという言葉を聞いたことがないかもしれない。

以下のコードを、

java.util.Map<String, Integer> map = new java.util.HashMap<>();
map.put("key012", 3);

Auto Boxingを使わずに記述すると、

java.util.Map<String, Integer> map = new java.util.HashMap<>();
map.put("key012", Integer.valueOf(3));

となる。

同様に、

System.out.format("key:%s, value:%d\n", entry.getKey(), entry.getValue());

は、

System.out.format("key:%s, value:%d\n", entry.getKey(), entry.getValue().intValue());

である。


要するに、プリミティブ型とラッパーオブジェクトとの相互変換をコンパイラが勝手にやってくれるというものである。

Objective-Cには、このAuto Boxingの機能は存在しない。
ゆえに、

[map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];

や、

NSLog(@"key:%@, value:%d", key, [[map objectForKey:key] intValue]);

のように、やや冗長な記述が必要になる。


putメソッドとsetObjectメソッドの引数の順序が違う

細かいことをいえば、見ての通り、連想配列に要素を格納するときのキーと値の引数の順序が逆である。

Java

map.put("key012", 3);

Objective-C

[map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];

Javaに慣れていると、つい癖で、キー、値の順で書きそうになってしまう。
Objective-Cでは、setObject(object == 値 をセットします)と、引数ラベル「forKey」に注目すると、引数順序の間違いは起こりにくいはずだ。


値にnull/nilを入れられるかどうかの違い

Javaの多くのコレクション系のクラスには、nullを格納することができる。

public class Test {
    public static void main(String[] args) {
        java.util.Map<String, Integer> map = new java.util.HashMap<>();
        map.put("key012", 3);
        map.put("key123", 4);
        map.put("key345", 5);
        map.put("key000", null);

        for (java.util.Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.format("key:%s, value:%d\n", entry.getKey(), entry.getValue());
        }
    }
}

【実行結果】

key:key000, value:null
key:key345, value:5
key:key123, value:4
key:key012, value:3

このように、値がnullの要素もきちんとコレクションの一員として管理される。

ところが、NSDictionaryを含む、Objective-Cの多くのコレクション系のクラスは、nilという値を許可しない。

int main(int argc, const char** argv)
{
    @autoreleasepool {
        NSMutableDictionary* map = [NSMutableDictionary dictionary];
        [map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];
        [map setObject:[NSNumber numberWithInt:4] forKey:@"key123"];
        [map setObject:[NSNumber numberWithInt:5] forKey:@"key345"];
        [map setObject:nil forKey:@"key000"];    // 実行時エラー:'NSInvalidArgumentException' ... object cannot be nil
        
        for (NSString* key in [map keyEnumerator]) {
            NSLog(@"key:%@, value:%d", key, [[map objectForKey:key] intValue]);
        }
    }
}

NSDictionaryの場合、「コレクション内に要素が存在しない」という意味でnilを使用する。たとえば、objectForKey:がキーに対応する値を見つけられなかったら、戻り値がnilになる。慣例として、nilを要素としてコレクションに格納したい場合には、NSNullオブジェクトを使う。

以下に例を示す。

int main(int argc, const char** argv)
{
    @autoreleasepool {
        NSMutableDictionary* map = [NSMutableDictionary dictionary];
        [map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];
        [map setObject:[NSNumber numberWithInt:4] forKey:@"key123"];
        [map setObject:[NSNumber numberWithInt:5] forKey:@"key345"];
        [map setObject:[NSNull null] forKey:@"key000"];
        
        for (NSString* key in [map keyEnumerator]) {
            // NSNullオブジェクトにintValueというセレクタは存在しないので、値を%@で表示するように修正
            NSLog(@"key:%@, value:%@", key, [map objectForKey:key]);
        }
    }
}

【実行結果】

key:key123, value:4
key:key012, value:3
key:key000, value:<null>
key:key345, value:5

このように、値がnullのオブジェクトとして、key000のエントリーはきちんと格納されている。


Map.EntrySetのあり、なし

java.util.Mapには、「キーと値の組み合わせ」という概念が存在し、それがMap.EntrySetという内部クラスである。Objective-Cには、Map.EntrySetに相当するクラスは用意されていない。
そもそも、内部クラスというものを定義できない。
NSDictionaryに格納されているオブジェクトを取得するには、

・キーをすべて列挙するか、(map#keyEnumerator)
・値をすべて列挙するか、(map#objectEnumerator)
・キーに対応する値を取得する(map#objectForKey)

ことしかできない。
従って、キーと値をすべて列挙するループ処理の記述が、Javaに比べて、Objective-Cはやや非効率的になっている。


要素の順序のサポートのあり、なし

java.util.HashMapは要素の登録順序を保持しない。
しかし、登録した順序を保持したければ、java.util.LinkedHashMapを使うことができる。

たとえば、要素の登録順序を保持するように最初のコードを修正するのは、以下のように簡単である。

public class Test {
    public static void main(String[] args) {
        java.util.Map<String, Integer> map = new java.util.LinkedHashMap<>();
        map.put("key012", 3);
        map.put("key123", 4);
        map.put("key345", 5);

        for (java.util.Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.format("key:%s, value:%d\n", entry.getKey(), entry.getValue());
        }
    }
}

Objective-Cには、java.util.LinkedHashMapに相当するクラスは用意されていない。
連想配列内の要素の順序を記憶しておきたければ、自分でキーの配列を用意すればよい。

int main(int argc, const char** argv)
{
    @autoreleasepool {
        NSArray* keys = [NSArray arrayWithObjects:@"key012", @"key123", @"key345", nil];
        
        NSMutableDictionary* map = [NSMutableDictionary dictionary];
        [map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];
        [map setObject:[NSNumber numberWithInt:4] forKey:@"key123"];
        [map setObject:[NSNumber numberWithInt:5] forKey:@"key345"];
        
        for (NSString* key in keys) {
            NSLog(@"key:%@, value:%d", key, [[map objectForKey:key] intValue]);
        }
    }
}

また、メモリ効率を無視してもよければ、以下のようなクラスを自前で用意することもできるだろう。

@interface LinkedDictionary : NSMutableDictionary
{
    // キーの登録順を配列で保持する
    NSMutableArray* _keys;
    NSMutableDictionary* _dict;
}
@end

@implementation LinkedDictionary

-(id)init {
    self = [super init];
    
    if (self) {
        _dict = [[NSMutableDictionary alloc] init];
        _keys = [[NSMutableArray alloc] init];
    }
    
    return self;
}

+(id)dictionary {
    return [[[LinkedDictionary alloc] init] autorelease];
}

-(void)dealloc {
    [_keys release];
    [_dict release];
    [super dealloc];
}

-(void)setObject:(id)anObject forKey:(id<NSCopying>)aKey {
    [_dict setObject:anObject forKey:aKey];
    [_keys addObject:aKey];
}

-(id)objectForKey:(id)aKey {
    return [_dict objectForKey:aKey];
}

-(NSEnumerator*)keyEnumerator {
    return [_keys objectEnumerator];
}

-(NSEnumerator*)objectEnumerator {
    NSMutableArray* array = [NSMutableArray array];
    
    for (id key in _keys) {
        [array addObject:[self objectForKey:key]];
    }
    
    return [array objectEnumerator];
}

@end

int main(int argc, const char** argv)
{
    @autoreleasepool {
        NSMutableDictionary* map = [LinkedDictionary dictionary];
        [map setObject:[NSNumber numberWithInt:3] forKey:@"key012"];
        [map setObject:[NSNumber numberWithInt:4] forKey:@"key123"];
        [map setObject:[NSNumber numberWithInt:5] forKey:@"key345"];
        
        for (NSString* key in [map keyEnumerator]) {
            NSLog(@"key:%@, value:%d", key, [[map objectForKey:key] intValue]);
        }
    }
}

この辺りは、若干面倒臭いと感じるところかもしれない。
↑のLinkedDictionaryの実装もあまり良いものではない。


NSCopyingプロトコル

これまで挙げた相違点というのは、言ってしまえば、些細な問題である。
Javajava.util.MapとObjective-CのNSDictionaryにはもっと致命的な違いがある。

それは、

NSDictionaryのキーには、NSCopyingプロトコルを実装したクラスのインスタンスしか格納できない

という点である。
これはつまり、NSDictionaryに格納するキーオブジェクトは、コピー可能なオブジェクトでなければならないし、NSDictionaryに格納される際にコピーされる、ということである。
Javaに喩えるなら、キーとなるオブジェクトがCloneableを実装していなければならない、というのに近い。

キーにするオブジェクトが文字列(NSString)や数値(NSNumber)である場合は、この制約は自動的にクリアしている。こいつらは元々NSCopyingプロトコルを実装しているからだ。もし、これから自分でなんらかのクラスを作成して、そのクラスのインスタンスをNSDictionaryのキーにしたいのであれば、NSCopyingプロトコルを実装して、-(id)copyWithZone:(NSZone*)というメソッドを用意しておかなければならない。

これは場合によっては問題になるだろう。
まず、そもそも、すべてのオブジェクトが論理的等価性を持っている(即ち、isEqualとhashをオーバーライドしている)わけではないはずだ。多数の属性を持つクラスにisEqualとhashを持たせるだけでも、相当骨が折れるし、属性の追加、削除時にバグを起こしやすい。その上、大きなデータ構造をコピーするのは、時に(明らかに必要でないのならば)非効率的である。そのコピー機能やisEqualなどを他の箇所で使わないのであれば、なおさら無駄だ。

たとえば、オブジェクトの参照値(ポインタ)をキーとして、対応する何らかの値を連想配列として管理したい場合もあるのではないだろうか。
仮に、これからなんらかの画面を作るとして、UIViewを継承するクラスを作ったとする。そのビューをキーとして、ビューに関連する情報を連想配列で管理したいと考えたとする。このようなときに、無理にUIView(のサブクラス)にNSCopyingを実装するべきではない。iOS6以降、NSDictionaryとは別の、便利な連想配列クラスが用意されている。


長くなったので、その話はまた次回