2011/02/28

NSObjectを継承しないクラスをランタイムAPIで作成する方法について

Objective-CにはランタイムAPIが用意されており、これを利用することでObjective-Cのクラスやオブジェクトを作成や操作を実行時に行うことができます。

本記事では新しいクラスをObjective-CランタイムAPIで作成して利用してみます。 このとき、新しいクラスをNSObjectから継承せずに作成してみることで、どのような問題が起こるのかを述べます。

Objective-CランタイムAPIについてはAppleのリファレンス「Objective-C Runtime Reference」を参照してください。 また、Objective-Cランタイムシステムに興味のある方は、マイコミジャーナルのコラム「ダイナミックObjective-C」の14〜31回を参照してください。

Objective-CランタイムAPIによるメソッドの列挙

まず、最初はObjective-CランタイムAPIを利用してクラスのメソッドを列挙してみましょう。 ちょっと適当ですが次のような関数を作成してみます。

void listMethods(id cls) {
    unsigned int method_count = 0;
    Method* m = class_copyMethodList(cls, &method_count);
    for (unsigned int i = 0; i < method_count; ++i) {
        SEL sel = method_getName(m[i]);
        const char* enc = method_getTypeEncoding(m[i]);
        char ret[256];
        method_getReturnType(m[i], ret, sizeof(ret) - 1);

        NSLog(@"method: %s (%s)", (char*)sel, enc);
    }
    free(m);
}

これを利用してNSObjectのメソッドを一覧を表示してみましょう。

listMethods([NSObject class]);

結果は次のようになります (長いのでほんの一部だけ抜粋)。

    :
