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
name
はnil
になりませんが、address
はnil
のことがありますし、personWithName:
を呼ぶときにはname
にnil
を渡してはいけませんし、返り値は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が書けるようになっているのに、これでは役に立たないと言っても過言ではないでしょう。
この問題をなんとかするためのツールを作りました。
インストールと使い方
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
これでPerson
とPersonViewController
以外に関するの警告は表示されなくなります。「〜に関する警告」とはなんなのかという話ですが、
- クラスの実装で発見された警告
- クラスが定義しているメソッドの引数に関する警告
- クラスが定義しているメソッドの返り値に関する警告
です。
ワークフロー
具体的な作業の方針としては、次のようになると思います。
- どのクラスを直すか決める
- nullfilterを作る
NS_ASSUME_NONNULL_BEGIN
/NS_ASSUME_NONNULL_END
を、直すクラスの.h
と.m
に書く- 警告がなくなるまで、修正→検査を繰り返す
一度に出てくる警告を数十個に減らせれば、まー少し気合いを入れれば直せます。
検査の内容
簡単に言えば、nonnull
な変数にnonnull
でない式を代入しようとすると警告になります。派生で、
- メソッドの引数も検査
return
する返り値も検査
しています。ブロックの型の引数・返り値の型も検査しているので、void(^)(NSString * _Nonnull)
に^(NSString * _Nullable x){}
を渡そうとすると警告が出ます。賢い!
あまりに自明なfalse positiveとか、まー常識的に考えてそこは気にしないみたいなものは、できるだけ見なかったことにしたいので、
- Nullabilityの指定がないローカル変数の宣言に初期値がある場合は、初期値のNullabilityを使う
if
の条件から明らかなものはthen節ではnonnull
にするself
はnonnull
だと思う- ループの変数は
nonnull
だと思う alloc
、init
、class
の返り値は、Nullablityの指定がない限りnonnull
だと思う
などしています。あと、どうしても明示的なキャストを書くのが増えるので、そこで間違えないようにするというのも注意しています。うっかり間違えてキャストを書かないように「_Nonnull
な型に変更するキャストはクラスを変える場合警告。ただしid
からのキャストは例外」などしていますし、逆に作業途中で書いたキャストがいらなくなることもあるので「_Nonnull
から_Nonnull
のクラスを変更しないキャストは警告」などもしています。もりもりキャスト書いてok。
これを使うべきなの?
使ってみるとわかるのですが、基本的にとても費用対効果が悪いのでなかなか微妙なところです。多分あなたが検査している対象のプログラムは今動いているものなので、型システム的には間違いがあっても、その範疇を超えたところで大体上手く動いているんですよね……そこで「isEqualToString:
の引数はnonnull
だから!」とか得意げに言われても、実はあまり嬉しくなかったりします。もちろん、Swiftで言うところのString
とString?
を混同して使っていると言うことなので、まずいのは確かなのですが。
今はかなり厳格に検査しているので(Nullabilityが書かれていないものはnullable
になるのでいっぱい警告が出る)、もう少しマイルドなもの、例えば
- Nullabilityが書かれているものだけ検査する
- 組み込みライブラリに関する警告は無視する
とかした方が実用的なのかもしれません。
とりあえず私の手元にあるObjective Cのプロジェクトではなんかそれっぽい結果がでるようにはなっていますが、全然雑に作っているので、なにか上手く動いていない感じとかありましたら、ぜひ教えてください。