読者です 読者をやめる 読者になる 読者になる

Objective-Cのクラス・カテゴリ・クラス拡張の整理

以前こんな記事を書きました。

現時点で、こんな場末のブログで唯一ブクマが多い記事なのですが、その文末で、

次は、カテゴリについて勉強したいと思います。

Objective-Cのプロトコルとデリゲートのまとめ - $ cat /var/log/shin

なーんて書いてから、はや9ヶ月超。ようやくカテゴリについて調べる機会が来たので、まとめてみます。

座学編

クラスの基本

まず初めにクラスの基本を整理しておきます。

// ex1.h
#import <Foundation/Foundation.h>

@interface MyClass1 : NSObject
- (void)publicMethod;
@end
// ex1.m
#import "ex1.h"

@implementation MyClass1
- (void)publicMethod { NSLog(@"%s", __PRETTY_FUNCTION__); }
@end
// main.m
#import "ex1.h"

int main() {
  MyClass1* myInstance1 = [[MyClass1 alloc] init];
  [myInstance1 publicMethod];
  [myInstance1 release];
  return 0;
}

コンパイル、実行します。

$ gcc main.m ex1.m -framework Foundation
$ ./a.out
2013-11-05 23:20:12.885 a.out[43822:507] -[MyClass1 publicMethod] 

後々のために、ここで述べておきますが、同名の@interfaceや@implementationは宣言することはできません。

これ以上の詳細に関しては、前に痛い記事を書いていますので良かったら読んでみて下さい。

カテゴリ

カテゴリを使えばクラスを拡張できるよ、という話は少し調べれば分かることなのですが、じゃあ何でクラスを拡張できるような機能の名前が「カテゴリ」なんだよ、という疑問が浮かんだので調べてみました。

このサイトによれば、カテゴリは元々クラスを分割して書くことが出来るようにするため考えられた言語機能のようです。クラスを書いていると、しばしば互いに関連しているメソッドが出てくるので、そういった機能のまとまりを「カテゴリ」化して、別々に*1実装できたら嬉しいじゃん、ということですね*2

とりあえずカテゴリのサンプルコードを載せます。

// document.h
#import <Foundation/Foundation.h>

@interface Document : NSObject {
  NSString *_title, *_content;
}
@property(nonatomic, assign) NSString *title, *content;
- (void)printTitle;
- (void)printContent;
@end
// document.m
#import "document.h"

@implementation Document
@synthesize title=_title, content=_content;
- (void)printTitle { printf("%s\n", [_title UTF8String]); }
- (void)printContent { printf("%s\n", [_content UTF8String]); }
@end
// document-ex.m
#import "document.h"

@interface Document(Ex)
- (void)printAsHtml;
@end

@implementation Document(Ex)
- (void)printAsHtml {
  printf("<h1>%s</h1><p>%s</p>\n", [_title UTF8String], [_content UTF8String]);
}
@end
// main.m
#import "document.h"

int main() {
  Document* doc = [[Document alloc] init];
  doc.title = @"Hello";
  doc.content = @"World";
  [doc printTitle];
  [doc printContent];
  if ([doc respondsToSelector:@selector(printAsHtml)])
    [doc printAsHtml];
  return 0;
}

以上をコンパイルして実行します。

まずカテゴリ無いバージョン。

$ gcc main.m document.m -framework Foundation
main.m:10:10: warning: instance method '-printAsHtml' not found (return type defaults to
      'id') [-Wobjc-method-access]
    [doc printAsHtml];
         ^~~~~~~~~~~
1 warning generated.
$ ./a.out
Hello
World 

printAsHtmlメソッドが無いと怒られますが、問題なく実行できます。

次に、カテゴリ有りバージョンです。

$ gcc main.m document.m document-ex.m -framework Foundation
main.m:10:10: warning: instance method '-printAsHtml' not found (return type defaults to
      'id') [-Wobjc-method-access]
    [doc printAsHtml];
         ^~~~~~~~~~~
1 warning generated.
$ ./a.out
Hello
World
<h1>Hello</h1><p>World</p> 

ちゃんとHTMLでの出力がされています。

以下、ポイントを箇条書きで列挙します。

  • 一番のポイントは、document.h、document.mがそれだけで完結していることです。それら2つのファイルが関知していない、document-ex.hでDocumentクラス本体を拡張できていることが分かるかと思います。
  • document.hでインスタンス変数宣言をしていますが、document.mの@synthesisで自動生成されるので書く必要ないじゃない、と思われるかもしれません。じゃあ何で書いているのかというと、カテゴリからインスタンス変数を参照するに必要だからです。もっと良い実装をするなら、document.hで変数宣言せず、カテゴリ側はプロパティ経由でアクセスするようにすれば良いのですが、今回はカテゴリでインスタンス変数にアクセスできるんだよということを示したかったので、あえてこのようにしました。
  • カテゴリにはインスタンス変数を追加することが出来ません。既存のインスタンス変数には@privateなものについてもアクセスできます。
  • main.mでrespondsToSelectorを使っています。これは、指定のセレクタのメソッドが実装されているかをチェックするNSObjectのメソッドです。カテゴリの実装を含めてコンパイルした場合はメソッドが実装されているので真が返る仕組みです。
  • コンパイル時にwarningが出るのは、document.hにカテゴリで拡張されるメソッドが宣言されてないので、コンパイラがメソッドを見つけられないためです。プロジェクト内でカテゴリを使う場合は、カテゴリ側でちゃんとヘッダを用意して、適宜importすべきだと思います。