method: superclass (#8@0:4)
method: hash (I8@0:4)
method: isEqual: (c12@0:4@8)
method: init (@8@0:4)
method: description (@8@0:4)
method: isKindOfClass: (c12@0:4#8)
method: dealloc (v8@0:4)
    :

括弧内の文字列はタイプエンコーディングと呼ばれているもので、メソッドの引数や戻り値を示しているもので、例えば「@」はオブジェクト、「:」はセレクタを示しています (詳細は「Objective-C Runtime Programming Guide: Type Encodings」を参照してください)。 メソッドdescriptionのタイプエンコーディングの数値を取り除いたものは「@@:」になり、 これはオブジェクトとセレクタを引数に取り、オブジェクトを戻り値にするということを示しています。

Objective-CランタイムAPIによるメソッドの列挙

ランタイムでObjective-Cのクラスを作成する手順は次のようになります。

  1. objc_allocateClassPairでクラスとメタクラスをアロケートします。
  2. classaddMethodやclassaddIvarなどでメソッドなどを追加します。インスタンスメソッドとインスタンス変数はクラスに追加し、クラスメソッドはメタクラスに追加します。なお、メタクラスは次のようにして取得することができます。 Class metaClass = object_getClass(newClass);
  3. objc_registerClassPairで登録することでlookup可能になります。

それでは実際にクラスを作成してみましょう。 次のコードではメソッドdescriptionを持つだけの単純なクラスMyObjectを作成しています。 なお、第1引数と第2引数はObjective-Cではselfと_cmdで参照できる隠し引数となっています。

NSString* MyObject_description(id self, SEL _cmd) {
     return @"MyObject_description is called.";
}

void MyObject_createClass() {
    Class cls = objc_allocateClassPair(nil, "MyObject", 0);
    class_addMethod(cls, @selector(description), (IMP) &MyObject_description, "@@:");
    objc_registerClassPair(cls);
}

これで、クラスMyObjectのインスタンスを作成して、メソッドdescriptionを実行することができます。

MyObject_createClass();

Class cls = objc_getClass("MyObject");
id obj = class_createInstance(cls, 0);
NSLog(@"%@", obj);

しかし、実行してみると、NSLogの行で落ちてしまいます。運がよければ、次のメッセージが表示されるでしょう。

*** NSInvocation: warning: object 0x9904960 of class 'MyObject' does not implement methodSignatureForSelector: -- trouble ahead
*** NSInvocation: warning: object 0x9904960 of class 'MyObject' does not implement doesNotRecognizeSelector: -- abort

メソッドmethodSignatureForSelector:は前回の記事「メソッドforwardInvocation:を利用したメッセージ転送について」でも少し出てきましたが、対処できないメッセージを受け取ったときに呼ばれます。 このメソッドが呼ぶことができなかったため、メソッドdoesNotRecognizeSelector:を呼ぼうとし、これもないためにAbortしてしまっています。

(メソッドmethodSignatureForSelector:を作成してトレースしてみればわかりますが) なぜ、メソッドmethodSignatureForSelector:が呼ばれている理由は、クラスメソッドinitializeがないためです。 クラスメソッドinitializeはクラス使用の初回時に1回だけ呼ばれるメソッドで、必ず実装する必要があります。

また、これ以外にもメソッドdescriptionを実行するにはもう少し作業が必要です。 実は、日付などのロケールを考慮する必要のあるクラスには、メソッドdescriptionのロケール版であるdescriptionWithLocale:があり、NSLogを用いた場合にはdescriptionを呼ぶ前にdescriptionWithLocale:があるかどうかをチェックするためにrespondsToSelector:が呼ばれてしまいます。

これらを踏まえたコードは次のようになります。 実行すると、メソッドdescriptionWithLocale:のために MyObject_respondsToSelectorが呼ばれますが、その場合はFALSEが返されるようにしています。 なお、Cocoaの実装ではSEL型が文字列であるため、それを利用しています。

void MyObject_initialize(id self, SEL _cmd) {
     return;
}

BOOL MyObject_respondsToSelector(id self, SEL _cmd, SEL sel) {
    if (strcmp((char*) sel, "description") == 0) return TRUE;
    return FALSE;
}

void MyObject_createClass() {
    Class cls = objc_allocateClassPair(nil, "MyObject", 0);
    class_addMethod(cls, @selector(description), (IMP) &MyObject_description, "@@:");
    class_addMethod(cls, @selector(respondsToSelector:), (IMP) &MyObject_respondsToSelector, "b@::");

    Class meta = object_getClass(cls);
    class_addMethod(meta, @selector(initialize), (IMP) &MyObject_initialize, "v@:");
    objc_registerClassPair(cls);
}

これでめでたく表示することができました。 NSObjectから継承した場合はこのあたりの面倒さを回避することができます。

method: respondsToSelector: (b@::)
method: description (@@:)
MyObject_description is called.

methodSignatureForSelector:とforwardInvocation:を利用したメッセージ処理

前回の記事「メソッドforwardInvocation:を利用したメッセージ転送について
」のようにmethodSignatureForSelector:forwardInvocation:とを組み合わせることで適当に出されたメッセージに対して処理を行うことができます。

次のコードでは、まずメソッドMyObjectmethodSignatureForSelectorでセレクタに応じた適当なタイプエンコーディングをでっちあげています。 それによって、メソッドMyObjectforwardInvocationでのNSInvocationの値が代わりますので、今回は引数によって適当な処理をして、戻り値をNSInvocationにセットしています。

NSMethodSignature* MyObject_methodSignatureForSelector(id self, SEL _cmd, SEL sel) {
    NSString* enc = @"@@:";
    for (char* i = (char*) sel; *i != 0; ++i) {
        if (*i == ':') {
            enc = [enc stringByAppendingString:@"@"];   
        }
    }
     return [NSMethodSignature signatureWithObjCTypes:[enc UTF8String]];
}

void MyObject_forwardInvocation(id self, SEL _cmd, NSInvocation* inv) {
    SEL sel = [inv selector];

    NSMethodSignature* sig = [inv methodSignature];
    NSUInteger         num = [sig numberOfArguments];

    NSLog(@"MyObject_forwardInvocation: %s (%d args)", sel, num);

    id ret = nil;

    if (num == 2) {
        ret = [NSString stringWithFormat:@"%s", sel];
    } else if (num == 3) {
        NSString* arg1;
        [inv getArgument:&arg1 atIndex:2];
        ret = [NSString stringWithFormat:@"%@%s", arg1, sel];
    } else if (num == 4) {
        int arg1;
        int arg2;
        [inv getArgument:&arg1 atIndex:2];
        [inv getArgument:&arg2 atIndex:3];
        ret = [NSNumber numberWithInt:arg1 * arg2];
    }
    [inv setReturnValue:&ret];
}

上のコードの利用例は次のようになります。

NSLog(@"%@", [obj foobar]);
NSLog(@"%@", [obj baz:@"some-string"]);
NSLog(@"%@", [obj x:12 y:3]);

結果は次のようになります。

MyObject_forwardInvocation: foobar (2 args)
foobar
MyObject_forwardInvocation: baz: (3 args)
some-stringbaz:
MyObject_forwardInvocation: x:y: (4 args)

まとめ

NSObjectを継承しないで新しいクラスをObjective-CランタイムAPIで作成する方法について述べました。

関連項目

0 件のコメント:

コメントを投稿

注: コメントを投稿できるのは、このブログのメンバーだけです。