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のプロジェクトではなんかそれっぽい結果がでるようにはなっていますが、全然雑に作っているので、なにか上手く動いていない感じとかありましたら、ぜひ教えてください。
Cのauto
Unix考古学を読んでいて、ちょっと面白い記述を見つけた。
たとえば、Dennis Ritchieのコンパイラは、局所的な自動変数を
auto
という型で定義したり、あるいは変数定義を省略することを許していましたが、型を明確に宣言するという今日のスタイルに変更されました。
- 作者: 藤田昭人
- 出版社/メーカー: KADOKAWA
- 発売日: 2016/04/28
- メディア: 単行本
- この商品を含むブログ (3件) を見る
これだけを読むと、
auto count;
のような宣言が許されていたように思える。
これってもしかして、C++っていう言語が2011年に獲得した機能のことでは……???これ多分1970年代の頭の話なので、MLができたのが1973年で型推論アルゴリズムが1969年とかで*1、その前後にCには型推論があったってことか*2。すごいな!
そんなことはない
検索したら当時のCのマニュアルっぽいものが見つかったので読んでみた*3。
In a declaration inside a function, if a storage class but no type is given, the identifier is assumed to be
int
; if a type but no storage class is indicated, the identifier is assumed to beauto
.
なんのことはない、型を省略するとint
になるのだった。関数宣言が見つからないときとかそうだった気がするので、まあ一貫性がある。
LINTを見直した話
僕は基本的にLINTが大嫌いで、理由は
- いくつかのLINTはあまり上手く実装されていない
- いくつかのよくあるルールは、プログラミング言語の設計者がわざと導入したルールを制限するものである
という二つが主である。(既に使われているLINTにわざわざ文句は言わないし、開発チームで決めたことには従いますが。)一番ばかばかしいと思うルールの一つに、
- リストの最後のカンマを入れるか入れないか
がある。これなんかは、設計者がわざわざ導入した自由を逆に制限するものであり、しかも見た目が揃う以外の利点がなにもないルールなので、こういうルールを有効にするのは止めたほうが良いと真顔で発言できる。
で、ずっとLINT嫌いの立場を貫いてきたんだけど、実はLINTって便利じゃないかと思ったことがあるので、書いておきたい。
OCLintのCoveredSwitchStatementsDontNeedDefault
を知って、もしかしたらLINTって便利なものなのかもしれないと、不明を恥じたのだった。これは、Objective-Cプログラムのswitch
が網羅的な場合にdefault
があったら警告するもので、つまり無駄な空のdefault
を書かなくて良くなる効果がある。
switch (enumValue) { case SomeCase: ... break; case AnotherCase: ... break; default: // Unreachable NSAssert(false); break; }
みたいなプログラム、皆さん書きますよね。現時点ではdefault
はいらないんだけど、将来にenumのcaseが増えたことを考えると、default
を書いておいてせめて実行時にはエラーになって欲しい。でもこのdefault
とか明らかに無駄なので、できればソースコードから無くしてしまいたい。
この葛藤から逃れられるのであれば、LINTは使うべきだなあと思ったのでした。
これは、止めて欲しいと思うルールとなにが違うかというと、
- 止めて欲しいのは、ある種のプログラムを書くことを禁止するルール
- 良いと思うのは、コンパイラの賢さが足りないせいで保守的にならざるを得なくてこれまで書けなかったプログラムを、書けるようにするルール
と言える気がする。
なお、OCLintのCoveredSwitchStatementsDontNeedDefault
について言えば、最近のClangはNS_ENUM
とかの新しい構文を使って定義したenumであれば網羅性の検査をしてくれるので、特にOCLintを使う必要はありません。(Cの素朴なenum
を書くのを禁止するルールがないかなーと思って眺めてたら見つけました。)
長い文字列をNSDecimalNumberに変換するとおしりが0になる
なにかの金額を入力する画面などで、ユーザーに数字を入力してもらうUIを作ることがある。金額なので、NSDecimalNumber
で受けることになる。このとき大きな数字を入力すると、入力とは違う値になることがある。*1
NSDecimalNumber(string: "999999999999999999999999999999999999999").stringValue // => "999999999999999999999999999999999999990"
これよりも長い文字列を入力すると、0
が続く。
NSDecimalNumber(string: "9999999999999999999999999999999999999999").stringValue // => "9999999999999999999999999999999999999900"
これは、NSDecimalNumber
の内部表現による制限である。NSDecimalNumber
は、128bitの符合なし整数と32bitの符合つき整数の組で表現されている。(下の定義では_mantissa
と_exponent
。)
struct NSDecimal { var _exponent: Int32 var _length: UInt32 var _isNegative: UInt32 var _isCompact: UInt32 var _reserved: UInt32 var _mantissa: (UInt16, UInt16, UInt16, UInt16, UInt16, UInt16, UInt16, UInt16) init() init(_exponent _exponent: Int32, _length _length: UInt32, _isNegative _isNegative: UInt32, _isCompact _isCompact: UInt32, _reserved _reserved: UInt32, _mantissa _mantissa: (UInt16, UInt16, UInt16, UInt16, UInt16, UInt16, UInt16, UInt16)) }
例えば、123.45であれば、12345 * 10^-2として、12345と-2の組で表現する。*2
つまり、_mantissa
に対応する値は、128bitで表現できる範囲でなくてはならない。
Rubyで試してみると999999999999999999999999999999999999999
を表現するには130bit必要とのことなので、128bitでは足りずに切り捨てられたということがわかる。
> 999999999999999999999999999999999999999.bit_length => 130 > 99999999999999999999999999999999999999.bit_length => 127
つまり、999999999999999999999999999999999999990
というのは、
_mantissa
=9999999999999999999999999999999999999
_exponent
=1
になっているはず。
NSDecimalNumber(floatLiteral:)は使ってはいけない
私がSwiftで気に入っている改善として、NSDecimalNumber
のインスタンスがちょう簡単に作れることがあります。
let d : NSDecimalNumber = 15.97
すばらしい。Objective Cだとこうなって、辛い。
NSDecimalNumber *d = [NSDecimalNumber decimalNumberWithString:@"15.97"];
このSwiftの機能はFloatLiteralConvertible
というプロトコルを使って実装されていて、floatのリテラルの型がFloatLiteralConvertible
なクラス(構造体もできる?)のときに、適当なイニシャライザを呼んで変換してくれるというものです。
init(floatLiteral: Double)
わーかっこいい。かっこいいのですが、ところがしかし型をよく見ると一回Doubleが作られています。NSDecimalNumber
をDouble
から作るのは安全なのかと聞かれて、一瞬悩んでしまったのでした。とりあえず、NSDecimalNumber
について言えば明らかにダメです。
let d : NSDecimalNumber = 15.97 _ = d.stringValue // => "15.970000000000004096"
15.97とは似ても似つかない値になりました。Stringから作る場合と見比べれば明らかですね。
let d = NSDecimalNumber(string: "15.97") _ = d.stringValue // => "15.97"
やはり、NSDecimalNumber
を作るときには、整数か文字列を与えるのが安全なようです。
さて、これはなぜでしょう?「浮動小数点はそんなもんだよ」と言われて、一瞬納得してしまいそうになったのですが、やっぱりよく考えた結果、これはNSDecimalNumberが壊れているというのが私の現時点の理解です。
浮動小数点に関しては、僕は全然経験がないので、ここから先まったく間違ったことを言うかもしれません。 自信はありません。 間違っていたら、教えてもらえると嬉しいです。
Floatのリテラルとは
0.1
を10回足しても1.0
にならないことはよく知られていると思います。
let a = 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 _ = (a as NSNumber).stringValue // => "0.9999999999999999"
ソースコードに0.1
と書いても、それは実数の0.1ではありません。Double
で表現できる中で一番実数の0.1に近い数です。この辺りの感覚があると、15.97
が15.970000000000004096になると言われても納得しても良さそうな気がする感じもします。
ところが、0.1
を一度Double
にしてから文字列表現に直すと、"0.1"
が得られます。0.1ではないのに。少し不思議な気がしますね。これは、Double
を表現する10進数の中に0.1よりも近いものが他にないことが理由です(多分)。Floatから文字列への変換をどういうアルゴリズムで実装するかによって違いが生まれますが、SwiftとかNSNumber
ではそうなっているようです。Swiftで試すと、0.1
に対応するFloatの文字列表現は"0.1"
ですし、15.97
に対応するFloatの文字列表現は"15.97"
です。
FloatをNSDecimalNumber
に変換するというのは10進数に変換するのと同じことですから、NSDecimalNumber
にDouble
を与えるとおかしくなるというのは辻褄が合わない話に思えます。
もちろん、変な数を考えるとうまく行かなくなることはあります。
let x = 0.09999999999999986 let y = 0.09999999999999987 _ x == y // => true
ここで、x
とy
は区別できないので、文字列表現も元には戻りません。
_ = (x as NSNumber).stringValue // => "0.09999999999999987" _ = (y as NSNumber).stringValue // => "0.09999999999999987"
まあ仕方ないですね。NSDecimalNumber
にすればこの二つは区別できるようになります。
let x = NSDecimalNumber(string: "0.09999999999999986") let y = NSDecimalNumber(string: "0.09999999999999987") _ = x.stringValue // => "0.09999999999999986" _ = y.stringValue // => "0.09999999999999987"
15.970000000000004096
それでは、15.970000000000004096とはなんなのでしょうか。15.97
は15.970000000000004096だったのでしょうか。
let s = 15.97 let t = 15.970000000000004096 _ = s == t // => false
もちろん違います。この二つは別々の数として認識されています。15.97
と15.970000000000004096
の間の数すら、Double
は表現できます。
let u = (s + t)/2 _ = s < u && u < t // => true
15.970000000000004096は、意味がわからない謎の数字なのです。同じ例は、0.09999999999999986
でも見ることができます。
let x : NSDecimalNumber = 0.09999999999999986 _ = x.stringValue // => "0.09999999999999983616"
0.09999999999999983616はどこから出てきたのでしょうか。ちなみに0.09999999999999986
と0.09999999999999983616
の間には0.09999999999999985
があります。
正しい実装の例
さて、Double
をNSDecimalNumber
に変換する正しいコードは次のようになります。この関数にFloatの15.97
を与えると15.97を指すNSDecimalNumber
が得られます。0.09999999999999986
と0.09999999999999987
のような場合に注意は必要ですが、それは仕方ないでしょう。
ちょっと難しいかもしれませんが、がんばって読んでみてください。
func decimalNumberWithDouble(double: Double) -> NSDecimalNumber { let number = double as NSNumber return NSDecimalNumber(string: number.stringValue) }
参考にしたもの
直接この問題について理解するというよりは、ぼんやり眺めて浮動小数点の周りの空気がわかった気がした。
特定のMacでビルドできないXcodeプロジェクト
Xcodeに登録されているファイルの名前とファイルシステム上の名前が大文字小文字で違っている場合、Apple Storeから買ってきたばかりのあなたのMacではビルドできるかもしれませんが、Case Sensitiveなファイルシステムに設定しているMacではビルドできません。
この画像でUBTypedJSONDictionary.h
となっているソースコードは、ファイルシステム上はUBTypedJsonDictionary.h
でした。なんでこうなったかというと、コードレビューで「NSJSONSerialization
があるのだからここはJSON
であるべき」と指摘されて、なるほどと思ってXcodeでRenameしたらファイルシステムがついてこなかったからです。
注意しましょう。
注意したくない人は、Xcodeプロジェクトに登録されたソースコードをスキャンして、Case Sensitiveなファイルシステムで問題になりそうな場合に警告するプログラムを作ったので、CIで実行したら良いと思います。
CI完了を待ってPRをマージするbotを作るためのライブラリを公開しました
CIを待って自動でPRをマージするbotを作ると便利だった - soutaroブログで、紹介した、CIが完了したときに自動でPRをマージするbotですが、皆さんが簡単に実装できるようnpm packageにしました。
動くと良いですね。(npm packageはまだ試してない。)