Objective-Cがメソッドをどのように呼び出しているのかについて気になったので、実装レベルまで追いかけてみました。
参考サイト
まず最初に、先人たちが書いた情報源がいろいろありますのでメモを兼ねて残しておきます。
- Apple公式
- Objective-C Runtime Programming Guide
- Objective-C Runtime Reference
- 有志
- Understanding the Objective-C Runtime
- objc_msgSend Tour
- Objective-C vs Swift message dispatch
- クラス拡張とカテゴリの違いの話。
Objective-Cのメソッド呼び出しは動的である
まず忘れてはいけないのは、 Objective-Cのメソッド呼び出しは動的だ ということです。 動的 と言っているのは「メソッド呼び出しによって実際にどのコードが実行されるかは実行時に決定される」という意味です。
その逆の 静的な呼び出し というのは main()
のようなグローバル関数の呼び出しなどです。静的に呼び出される関数はコンパイル時にアドレスが決定されるので、実行時に実装を差し変えるようなことはできません。
具体的に見てみましょう。
静的呼び出しの例
C言語の例です。
#include <stdio.h>
void static_func() {
printf("hello");
}
int main(int argc, char *argv[]) {
static_func();
}
このCのプログラムをgcc -S test.c
でアセンブラに変換してみますと、static_func() の呼び出しは以下のようになります。
callq _static_func
このように、static_func関数のアドレスを直接コールしています。これはとても高速に動作しますし、至極まっとうなコードです。
しかしもし static_func()関数の実装を static_func2()関数に差し替えたい となった場合は、再コンパイルが必要です。プログラムが動作している間に差し替えることはできません。プログラム上のすべてのcallq _static_func
命令を差し替えれば不可能ではないですが、モダンなOSではコードの自己書き換えはセキュリティ上許していないので、不可能です。
少し脱線しますが 呼び出し先の関数のアドレスはコンパイル時に決まってしまう と言っても、0x1234 のような絶対アドレスが決定されるわけではありません。絶対アドレスはプログラムがメモリ上にロードされるときに決定されます。どういうことかというと、実はコンパイルして作られた実行バイナリの中を覗くと、機械語のcallq
命令などの参照先アドレスが空欄のままになっています。そして実際に実行バイナリがプログラムローダーによってロードされるときに、callq
命令の呼び出し先を書き換えてメモリ上に配置されるのです。そういう意味では呼び出し先アドレスは動的に決まるのですが、 プログラムが走り始めた後は変わらないという意味で静的です。
動的呼び出しの例
Objective-Cの動的メソッド呼び出しの例を見てみます。
int main(int argc, char *argv[]) {
NSObject* obj = ...;
NSString* desc = [obj description];
}
NSObjectクラスの description メソッドを呼び出しています。これを gcc -S test.m
などとしてみると、以下のようなコードが生成されます。(実際にビルドする場合は ... の部分を nilとかにしてコンパイルエラーにならないようにしてください)
movq L_OBJC_SELECTOR_REFERENCES_(%rip), %rax
movq %rsi, %rdi
movq %rax, %rsi
callq _objc_msgSend
:
L_OBJC_METH_VAR_NAME_: ## @OBJC_METH_VAR_NAME_
.asciz "description"
.section __DATA,__objc_selrefs,literal_pointers,no_dead_strip
.align 3 ## @OBJC_SELECTOR_REFERENCES_
L_OBJC_SELECTOR_REFERENCES_:
.quad L_OBJC_METH_VAR_NAME_
細かい説明は省きますが、ようは、"description" という文字列(のポインタ)をレジスタにセットして _objc_msgSend という関数を呼び出しています。つまり descriptionという関数を直接 callしているわけではありません。
お分かりだと思いますが、 NSObject*型 の変数に対して descriptionメソッドを呼び出しても、NSObjectクラスそのものの descriptionメソッドが呼び出されるとは限りません。objには、 NSObjectのサブクラスのインスタンスがセットされているかも しれないからです。そういった場合はサブクラスの descriptionメソッドを呼び出さなければいけないため、この時点でどの関数を呼び出せばいいのかが決まらないのです。
これは オブジェクト指向の ポリモーフィズム と呼ばれる挙動ですね。ポリモーフィズムを実現するためには、呼び出すべきメソッドを実行時に決定する必要があります。 その判定を行って実際の関数を呼び出す処理を行っているのが objc_msgSend 関数なのです。
objc_msgSendの詳細
Objective-C のメソッド呼び出しがobjc_msgSend関数によって動的に行われるということが分かったと思いますが、具体的にどのように行われているかは Objective-C Runtime Programming Guideの The objc_msgSend Functionやobjc_msgSend Tourに詳しく書かれています。
このページの図を引用して、呼び出すべきメソッドを解決する手順を説明します。
- インスタンスのメモリ領域の先頭にある isaポインタ をたどって、そのインスタンスのクラス情報にアクセスします。なお、 isa は「this obj is a MyClass instance (このオブジェクトはMyClasssのインスタンスです)」の is a から来ていて「イズア」と読みます。
- クラス情報にはそのクラスが持っているインスタンスメソッドの情報が書かれているので、その中から該当のメソッドを探します。見つかればそれを呼び出します
- もし見つからなかった場合は、親クラスへのポインタをたどって、親クラスに対して2の処理を行います
- 2〜3の処理を繰り返してもメソッドが見つからなかった場合は、 unrecognized selector sent to instance例外 が発生します。
このように、子クラスから親クラスに向かう方向でメソッドを探していくため、子クラスによるメソッドのオーバーライドが実現できるのです。
objc_msgSendの実行速度と、C++との比較
objc_msgSendが行っている処理は結構複雑(やることが多い)ですね。メソッド呼び出しのたびにこんなことをやっていて、実行速度は問題にならないのでしょうか?
実際、C++もオブジェクト指向言語でポリモーフィズムを実現している言語ですが、objc_msgSend がやっているような非効率な方法は使っていません。C++の場合は、各クラスに対して 仮想関数テーブル というテーブルを持っていて、「仮想関数テーブルのn番目にある関数を呼び出す」という形で機械語が生成されます。詳しくはWikipediaの仮想関数テーブルの説明などを読んでいただきたいですが、例えば、 descriptionというメソッドが 3番目に定義されたメソッドだと仮定 すると、
obj->description();
という呼び出しは「objの仮想関数テーブルの3番目にある関数を呼び出す」というコードにコンパイルされます。
この仮想関数テーブルは、インスタンスが属するクラスごとに異なるものが用意されています。つまり、objがParent
クラスのインスタンスだった場合(①)とChild
クラスのインスタンスだった場合(②)では、異なる仮想関数テーブルが使われます。
そのためChild
クラスで description()
をオーバーライドしたい場合は、Child
クラスの仮想関数テーブルの3番目に、親クラスとは異なる関数のアドレスを書いておきます。そうすることによって、①に対して description()
を呼び出した場合と、②に対してdescription()
を呼び出した場合で異なる関数を呼び分けることができます。
また、Child
クラスで description()
メソッドをオーバーライドしていない場合は、Child
クラスの仮想関数テーブルの3番目にParent
クラスと同じ値をセットしておけば良いだけです。
C++ではこのようにしてポリモーフィズムが正しく実現されるのですが、 明らかに objc_msgSend よりも効率の良い実装 です。
実際、速度面で劣る Objective-Cは実行速度を稼ぐためにメソッドのキャッシュを持てるようにしたり、よく使われるメソッドを最大16個まで保持できる関数テーブルをもたせたり(Understanding the Objective-C Runtimeの「Hybrid vTable Dispatch」を参照)と色々努力しています。
Objective-Cがそんな努力をしてまで C++と同じ仮想関数テーブル方式を取らないのには理由があります。
Objective-CはC++よりも柔軟なメソッド呼び出しができる
C++よりもメソッド呼び出し効率の悪い Objective-Cですが、その代わりに、C++よりも柔軟なメソッド呼び出しが行えるというメリットがあります。例えば以下のようなコード。
id obj = .....;
NSString* desc = [obj description];
obj は id型なので、どのようなメソッドを持っているかわかりません。それでも objに対して descriptionメソッドを呼び出すコードはコンパイルできてしまいます。この場合、
- 実行時に、objが descriptionメソッドを実装していれば呼び出しが成功
- 実行時に、objが descriptionメソッドを実装していなければ例外
となります。
このようなことは C++ ではできません。一旦何らかの型にキャストする必要があります。
void* obj = ....;
Hoge* casted = static_cast<Hoge*>(obj);
casted->hoge();
なぜかというと、C++のメソッド呼び出し(仮想関数呼び出し)は、「仮想関数テーブルのn番目を呼び出す」というコードにコンパイルしなければならず、そのためには「どの型(サブクラスでも良い)の何という名前のメソッドなのか」がコンパイル時に決まらないといけないのです。 「型はどうでもいいけど、名前が一致するメソッドがあれば呼び出して」というようなこと(ダックタイピング的なこと)はできない のです。
カテゴリによるメソッドの動的追加
カテゴリとは
柔軟なメソッド呼び出しができる Objective-Cはそのメリットをさらに生かして、 カテゴリという機能が使えるようになっています。
例えば、NSStringクラスには hogehogeというメソッドは存在しないので、以下のコードはコンパイルエラーとなります。
NSString* msg = @"Hello";
[msg hogehoge];
以下のようにid型にキャストしてhogehogeメソッドを呼ぶとコンパイルは通りますが、実行時にエラーとなります(objc_msgSendがメソッドを見つけられないため)。
id msg = @"Hello";
[msg hogehoge];
ところが、カテゴリを使うと既存の NSStringクラスに hogehogeメソッドの実装を追加してしまうことができます。以下のヘッダファイルはカテゴリの定義例です。
@interface NSString (HogeHogeCategory)
- (void) hogehoge;
@end
このヘッダファイルを以下のようにimportするだけで新しいメソッドhogehoge
を使うことができるようになります。
#import "NSString+HogeHoge.h"
NSString* msg = @"Hello";
[msg hogehoge];
msgは紛れもなく NSString型のインスタンスですが、[msg hogehoge] はコンパイルエラーにもならず、実行時には正しく hogehoge メソッドを呼び出すことができます。つまり、NSStringという既存のクラスに対して、サブクラス化することなくメソッドが追加できたわけです。
これがカテゴリ拡張です。
カテゴリが実現される仕組み
既存のクラスにメソッドが追加できるという面白い機能ですが、どのようにして実現されているのでしょうか。
メソッド呼び出しは先ほど説明した objc_msgSend によって実現されていますので、その動作を思い出してください。NSString型の場合は以下のようになるでしょうか。
msgはNSStringのインスタンスなので、msgのisaポインタはNSStringのクラス情報を指しています。そこにはNSStringクラスが実装しているメソッドの名前とアドレスが書かれています。NSStringの親クラスはNSObjectなので、superclassポインタをたどることでNSObjectのクラス情報にも到達できるようになっています。
NSObjectには description、isEqual、hashなどの基本的なメソッドが実装されていますが、これらのメソッドはサブクラスのNSStringでオーバーライドされていますのでNSStringのクラス情報にも存在します(指しているアドレスは違います)。また、NSStringにはNSObjectにはない characterAtIndex: や stringByAppendingString: などのメソッドがありますので、これらもNSStringのクラス情報に書かれています。
そして、NSStringのクラス情報の最後に hogehoge メソッドのアドレスがあります。これがカテゴリによって追加されたメソッドです。NSStringを実装しているFoundationフレームワークをロードした段階ではこのhogehogeメソッドの情報は存在しませんが、NSStringに対するカテゴリを実装したプログラムがロードされたタイミングでNSStringのメソッド一覧に hogehogeが追加されます。すると、それ以降は msgオブジェクトに対して hogehoge メソッドを呼び出してもエラーにならなくなる、というわけです。
なお、#import "NSString+HogeHoge.h"
というようにヘッダファイルをインポートするのはコンパイラに対して「NSStringはhogehogeというメソッドがあるはずだから呼び出しても安全だよ」ということを教えてあげるためです。ですので、ヘッダファイルをインポートしなくても以下のようにして直接呼び出すこともできます。
id msg = @"Hello";
[msg hogehoge];
上記コードが実行時にエラーにならない条件は、HogeHogeカテゴリを含むモジュールが実行時までにロードされているかどうかだけに依存しています。
メソッドテーブルの詳細
上記模式図が具体的にどう実現されているかを知りたい場合は、Objective-Cのインスタンスやクラス定義がどのような構造体で定義されているかを知る必要があります。幸い Objective-Cのランタイムは http://opensource.apple.com でオープンソースとして公開されていますので、ヘッダファイルを見ることができます。
objc_object構造体
Objective-Cのオブジェクトはobjc.hでobjc_object構造体として定義されています。
/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;
/// Represents an instance of a class.
struct objc_object {
Class isa OBJC_ISA_AVAILABILITY;
};
/// A pointer to an instance of a class.
typedef struct objc_object *id;
説明の通り、オブジェクトの先頭には isa があります。isaは Class型ですが、Class型は objc_class構造体へのポインタが typedefされたものですので、これはポインタで、それが指す先は objc_class構造体です。
objc_class構造体
isaポインタが指す先の objc_class構造体は、objc-runtime-new.h というファイルで定義されています。結構複雑な構造をしていて長いので、ちょっと整形して引用します。
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
class_rw_t *data() {
return bits.data();
}
}
objc_class構造体自体は objc_object構造体を継承して作られていて、Class情報そのものをClassオブジェクトとして扱えるようになっていることがわかります。また、superclassポインタで親クラスの情報に辿れることがわかります。
肝心のメソッド情報はどこでしょうか?メソッド情報はこの構造体の中ではなく、class_rw_t型のdataポインタが指す先にあります。
class_rw_t構造体
struct class_rw_t {
uint32_t flags;
uint32_t version;
const class_ro_t *ro;
method_array_t methods;
property_array_t properties;
protocol_array_t protocols;
Class firstSubclass;
Class nextSiblingClass;
どうやらメソッドの情報は、class_rw_t構造体の methodsというメンバが保持しているっぽいです。method_array_tは list_array_tt のサブクラスで、method_t型のリストのリスト(リストを複数管理できるもの)です。
method_t構造体
struct method_t {
SEL name;
const char *types;
IMP imp;
};
ようやくメソッドの情報を保持しているmethod_t構造体まで来ました。メソッド名(name)、型情報(types)、関数ポインタ(imp)を持っています。
SELとIMPは objc.hで定義されています。
/// An opaque type that represents a method selector.
typedef struct objc_selector *SEL;
/// A pointer to the function of a method implementation.
#if !OBJC_OLD_DISPATCH_PROTOTYPES
typedef void (*IMP)(void /* id, SEL, ... */ );
#else
typedef id (*IMP)(id, SEL, ...);
#endif
IMPはただの関数ポインタだとわかります。
typesはメソッドシグネチャを表す文字列(のリスト?)と思われます。ここにはおそらく、Type Endodingされた文字列が入り、メソッドの引数や返り値の型情報を保持しているのだと思います。
SELは objc_selector構造体へのポインタとして定義されていますが、この構造体の実体はどこにも定義されていません。Stack Overflowによると「SELの実体は const char *
だが、そのことを隠蔽するために用意されたダミーの構造体だ」とのことです。
実際にSELからメソッド名を取り出す際は、objc-sel.mmにある、sel_getName関数でconst char *
にキャストして使うようです。
const char *sel_getName(SEL sel)
{
if (!sel) return "<null selector>";
return (const char *)(const void*)sel;
}
図解
以上をもとに図に整理してみると以下のようになります。
method_array_tのところがちょっと複雑ですが、メソッドが単純に列挙されているわけではなく、メソッド群が複数持てるような構造になっているようです。一つのカテゴリには複数のメソッドを定義できるので、カテゴリが一つ増えるたびにmethod_tのリスト(横一列)が増えていくのだと思います。
いろいろな疑問に対する(僕なりの)回答
Objective-Cがメソッドを呼び出す仕組みの詳細が分かったため、以下のような疑問に対して自分なりの答えを持つことができるようになりました。
同一クラスで実装されているメソッドをカテゴリで再定義できるか
例えば、NSStringの descriptionメソッドを独自にものに差し替える、というような動作です。Appleのドキュメントや Stack Overflowなどでもすぐに出てきますが、これはNGです。
あるクラスのメソッドを探す処理(つまり objc_msgSendの実装)は、method_array_t から呼び出すべきメソッド(method_t)を探す処理となります。この中に同じメソッドが2つあった場合、どちらを呼び出すべきかを決定することができないからです。
method_array_tを頭からリニアサーチするのだとすると、もっとも先にロードされたメソッドが呼び出されそうな気もしますが、実際には objc_msgSendはキャッシュ機構を持っていたりするので、必ずしもそのように呼び出されるとは限りません。
親クラスのメソッドをサブクラスのカテゴリでオーバーライドできるか
これは問題なくできそうです。
Objective-Cのポリモーフィズムは、メソッドを子から親に向かって検索することで実現されています。そのため、子クラスのメソッドであれば本体が実装していようがカテゴリが実装していようが、親クラスのメソッドよりは優先して呼び出されます。つまりオーバーライドが効きます。
親クラスのカテゴリで実装されたメソッドを、サブクラスの本体でオーバーライドできるか
これも上と同じ理由で問題なさそうです。
親クラスのカテゴリで実装されたメソッドを、サブクラスのカテゴリでオーバーライドできるか
これも上と同じ理由で問題なさそうです。
感想
しばらくモヤモヤしていたことがだいぶスッキリしました。次回はこれのSwift版を調査してみたいと思っています(せっかくオープンソースになったところですし)。