さて、カテゴリを利用すればクラスの実装を分割することが出来るということでした。別の見方をすれば、この機能を使えば、既存のクラスの実装に一切手を付けずに既存のクラス自体にメソッドを追加することができるということです。よって、NSObjectやUIViewなど既存のライブラリが提供しているクラスも、クラス本体を拡張できます。面白いですね。

クラス拡張 (class extension)

ここまで、カテゴリ使えばクラスを拡張できるよ、という話をしてきました。次に見ていくのは「クラス拡張」という機能です。「クラス拡張」はカテゴリとは別物のObjectice-Cの一機能です*3

クラス拡張は、無名のカテゴリに似ています。が、別物です。一番の違いは、インスタンス変数を宣言できることと、宣言したメソッドはクラス本体の(=カテゴリ無しの)@implementationで実装しなければならない点です。

と、これだけみると何でこんな変な機能があるのかと思うかもしれませんが、つまるところ何がしたいかというと、ヘッダに公開したくないメソッドや変数を隠したいのです。C++でいうところのpimplイディオム的なものだと考えればよいでしょう。それ以外の用途はほとんど無いと考えて問題ないと思います。

というわけでサンプルコードです。

// counter.h
#import <Foundation/Foundation.h>

@interface Counter : NSObject
- (int)get;
- (void)next;
@end 
// counter.m
#import "counter.h"

@interface Counter () {
  int _count;
}
- (void)increment;
@end

@implementation Counter
- (Counter*)init { _count = 0; return self; }
- (int)get { return _count; }
- (void)next { [self increment]; }
- (void)increment { _count++; }
@end
// main.m
#import "counter.h"

int main() {
  Counter* c = [[Counter alloc] init];
  printf("%d\n", [c get]);
  [c next];
  printf("%d\n", [c get]);
  [c next];
  printf("%d\n", [c get]);
  [c next];
  printf("%d\n", [c get]);
  return 0;
}

コンパイル、実行します。

$ gcc main.m counter.m -framework Foundation
$ ./a.out
0
1
2
3

ただし、今回のケースの場合、クラス拡張を使わない次のような実装でもOKです。

#import "counter.h"

@implementation Counter {
  int _count;
}
- (Counter*)init { _count = 0; return self; }
- (int)get { return _count; }
- (void)next { [self increment]; }
- (void)increment { _count++; }
@end

公式ドキュメントによると、@property宣言を追加してたりしますが*4、実際の所、インスタンス変数やメソッドに関しては、最近のコンパイラの実装では全部@implementation側に書けるので(参考)、本当に@property関係くらいでしかこの機能を使う機会は無いのかもしれません*5

実例編

実際にカテゴリやクラス拡張を使っている実例を色々探してみたので記録しておきます。

Textmate

言わずと知れたMacのエディタですね。

  • AppControllerクラスでファイルの分割のためにカテゴリが使われています。
    • カテゴリの宣言はクラス本体の宣言と同じAppController.hに書かれています。
    • 大多数のインスタンス変数はカテゴリから参照するためか、ヘッダ側に書かれています。

JSONKit

JSON I/Oライブラリっぽいです。

  • JSONKit.hでNSArrayやNSDictionaryなどがカテゴリでバリバリ拡張されています。

Toast

AndroidのToast的なiOSのUIライブラリ。

  • Toast+UIView.mでUIViewをカテゴリで拡張しています。
    • カテゴリで宣言するメソッドはどこかで実装されていればOKです。その特性を利用して、privateなメソッドについて、宣言だけは”〜Private”のような名前のカテゴリで行い、実装は1つの@implementation内に書いてしまうというパターンがこのプロジェクトに限らずちょくちょく見受けられます。

参考文献

*1:別ファイルで

*2:先に述べたとおり、同名の@implementationは複数書けません。なので、もしカテゴリがなかったら、クラスの実装は1つ@implementation内に=1つのファイル内に記述しないといけません。

*3:このエントリーを真面目にまとめようと思ったきっかけがこのクラス拡張です。初めて見た時は唖然としました。

*4:プロパティの再宣言による属性の変更も可能

*5:Xcodeのプリセットのスケルトンコードではインスタンス変数がクラス拡張部分に書かれていたりします。このあたりは好みの問題でしょうかね。