Swift:「Objective-Cランタイム & Swiftダイナミズム」(私家版・和訳版)
元記事はRealm Academyの「The Objective-C Runtime & Swift Dynamism」(2017-01-24)。
章タイトルに付されたタイムコードは、ポーランドのウッチで2016-10-22に開かれた「Mobilization 6: For Mobile Conference」TomTom(D)に、Roy氏が登壇された際に収録された動画の、冒頭からの経過時間に対応してます。
因みに。「Swift Playgrounds」がリリースされた約1ヶ月後に「Mobilization 6」が開かれ、その約半年後にSwift 3.1が、さらにその半年後にSwift 4がリリースされる…そんなタイムラインの話ですね。
以下、本文です。
Swiftが導入されて以来、Swiftを「もっと動的に」という声が上がっています。しかし、「動的である」とはどういう意味なのでしょうか? Objective-CがSwiftよりも動的なのは何故でしょう? そして、非動的言語で、これまでダイナミズムに依存していたものをどうやって構築するのでしょう? Roy Marmelstein氏が、Mobilization 2016で、これらについて取り上げました。
導入 (0:00)
今日はObjective-CランタイムとSwiftのダイナミズムについてお話します。タグラインに「2016年の展望」をつけたのは、私の言うことの多くが今後数年で変わる可能性があるからです。
まずは5ヶ月ほど前に遡ってみましょう。5月中旬です。太陽が輝いています。春ですね。花が咲き、Objective-CやiOSの人気開発者、Brent Simmons氏が一連のブログ記事を公開しました。ブログ記事では、プログラマーがObjective-Cのランタイム関数の力を借りて解決する様々な問題をドキュメント化しています。彼が主張しようとしているのは、言語としてのSwiftには、これに対するネイティブの解決策がないということです。そして、現在、SwiftはObjective-Cランタイムを同梱してますが、SwiftがObjective-Cに完全に取って代わる将来、これらの問題をどのように解決するかはわかりません。
次に起こったのは、Twitterでの大炎上。人々はどちらかの側を選びました。それは、「過去20年間Mac用のObjective-Cアプリを開発してきた保守派 vs. 新しい理想主義的なSwift開発者」「経験 vs. 理想主義」「柔軟性 vs. 型安全性」の論争に見えました。ツィートやMediumの投稿が多くて、あなたが買えるTシャツまであるほど、手に負えなくなってしまいました。
この話をやってみようかなと思ったのが、その頃ですね。今日は、実行時の関数と、「ダイナミズム」という言葉の意味を探ってみたいと思います。Swiftの現在を見て、これらの問題を見て、未来に向かって前を見て、そして何が来るのか…を見ていきたいと思っています。少し濃くなるかもしれませんが、最後には本当にcoolな猫GIFがあることをお約束します。お楽しみに。
Objective-Cランタイム (2:06)
始める前に、Objective-Cはランタイム指向の言語で、メソッド・変数・クラス間のすべてのリンクが、アプリが実際に実行される最後の瞬間まで延期されることを意味し、これにより、これらのリンクを変更できるため、非常に柔軟性が高くなります。一方、殆どの場合、Swiftはコンパイル時指向の言語です。そのため、Swiftではすべてがやや厳格な型付けとなり、安全性は向上しますが、柔軟性が低下します。
これが今回の討論の目的です。というわけで、これ以上は言わずに、さっそく始めてみましょう。
Objective-Cランタイムはライブラリです。「対象指向な」パートを担当し、オブジェクト指向プログラミングについて知っていることや好きなことは、すべてそこに実装されています。関数にアクセスしたい場合は、ライブラリをインポートするだけです。
#import <objc/runtime.h>
殆どがCとアセンブリで書かれていて、クラスやオブジェクト、メソッドのディスパッチ方法、プロトコル等すべてを実装しています。それは完全なオープンソースであり、非常に長い間オープンソースであり続けています。ダウンロードして、どのように実装されているかを見て、みんなで開発している言語について詳しく知ることができます。
ランタイムは、Objective-Cのオブジェクト指向プログラミングの部分を担当します。まずは、基本的な積み木から、始めてみましょう。オブジェクトって何でしょう? オブジェクトはruntime.hで定義されています:
typedef struct objc_class *Class;
struct objc_object {
Class isa;
};
オブジェクトは、クラスへの参照しか持っていません。それはisa(「is a」)であり、それだけです。これは、Objective-Cのすべてのオブジェクトが実装しなければならないことです。さて、クラスって何でしょう? クラスはもう少し複雑です。
struct objc_class {
Class isa;
Class super_class;
const char *name;
long version;
long info;
long instance_size;
struct objc_ivar_list *ivars;
struct objc_method_list **methodLists;
struct objc_cache *cache;
struct objc_protocol_list *protocols;
};
クラスはisaを持ちます。これはスーパークラスへの参照を持ち、NSObjectの場合を除いて、決してnilになることはありません。Objective-Cの他のすべてのものは、何らかの方法でNSObjectを継承しています。あとは名前、バージョン、情報があって、あんまり面白くありません。
私たちにとって気になるのは、ivarリスト、メソッドリスト、プロトコルリストです。これらは、実行時に変更したり、実行時に読み込んだりできるものです。オブジェクトは非常にシンプルな構造体であり、クラスは非常にシンプルな構造体であることがわかりました。これにより、最初のランタイム関数が提供される為、実行時にクラスを作成できます。
なぜそんなことをしたいのでしょう? ライブラリ・プロバイダのフレームワークでよく使われています。ユーザーが作成できるデータがわからない場合は、実行時にクラスを作成できます。Core Dataによって使用されています。必要に応じて、JSON解析と一緒に使用できます。
Class myClass = objc_allocateClassPair( [NSObject class], "MyClass", 0 );
// Add ivars, methods, protocols here.
objc_registerClassPair(myClass);
// ivars are locked after registration.
[[myClass alloc] init];
allocateClassPairというObjective-Cのランタイム関数があります。これにisa(この場合はNSObject)を与え、それに名前を付けます。3番目の引数は、追加バイト用である為、多くの場合、0のままにします。ivars、メソッド、プロトコルを追加してから、ClassPairを登録します。登録後はivarは変更できないが、他の全ては変更できます。
それでおしまい。このクラスは、他のObjective-Cクラスと同じように動作し、違いはありません。
カテゴリー (5:34)
さて、所有していないクラスがあり、それを拡張したい場合、関数を追加したい場合、簡単な方法はObjective-Cのカテゴリーにあります。Swiftのextensionに非常によく似ています。カテゴリーに関する問題点の一つは、storedプロパティを追加できないことです。computed変数を追加することはできますが、storedプロパティを追加することはできません。
ランタイムのもう一つの特徴は、関数setAssociatedObjectとgetAssociatedObjectを使って、既存のクラスにstoredプロパティを追加できることです。
@implementation NSObject (AssociatedObject)
//TODO: Property implementation must have its declaration in the category 'AssociatedObject'
@dynamic associatedObject;
- (id)associatedObject {
//TODO: Conflicting types for 'objc_getAssociatedObject'
return objc_getAssociatedObject(self,
@selector(associatedObject));
}
- (void)setAssociatedObject:(id)object {
//TODO: Conflicting types for 'objc_setAssociatedObject'
//TODO: Declaration of 'OBJC_ASSOCIATION_RETAIN_NONATOMIC' must be imported from module 'ObjectiveC.runtime' before it is required
objc_setAssociatedObject(self,
@selector(associatedObject),
object,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@end
これは、所有していないクラスを拡張する場合に、非常に役立ちます。
次に話したいのは、クラスは何ができるかを考えることです。これが「イントロスペクション」と呼ばれるものです。イントロスペクションの非常に基本的なバージョンは、おそらくあなたが日常的に使用するものです。
[myObject isMemberOfClass:NSObject.class];
[myObject respondsToSelector:@selector(doStuff:)];
// isa == class
class_respondsToSelector(myObject.class, @selector(doStuff:));
FoundationとNSObjectのサブクラスの一部である、isMemberOfClassがあります。それから、オプショナルなメソッドを持つプロトコルで、クラッシュしたくない場合に使用する、respondsToSelector:があります。ランタイムレベルでは、isMemberOfClassはisaの値を比較します。respondsToSelector:は、Objective-Cランタイム関数respondsToSelectorをラップしており、セレクタとクラスを指定します。
さて、ユニットテストを行なったことがある人ならば、XCTestCaseを書くときにsetUpとtearDownがあり、関数testを書くことを知っているでしょう。テストを実行すると、関数を列挙して自動的に実行します。この実装方法はObjective-Cランタイムを使っています。
unsigned int count;
Method *methods = class_copyMethodList(myObject.class,
&count);
//Ivar *list = class_copyIvarList(myObject.class, &count);
for(unsigned i = 0; i < count; i++) {
SEL selector = method_getName(methods[i]);
NSString *selectorString = NSStringFromSelector(selector);
if ([selectorString containsString:@"test"]) {
[myObject performSelector:selector];
}
}
free(methods);
メソッドリストをコピーできます。必要に応じて、ivarリストをコピーすることもできます。メソッド名を取得し、それを文字列に変換し、文字列「test」が含まれているかどうかをチェックして、実行します。XCTestのミニ版をビルドしました!
では、ivarやメソッドとは何でしょう?
struct objc_ivar {
char *ivar_name;
char *ivar_type;
int ivar_offset;
}
struct objc_method {
SEL method_name;
char *method_types;
IMP method_imp;
}
ivarは、実際にコードで定義しているものとそれほど違いはありません。タイプがあって、名前もある。オフセットは内部メモリ管理に関するものです。
Objective-Cメソッドは、その名前を「セレクタ」と呼び、performSelectorで使用されるものです。メソッドには、メソッドの型を表すエンコードされた文字列もあります。それから、我々のあずかりしらない特定の方法で、実装されます。
メソッドはシンプルなので、実行時にオブジェクトにメソッドを追加することもできます。
Method doStuff = class_getInstanceMethod(self.class,
@selector(doStuff));
IMP doStuffImplementation = method_getImplementation(doStuff);
const char *types = method_getTypeEncoding(doStuff); //"v@:@"
class_addMethod(myClass.class,
@selector(doStuff:),
doStuffImplementation,
types);
これを行なうには、関数class_addMethodを使用します。メソッドが存在するために必要だと言ったもの全て(セレクタ、実装、型)を受け取ります。既存のメソッドdoStuffを使って型文字列を取得しているので、この特定の実装はズルいですが、他にもいくつかの方法があります。
勿論、それらを利用する為に関数を呼び出します。同じことをする[self doStuff]か[self performSelector:@selector(doStuff)]を利用しますが、実行時レベルではobjc_msgSendでオブジェクトにメッセージを送信します。
[self doStuff];
[self performSelector:@selector(doStuff)];
objc_msgSend(self, @selector(message));
しかし、メソッドを呼び出してそのオブジェクトがnilである場合、例外が発生してアプリがクラッシュしてしまいます。しかし、事前に行われるステップ(備えていない関数で何かしようとすること)が存在する事が判明しています。
メソッド転送 (9:24)
メソッドを、別ターゲットに転送できます。これは、異なるフレームワーク間の橋渡しをしようとしている場合に、非常に便利です。実装されていないメソッドを呼び出すと、こうなります。
// 1
+(BOOL)resolveInstanceMethod:(SEL)sel{
// インスタンスメソッドを追加してtrueを返すチャンス。その後、メッセージ送信を再試行します。
}
// 2
- (id)forwardingTargetForSelector:(SEL)aSelector{
// セレクタを処理できるオブジェクトを返却します。
}
// 3
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
// NSInvocationを作成するには、これを実装する必要があります。
}
- (void)forwardInvocation:(NSInvocation *)invocation {
// 選択したターゲット上で、セレクターを呼び出します。
[invocation invokeWithTarget:target];
}
存在しないメソッドを呼び出すと、ランタイムは最初にresolveInstanceMethod(クラスメソッドであればresolveClassMethod)と呼ばれるクラス関数を、呼び出します。これは、以前に示した方法をメソッドに追加する機会です。trueを返却すると、元のメソッドが再度呼び出されます。
或いは、新しいメソッドを作成したくない場合は、forwardingTargetForSelectorがあります。そのメソッドを呼び出したいターゲットを返すだけで、そのターゲット上でセレクタが呼び出されます。
もう少し複雑なforwardInvocationがあります。呼び出し全体がNSInvocationオブジェクトにラップされ、特定ターゲットで呼び出せます。その際には、methodSignatureForSelectorも実装する必要があります。
メソッドを別オブジェクトに転送できますが、実装を置換 and/or 交換することもできます。ランタイムで実行できる動的機能で最もよく知られているのは、swizzlingと呼ばれるものです。これがswizzleの基本の方法です:
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL originalSelector = @selector(doSomething);
SEL swizzledSelector = @selector(mo_doSomething);
Method originalMethod = class_getInstanceMethod(class,
originalSelector);
Method swizzledMethod = class_getInstanceMethod(class,
swizzledSelector);
BOOL didAddMethod = class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod,
swizzledMethod);
}
});
}
クラスがロードされると、クラス関数loadが呼ばれます。swizzlingは一度だけ発生させたいので、dispatch_onceを使います。そして、メソッドを取得してclass_replaceMethodまたはmethod_exchangeImplementationsを使用します。なぜこれをした方がいいのかというと、モッキングやロギングに最適だからです。
Foundation (11:15)
ランタイムからレベルを上げると、Foundationがあります。Foundationは、ランタイム上に実際に構築された、特定の機能を実装しています:Key-Valueコーディング(KVC)とKey-Value監視(KVO)。KVCとKVOでは、UIをデータにバインドすることができます。これはRxとすべてのリアクティブ・フレームワークの約束であり、これはすでにFoundationに存在しています。これがKVCの方法です:
@property (nonatomic, strong) NSNumber *number;
[myClass valueForKey:@"number"];
[myClass setValue:@(4) forKey:@"number"];
例えば、code numberというプロパティがあれば、プロパティ名をキーにして、値を取得したり設定したりできます。以前に見た変数やプロトコル、危険な検査機能の一覧を取得することで動作します。
そして、KVOを使えば、変更を登録できます。
myClass.addObserver(self,
forKeyPath: "number",
options:[ NSKeyValueObservingOptions.initial, NSKeyValueObservingOptions.new ],
context: nil)
func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [ String : Any ]?,
context: UnsafeMutableRawPointer?) {
// Respond to observation.
}
監視値が変化した場合、このメソッドを呼び出してオブザーバーに通知します。そこからは、必要に応じて、UIを更新できます。
Objective-Cで「ダイナミズム」といえば、一般的にはこのような話をしています。他にもたくさんの関数がありますが、これらが一番便利です。これらは、Swiftに欠けていると人々が言うものです。
さて、繰り返しになりますが、これらのすべてには警告があります。例えばKVOでは、特に自分が所有していないクラスを監視すると、アプリ内で予期せぬ変化が起こることがあります。一般的に、これらは本当にデバッグが難しく、推論が難しいです。本番環境ではあまり使わない方がいいと思いますが、デバッグをしている時や自分でフレームワークを作る時にはとても便利だと思いました。でも、本番環境では、これには本当に気をつけようと思います。
Appleも同じことを考えていて、ビューコントローラの中に、クラスダンプで確認できるプライベート・メソッドを追加しました。
+ (void) attentionClassDumpUser:
yesItsUsAgain:
althoughSwizzlingAndOverridingPrivateMethodsIsFun:
itWasntMuchFunWhenYourAppStoppedWorking:
pleaseRefrainFromDoingSoInTheFutureOkayThanksBye:
かなりクレイジーです。
Swift (13:29)
Swiftの話をしましょう。言語としてのSwiftは、一般的に強く型付けされています。静的に型付けされていて、Swiftのデフォルトの型はとても安全です。安全ではない型を使用したいのであれば、安全ではない型を使用しても構いませんが、この言語では安全な静的型を使用することが推奨されています。Swiftに存在するダイナミズムが何であれ、Objective-Cランタイムを介して利用できます。
これはSwiftがオープンソース化され、SwiftがLinuxに移行するまでは、問題ありませんでした。Linux上のSwiftには、Objective-Cランタイムが同梱されていません。コミュニティでは、これが将来のSwiftの方向性であり、Appleに依存しないという話が出ています。
Swiftで得られるのは、@objcと@dynamicという2つの修飾子と、NSObjectへのアクセスです。@objcは、Swift APIをObjective-Cランタイムに公開しますが、コンパイラが最適化を試みないことを保証するものではありません。クールな動的機能を本当に使いたいのであれば、@dynamicを使う必要があります。@dynamic修飾子を使う場合、暗黙のうちに使われるので@objcを使用する必要はありません。
動的機能のリストに戻って、Swiftでどのように変化するか見てみましょう。私たちのイントロスペクションは、メソッドを転送し、置換しバインドするとします。転送は、実はそんなに変わりません:
// 1
override class func resolveInstanceMethod(_ sel: Selector!) -> Bool {
// インスタンスメソッドを追加してtrueを返すチャンス。その後、メッセージ送信を再試行します。
}
// 2
override func forwardingTarget(for aSelector: Selector!) -> Any? {
// セレクタを処理できるオブジェクトを返却します。
}
// 3 - NSInvocationはSwiftでは利用できません。
呼び出されるresolveInstanceMethodは、まだあります。forwardingTargetも、Swift 3スタイルのAPIのようなものを持っています。しかし、NSInvocationは、Swiftには存在しません。私たちはまだ転送を行なうことができるので、これはそれほど悪いことではありません。
Swizzlingは、少し違います。Swiftではloadは全く呼ばれないので、initialize中にswizzlingを行なう必要があります。Objective-Cでは、dispatch_onceを使用しましたが、Swift 3以降は存在しません。ちょっとややこしいですね。動的関数として定義できる特定種類の関数はまだ可能ですが、swizzleする機能の殆どが削除されています。
イントロスペクションのために、新しいものを用意しています。
if self is MyClass {
// わ〜い
}
let myString = "myString"
let mirror = Mirror(reflecting: myString)
print( mirror.subjectType ) // "String"
let string = String(reflecting: type(of: myString)) // Swift.String
// ネイティブメソッドのイントロペクションはありません。
isMemberOfClassの代わりに、isがあり、Swift値型でも機能します。これを構造体や列挙型など、Swiftで手に入れた素晴らしい新機能に使うことができます。また、パイプやデータに焦点を当てた新しいミラーリングAPIもあります。
今のところ、メソッドをイントロスペクトするネイティブな方法はありません。それが登場することは仄めかされてますが、まだありません。前に示したXCTestCaseの動作を考えると、これは特にイライラします。Linux用のユニットテストを書きたいのであれば、関数を自動的に列挙することはできません。static var allTestsを実装し、すべてのテストを手動でリストアップしなければなりません。これは非常に悲しいことです。
KVOやKVCはどうでしょう? KVOの威力は、所有していないクラスや、変化に対応したいだけのクラスでも実行できることです。KVOやKVCは、Swiftではかなり弱くなっています。監視しているオブジェクトはNSObjectを継承し、Objective-C型でなければなりません。監視している変数は、dynamicとして宣言されていなければなりません。監視するモノについては、非常に具体的にする必要があります。
問題は、Swiftが素晴らしい代替案がないことです。モノの監視方法に、Rxを使うこともできるし、プロトコルベースを使うこともできます。しかし、この問題を解決するネイティブなものはまだ何もありません。
Swiftはエキサイティングな言語であり、朗報があります。最近Swiftメーリングリストで、Chris Lattner氏は、動的機能の追加が、Swiftには不可欠だと考えていると述べています。また、これらの「動的な」機能が何であるかについて、人々が同意しなくても、これらの問題を解決する為の、ネイティブで、流暢で、Swift的な方法を見つけることが重要だとも述べています。
結論 (18:11)
今日、Swift 4のステージ2に対して、より多くのダイナミズムが予定されています。現在はSwift 4のステージ1にあり、APIの安定性に焦点を当てています。それが完了され次第、iOS 11迄の残り時間で、コアチームの中心的な焦点の1つになるでしょうし、メソッドをイントロスペクトする方法からおそらく始まるでしょう。
もう一つ、サイドプロジェクトで取り組んでいることがあります。ObjectiveKitというオープンソース・プロジェクトに取り組んでいます。Swift的な方法でいくつかのランタイム関数を公開するというアイデアですが、これはかなり面白いと思います。
結論から言うと、Objective-Cのダイナミズムは強力で、便利で、少し危険です。Swiftは現在、これらの問題に対して十分な代替案や解決策を持っていません。しかし、それは間も無く登場するだろうし、楽しみなこともあります。これが猫gifで、凄いと思います。どうもありがとうございました。