Objective Cでnilと向き合う

最近のObjective Cでは、クラスやプロトコルの定義をするときに、メソッドの引数・返り値やプロパティがnilになるかどうかを書くことができるようになっています。

@interface Person : NSObject

@property (nonatomic, nonnull) NSString *name;
@property (nonatomic, nullable) NSString *address;

@property (nonatomic, nonnull, readonly) NSString *displayName;

+ (nonnull instancetype)personWithName:(nonnull NSString *)name;

@end

namenilになりませんが、addressnilのことがありますし、personWithName:を呼ぶときにはnamenilを渡してはいけませんし、返り値はnilになりません。Nullabilityのアノテーションがついていれば、マニュアルを見たりソースコードを見たりしなくても、これだけのことがわかります。便利ですね。

このNullabilityの注釈が特に重要なのはSwiftとの相互互換性を考えたときですが、それはひとまず置いておきます。今日の話題は、この情報がObjective Cの中でほとんどまったく活用されないことです。

Person *person = [Person personWithName:@"Taro"];

person.name = nil;  // ここは警告が出る

NSString *name = nil;
person.name = name;  // ここは出ない

メソッド呼び出しの引数にあからさまにnilを渡した場合には警告が出ますが、一度変数に代入すると素通ししてしまいます。変数にNullabilityを書いてみるとどうでしょうか。

NSString * _Nullable name = nil;
person.name = name;

これでも警告は出ません。Nullabilityの注釈は単純に無視されます。メソッドの返り値も警告は出ません。

- (nonnull NSString *)displayName {
  return nil;  // 明らかに nonnull ではない……
}

これでは既存のクラスをnonnullにしようと思いたったとしても、作業が困難です。雑にnonnullを付けて回ると、nilが返るのにnonnullと主張する嘘つきメソッドが量産されることになります*1。せっかくNullabilityが書けるようになっているのに、これでは役に立たないと言っても過言ではないでしょう。

この問題をなんとかするためのツールを作りました。

github.com

インストールと使い方

Homebrew (brew tap)になっているので、適当にインストールしてください。

$ brew tap soutaro/nullarihyon
$ brew install nullarihyon

Clangとかダウンロードはじめるので不穏な感じがするかもしれませんが、Nullarihyonだけしか見ないところにコピーするので既存のなにかを壊すことはないはずですし、バイナリ配布を使うのでインストールが死ぬほど遅いということもありません。

インストールしたら、Xcodeプロジェクトのビルド設定を変更します。Build Phaseのコンパイルの後の段階に、新しくRun Script Phaseを追加してください。

if which nullarihyon >/dev/null; then
  nullarihyon xcode
fi

これでプロジェクトをビルドすれば、ソースコード中のNullabilityが間違っているところを全部見つけて、警告としてXcodeに表示します。

  • 毎回すべてのソースコードを検査すると時間がかかるので、変更されたファイル(今回のビルドでコンパイルされたファイル)のみを検査します
  • 上の事情と、Frameworkとかいろいろ面倒を見ないといけないものがあるので、コンパイルよりも後の段階で実行しないといけません
  • ARMだと上手く動かないので、iOSプロジェクトの場合はシミュレータを対象にビルドしてください

検査の結果をフィルタリングする

ソースコードの歴史の長さとか量にもよりますが、実行してみると何千件と警告が出ると思います*2。これは多すぎて修正するのが大変、というかふつーに心が折れると思いますので、検査の結果をフィルタリングできるようになっています。.xcodeprojと同じディレクトリにnullfilterというファイルを作って、そこにクラス名を書いてください。

$ cat nullfilter
Person
PersonViewController # コメントが書ける
# PersonAddressViewController

これでPersonPersonViewController以外に関するの警告は表示されなくなります。「〜に関する警告」とはなんなのかという話ですが、

  1. クラスの実装で発見された警告
  2. クラスが定義しているメソッドの引数に関する警告
  3. クラスが定義しているメソッドの返り値に関する警告

です。

ワークフロー

具体的な作業の方針としては、次のようになると思います。

  1. どのクラスを直すか決める
  2. nullfilterを作る
  3. NS_ASSUME_NONNULL_BEGIN / NS_ASSUME_NONNULL_ENDを、直すクラスの.h.mに書く
  4. 警告がなくなるまで、修正→検査を繰り返す

一度に出てくる警告を数十個に減らせれば、まー少し気合いを入れれば直せます。

検査の内容

簡単に言えば、nonnullな変数にnonnullでない式を代入しようとすると警告になります。派生で、

  • メソッドの引数も検査
  • returnする返り値も検査

しています。ブロックの型の引数・返り値の型も検査しているので、void(^)(NSString * _Nonnull)^(NSString * _Nullable x){}を渡そうとすると警告が出ます。賢い!

あまりに自明なfalse positiveとか、まー常識的に考えてそこは気にしないみたいなものは、できるだけ見なかったことにしたいので、

  • Nullabilityの指定がないローカル変数の宣言に初期値がある場合は、初期値のNullabilityを使う
  • ifの条件から明らかなものはthen節ではnonnullにする
  • selfnonnullだと思う
  • ループの変数はnonnullだと思う
  • allocinitclassの返り値は、Nullablityの指定がない限りnonnullだと思う

などしています。あと、どうしても明示的なキャストを書くのが増えるので、そこで間違えないようにするというのも注意しています。うっかり間違えてキャストを書かないように「_Nonnullな型に変更するキャストはクラスを変える場合警告。ただしidからのキャストは例外」などしていますし、逆に作業途中で書いたキャストがいらなくなることもあるので「_Nonnullから_Nonnullのクラスを変更しないキャストは警告」などもしています。もりもりキャスト書いてok。

これを使うべきなの?

使ってみるとわかるのですが、基本的にとても費用対効果が悪いのでなかなか微妙なところです。多分あなたが検査している対象のプログラムは今動いているものなので、型システム的には間違いがあっても、その範疇を超えたところで大体上手く動いているんですよね……そこで「isEqualToString:の引数はnonnullだから!」とか得意げに言われても、実はあまり嬉しくなかったりします。もちろん、Swiftで言うところのStringString?を混同して使っていると言うことなので、まずいのは確かなのですが。

今はかなり厳格に検査しているので(Nullabilityが書かれていないものはnullableになるのでいっぱい警告が出る)、もう少しマイルドなもの、例えば

  • Nullabilityが書かれているものだけ検査する
  • 組み込みライブラリに関する警告は無視する

とかした方が実用的なのかもしれません。

とりあえず私の手元にあるObjective Cのプロジェクトではなんかそれっぽい結果がでるようにはなっていますが、全然雑に作っているので、なにか上手く動いていない感じとかありましたら、ぜひ教えてください。

*1:実際の経験

*2:ユビレジの場合の例をいうと、500個ソースコードがあって2500個警告が出ました