Rubyでnilとfalseを区別する方法が他にもある話
他にもいろいろあるという話をTwitterで聞きました。下のツイートから大体見られるんじゃないかな。
なんとかKaigiに合わせて、と思いきや全然関係ない内容でRubyの記事を書きました。 #はてなブログ
— 松本宗太郎 (@soutaro) May 30, 2018
Rubyでnilとfalseを区別する方法 - soutaroブログhttps://t.co/o9DYnwwknl
TracePointで私のアイディアを騙すとか、引数の評価に入れるとTracePointを回避できるとか、 $@
に false
を代入すると例外が上がるとか、 false..nil
にして確実に例外が上がるようにするとか、いろいろな方法があることがわかりました。Rubyの勉強になりますね。
今の時点で決定版かなーと思っているのは
rangeリテラル、trunkでも動くように直してTracePoint対策するとこういう感じですかね
— Ⓜ️ (@hanachin_) May 30, 2018
def nilp(v, _=(return (not v&.(nil..false)) rescue return false))
end
ですかねぇ。(わかりませんが。)
一つだけわかったこととしては、「Ruby 2.3より前では nil
と false
が区別できなかった」というのは間違いかも、というところで、一筋縄ではいきませんでしたが2.3以降でやるときと同程度の仮定で nil
と false
を区別できていたような気がします。
ちなみに、「 is_nil?
を呼び出す方法がない」というのがオチのつもりでした。(誰かに上書きされないことが保証できないので。)
Rubyでnilとfalseを区別する方法
補足があります。 Rubyでnilとfalseを区別する方法 - soutaroブログ
Rubyでは、ついこの間の2.3のリリースまで、 nil
と false
を区別する方法がありませんでした。
「 nil
か false
」とそれ以外を区別することはできます。 if
とか unless
でも良いですし、 &&
でも良いです。Rubyの構文の中には、真理値に応じてなんらかの処理をしたりしなかったりするものがありますので、「偽」である「 nil
または false
」と、「真」である「それ以外の値」は区別できるのです。が、「偽」同士の nil
と false
は区別することができませんでした。
と、進めると、Ruby初心者の皆さんは混乱するかもしれませんね。 nil?
があるじゃないかと。 x == nil
でいいじゃないかと。しかし、こいつらはメソッドでありユーザーが自由に再定義できるので、一般的な話をするときには、信頼してはいけません。「 nil?
で nil
かどうかをテストできる」と言うためには「ただし nil?
は再定義されていないものとする」と前提条件を示す必要があります。
# x.nil? を殺す def nil.nil? false end # x == nil を殺す def nil.==(other) false end # nil.object_id を殺す def nil.object_id false.__id__ end # nil.class == NilClass を殺す def nil.class ::FalseClass end
さて、それではどうするかというと、Safe Navigation Operatorを使います。 &.
を使うと、レシーバが nil
のときのみメソッドが呼ばれません。つまり、 false
と nil
を区別することができるようになったのです。さらに、一昨日くらいに気づいたのですが、 nil
のときには引数の評価もされません。なんて便利!
こんな感じでどうでしょうか。
def is_nil?(value) result = true value&.__id__(result = false) result rescue result end
&.
を使うという方法には3年くらい前に気づいていたのですが、引数が評価されたりされなかったりすることには気づいていなかったため、メソッド呼び出しがあったかどうかをメソッドの定義に依存する形でしか判定できず、特定のメソッドの定義を前提とするのがちょっとずるいなあって思ってずっと眠らせていたTipsでした。一昨日くらいに気づいたブレイクスルーとして、これは引数の評価だけ見るので、メソッドがあってもなくてもかまわないので、前提が十分に少なくなったと言えると考えています。
他に方法があったり、上の is_nil?
を騙す方法を思いついた方は、ぜひ教えてください。
型付き ⊄ 型なし
最近、TypeScriptについて考えることが多い。SideCIでWebフロントエンドの開発に使いはじめたこともあるし、Steepの開発をしていて「TypeScriptだとどうなるんだっけ??」などと言いながら試してみることもある。
TypeScriptは型付きのJavaScriptである。構文はほとんど同じで、使えるライブラリもかなり近い。JavaScriptへの変換はかなり自明で、ランタイムは全く同じ。性能の差はない。Webpackやnpmを初めとするツール群もかなり共通しているし、アプリケーションも似たようなもん。書いている気分には、ほとんど差がない。つまり、TypeScriptとJavaScriptでプログラミングしているときに、なにか違いを感じるとすれば、それは(ほとんど)型付きの言語と型なしの言語の差と考えて良い。
RubyとJavaを比較するのとは、全然話が違う。構文も意味も違うし、ランタイムも違うし、使えるライブラリも違う。当然アプリケーションも違う。RubyとJavaを比較して、型付き言語と型なし言語の差を感じ取るのは、かなり難しい。
型なしのプログラミング言語に型検査器を入れると、型付きのプログラミング言語になる。逆の方がわかりやすいかな……型付きのプログラミング言語の型検査器を常にacceptするように変更すると、型なしのプログラミング言語ができる。さて、型検査器は、型なしの言語で書かれたプログラムの一部をrejectするものなのだから、型なしのプログラミング言語でプログラムとして受け付けられるものの集合は、型付き言語のそれよりも大きい集合になる。自明だ。
TypeScriptの型検査器をハックして常にacceptするようにすれば、JavaScriptと同じことができる。*1TypeScriptは、JavaScriptプログラムのうちの一部を型エラーが含まれるとしてrejectするものだから、TypeScriptで書けるプログラムの集合は、JavaScriptで書けるプログラムの集合の部分集合になる。TypeScriptプログラムは常にJavaScriptプログラムになるが、JavaScriptプログラムの中にはTypeScriptプログラムでないものがいる。自明だ。
ところがここに「プロダクションにデプロイするプログラム」と注釈をつけると、この部分集合の関係は必ずしも成り立たないことを最近実感している。型検査器を前提にしたときに書けるプログラムの一部は型検査器なしでは怖くて書けないし、型検査器を前提にしたときにできるプログラミングのうちには型検査器なしでは現実的でない行為がある。
代数的データ構造を例として挙げよう。代数的データ構造自体は、型検査器の存在を前提としない。JavaScriptでも書ける。
type ErrorResult = { id: "ErrorResult", code: number } type SuccessResult = { id: "SuccessResult", body: any } type HttpResult = ErrorResult | SuccessResult const errorResult = { id: "ErrorResult", code: 404 } const successResult = { id: "SuccessResult", body: [1,2,3] } function processResult(result: HttpResult): string { switch (result.id) { case "ErrorResult": return "Error" case "SuccessResult": return "Success" } }
別にJavaScriptでも書ける。
const errorResult = { id: "ErrorResult", code: 404 } const successResult = { id: "SuccessResult", body: [1,2,3] } function processResult(result) { switch (result.id) { case "ErrorResult": return "Error" case "SuccessResult": return "Success" } }
確かに書けるんだけど、まあJavaScriptでこんなコードを書くのは、結構肝が据わっていないとできないことだと思う。どう考えても問題があるコードで、
code
とbody
を間違えたら困る- 将来的に新しい結果の種類を増やしたいときに、全ての
switch
を忘れずに更新できるかどうかの賭けはしたくない
などがすぐに思いつくはずだ。
一方、このプログラムはTypeScriptでは問題がない。
code
とかbody
とかを間違えても型検査が見つけてくれる- 新しい型を増やしたときにも
switch
の分岐が網羅的か、型検査器がチェックしてくれる
プロダクションに自信を持って送り込めるコードになった。つまり、この前提では、型検査器によって書けるコードが増えている。
こんなことを考えるようになったのは、やっぱりTypeScriptを書いているからだと思う。型検査の有無しか違わない二つの言語で、プログラミングの心構えが変わるのは、型検査の有無が原因と考えるのが妥当だろう。Objective Cを書いていたときには、コンパイラのバージョンが上がって機能が増えたことがけっこうあったが、「機能が増えたから書けるプログラムが増えるのは当然」としか思わなかった。大体の新機能は制約を緩めるために導入されるものなので、書けるプログラムが増えるのはやっぱり当然である。
「ある種の検査器を導入すると、原理的には書けるプログラムの集合は小さくなるはずなのに、逆にこれまでは(現実的に)書けなかった種類のプログラムが書けるようになる」という体験は、面白いと思う。
*1:全部any型を付ければ大体なる。
Type Checking Ruby Programs with Anntations
RubyKaigi 2017で話す内容です。
当日使ったスライドを公開します。
Type Checking Ruby Programs with Annotations // Speaker Deck
多すぎて飛ばした分も入っています。全体の内容としては、この記事を読めばほとんどそれで良いんじゃないかと思います。
さて、まずは「Rubyプログラムを型検査する」とはどういうことなのかを考えましょう。ご存じの通り、Rubyというのはすでに型安全な言語です。この場合に、型検査をしてなにをしたいのかは、いまいち自明ではありません。
型安全性とは「型検査をパスしたプログラムを実行しても、未定義の状態にならない」という性質です。Rubyのような型なしの言語を考える場合には「常に成功する型検査をする」と考えます。Rubyは、Cなどと違って、実行中に未定義の状態になることはありません。配列の範囲外にアクセスすれば
nil
になることが定義されていますし、存在しないインスタンス変数にアクセスしたときにもnil
が得られることが定義されています。メソッドがなければNoMethodError
が発生することも定義されています。
自明ではないので、適当に都合が良さそうなものを定義しましょう。こんな感じでどうでしょうか。
- 型検査をパスしたRubyプログラムを実行しても、
NoMethodError
とArgumentError
が発生しないこと
この性質を「Rubyプログラムの型検査の健全性」と言うことにして、型検査ツールの開発ではこれを目指すことにします。
実際には、
raise NoMethodError
とかプログラム中に書くこともできるので、この定義には問題がありますが、まー
"".map {|x| ... } 1 + "3"
みたいな明らかに型検査ではじいてしまいたいようなプログラムは上手く取り扱えるので良いことにしましょう。
Rubyプログラムを型検査したいと思った人たち
Rubyプログラムを型検査したいというのは、最近になって考え出された新しいアイディアではなくて、少なくとも10年くらい前からは考えている人がいます。とりあえず、FurrとMatsumotoの論文があります。
- 2007, S. Matsumoto and Y. Minamide. Type Inference for Ruby Programs based on Polymorphic Record Types
- 2008, M. Furr, J. hoon (David) An, J. S. Foster, and M. Hicks. Static Type Inference for Ruby
Matsumotoというのは私です。Furrはちょっとどういう人なのかわからなかったのですが、多分Fosterさんのところにいた学生だと思います。Fosterさんはまた後で出てくるので覚えておいてください。
この人たちは、Rubyプログラムを型検査しようと思っていたのですが、Rubyというのは型なしのプログラミング言語なので、ソースコード中には型が書いてありません。なんとかするために「型推論」と呼ばれるテクノロジーで立ち向かいました。そして負けました。(火曜日にはもう少し説明するつもりです。)
なぜ負けたのでしょうか?どうすれば勝てるのでしょうか?
ここまでの話は、「型検査」について次のようなゴールを暗黙に仮定していました。
これで負けたので、勝てるようなゲームに前提条件を変えるのが良いでしょう。それぞれの条件を緩めて、
- 正しくなくても良いことにする
- 実行しながら型検査しても良いことにする
- 既存のRubyプログラムに型を書いてから検査して良いことにする
というのを考えてみましょう。
正しくなくても良いかもしれない
「型検査は健全であって欲しい」というのが、全ての型検査器を開発する人間の思いですが、別に「健全でないと何の役にも立たない」ということもありません。
「健全ではないけどなんとなく便利に使える」というなんらかのツールを作る方法があります。こういうツールは、
- TypeScriptの様な「だいたい健全だけど現実のために一部を諦めた」ものから
- RuboCopの様な「雰囲気でルールを追加していく」もの
まで、まーいろいろと考えられると思います。
個人的には、あんまり興味がありません。Rubyでこれを始めると、多分なにもわからなくなって結局 -cw
になるか、それ以上を頑張ろうとするとRuboCopになると思います。
実行しながら型検査しても良いことにする
「実行せずに型検査しようとするから難しくなるのであって、実行しながら型検査すれば良いのではないか」というアイディアです。さっき出てきたFosterさんのチームは、実行せずに型検査する戦略にさっさと見切りを付けて、ずっとこれをやっています。
- 2016, B. M. Ren and J. S. Foster. Just-in-Time Static Type Checking for Dynamic Languages
POPLとかPLDIとか、超有名な国際会議で発表されている話で、とてもすごい。
def foo(x) "".bar if x end
素のRubyはあるメソッドを呼び出した瞬間に初めてチェックが行われて、そのメソッドがなかったら NoMethodError
を上げるという処理を行います。これだと例えば上のプログラムを foo false
としたときには、エラーにはなりません。これをそのちょっと前、具体的には foo
が呼び出された瞬間に検査することにするという話だと思います。(すみません。全然きちんと読んでいません。)
そうすると
という嬉しさがあります。嬉しくなさとしては、
- 実行時の環境に依存するので、ユニットテストとかで変なライブラリが追加されていたりすると変なことが起きるのでは??
と思いますが、まー現実的にはこれは問題にならないとは思います。
既存のRubyプログラムに型を書いてから検査して良いことにする
ここからが本題です。
Steep
型を書いて検査するツールを作りました。
READMEにはウソしか書いてないので、注意してください。(今から直します。)
RubyGemにはなっているので
$ gem install steep --pre
とするとインストールできます。
さっそく型検査をしてみましょう。Steepでプログラミングするには、まず型定義が必要です。適当なファイル hello.rbi
などに、次のように書いてください。
class Conference
def initialize:(name: String, year: Integer) -> any
def name: -> String
def year: -> Integer
end
def initialize:
の後に:
が必要なのが抜けていたのを直しました。
Conference
というクラスを定義して、name
とyear
というメソッドがあることがわかります。
これを実装する前に、このクラスを使うプログラムを書いてみましょう。適当に test.rb
などとファイルを作ってください。
# @type const Conference: Conference.module
# @type var year: Integer
conference = Conference.new(name: :RubyKaigi, year: 2017)
year = conference.name
型注釈は二つです。Conference
という定数とyear
というローカル変数の型を宣言しています。定数の注釈が必要なところが特徴です。このプログラムには(わざと)エラーが含まれていて、Conference.new
でname
にSymbol
を指定しているところと、Integer
のはずのyear
に文字列を代入しようとしているところがエラーです。
$ steep check -I hello.rbi test.rb
test.rb:4:13: ArgumentTypeMismatch: type=Conference.module, method=new
Conference.new
の引数の型が合わないというエラーが見つかりました。しかし、year
の代入ではエラーになっていません。これは、conference
の型がany
になってしまっているからです。conference
の型アノテーションはないので、右辺から推論しようとします。ところが右辺の型は全くわからないままだったので、any
になってしまったというわけです。
any
というのは型なしの言語をシミュレーションするための型で、要するに「なんでもあり」です。なんでもありなので、Integer
に変換することもできてしまいます。:RubyKaigi
を"``RubyKaigi``"
に直すとどうなるでしょう。
$ steep check -I hello.rbi test.rb
test.rb:5:0: IncompatibleAssignment: lhs_type=Integer, rhs_type=String
今度はちゃんと代入でエラーが見つかりました。この辺りはローカル型推論とかGradual Typing呼ばれる話と大体一致していると思います。
クラスの実装はこんな感じになります。
class Conference
# @implements Conference
attr_reader :name
attr_reader :year
def initialize(name:, year:)
@name = name
@year = year
end
end
@implements
というのが新しい注釈で、このクラスがシグネチャで与えられたクラスを実装していることを示します。これがあると、
などの処理をします。検査してみましょう。
$ steep check -I hello.rbi hello.rb
hello.rb:1:0: MethodDefinitionMissing: module=Conference, method=name
hello.rb:1:0: MethodDefinitionMissing: module=Conference, method=year
name
とyear
のメソッド定義がないと怒られてしまいました。Steepはattr_reader
を理解しないのでこういうことがおきます。このためには@dynamic
という注釈が用意しています。これを使いましょう。
# @dynamic name
attr_reader :name
# @dynamic year
attr_reader year
これで検査ができました。
これだけではつまらないので、一つメソッドを追加します。
class Conference
def initialize: (name: String, year: Integer) -> any
def name: -> String
def year: -> Integer
def succ: -> instance
end
ここの
initialize
も:
が抜けていた……
succ
メソッドで、次の年のカンファレンスを返すようにしてみましょう。 instance
というのは、このクラスのインスタンスの型です。この場合はConference
と書いても良いのですがself
を返すようなメソッドだと、意味が違ってきます。
class Conference
...
def succ
Conference.new(name: name, year: year+1)
end
end
検査します。
$ steep check -I hello.rbi hello.rb
$
上手く検査できたように見えますが、本当でしょうか?全部の式の型を見るために --dump-all-types
というオプションも用意してありますが、ここでは--fallback-any-is-error
を使いましょう。
Steepのany
には、二つの意味があります。
- なんでも良い型を示す
- 上手く型を付けられなかったときに先に進めるために使う
後者は、どちらかと言えば型エラーに近いはずなので、表示したい気持ちにもなります。
$ steep check --fallback-any-is-error -I hello.rbi hello.rb
hello.rb:15:4: FallbackAny
見つかりました。これがなにかというと、Conference
という定数の参照でした。Conference
がここでなんの値になるのかは、プログラム全体を実行してみないとわからないので、Steepにはなんとも言えません。もちろん私たちはこれがConference
クラスを表す定数であると信じていますので、注釈を追加して対応します。
class Conference
...
# @type const Conference: Conference.module
...
end
これで型検査ができました。
工夫
Structural SubtypingとかLocal Type Inferenceの話は省略します。まーいいでしょう。
工夫は、
- 型と実装は全く別のものとして定義して、その二つの関連づけはユーザーの責任に押しつける
という点です。
先に見せたように、Steepは定数とクラスの関連づけすら行いません。これはRubyの性質として、定数になにが代入されているかはプログラム全体を見ないとわからないし、プログラム全体を見ると言うことはどのタイミングで実行されるのかも考える必要があるしで、要するのこの辺が難しいというところです。そして、それを全部プログラマに押しつけることによって解決を図ります。機械にやらせるのは難しいことでも、人間のプログラマーは大体自分でできる程度の知性を持っているので、まーそんなに困らないんじゃないですかね……
メタプログラミングも、上で見たattr_reader
にように、一切を無視します。実際のところ、
あるメソッドを読んだときに、最初の1回はメソッドがないけど2回目ではメソッドが追加されている
みたいなことは希です。希なので、そのクラスの実装を外側から観察したときには、大体型が付けられるはずで、@dynamic
のような注釈を用意しておけばそれなりに上手くやっていけるんじゃ無いかと思います。
この辺りの直感とか
@dynamic
という名前には、Objective Cでの経験が反映されています。
エクステンション
シグネチャには、class
とかmodule
とかを用意してありますが、最後の一つはextension
です。これは、要するにC#とかObjective CとかSwiftのアレだと思ってもらえば良くて、既存のクラスにメソッドを生やすためものです。
Steepのシグネチャではクラスやモジュールは開かれていません。オープンクラス的なものでメソッドを追加するにはextension
を使います。
extension Object (Pathname)
def Pathname: (String) -> Pathname
end
今の実装では、エクステンションは常に有効です。↑のようなPathname
エクステンションを定義したら、常にPathname
が呼べることになります。これは不便なので、選択的に有効にするような方法を用意したいと思っています。
Rubyを直さないとできないこと
Steepは、Ruby処理系とは別のソフトウェアとして実装されていて、注釈はコメントで書くようになっていますが、この先、Rubyを拡張しないとできないことがいくつかあります。
前者はまあ自明だと思うのでおいておいて、後者の話はというと、 Object#is_a?
は継承の関係を見ているだけなのでStructural SubtypingなSteepの意味と違っていて役に立たない、という話です。Structural Subtypingの関係をテストする述語が欲しいのですが、そのためには実行時にメソッド定義の型を全部引っ張って回らないといけないので、けっこう大がかりな改修になるんじゃないかと思います。少なくとも、僕が自分で実装しようとは思わない程度には難しそうです。
その他
Steepの機能としては
- Union Typeがあるので便利!
- メソッドのオーバーローディングがそのまま型として書けるようになっていて便利
interface
というのを定義できるようになっているので、クラスじゃないやつもなんとかするよ!
などあります。
(でもまだ全然実装はいろいろぶっ壊れているので、動かなくても失望しないでください……)
2017年はFigmaである
最近はSketchではなくFigmaを使っています。
何が良いかというと、これはブラウザで動いていて(アプリもあるけどブラウザ版をそのまま動かしている感じ)、一つのワークスペースを何人かでそのまま突っつけるのが良いのです。誰かがなにかを変更すると、それは隣で開いている人にすぐに伝わります。これがものすごく快適なのです。
Sketchに残された問題について以前に書いたことがありますが、それは複数人で一つのファイルを変更することが事実上不可能なことです。diffを取って後でマージするというソフトウェア開発で広く行われている方法は、Sketchには通用しません。(なんとかっていう頑張ってSketchをマージする製品もあるそうですが、結局Figmaで良いという気分になったので使ったことがありません。)どうしてdiffを取ってマージしたいかというと、非同期に変更を共有したいからですが、そこでFigmaは同期的にやってしまうことでこの問題を解決したという。
とは言え、やっぱりいろいろと動作が怪しかったり、機能が足りなかったり、プラグインみたいな仕組みがないのでちょっと手が届かないことがあって辛かったり、そもそもでかいファイルになったときにきちんと動作するのかは(私はまだ経験がないので)わからなかったりするのですが、でもしばらく仕事で使ってみた限りで言えばとても良いです。デザインの共有もURLをコピペしてSlackに貼るだけなので、他の開発者にデザインを送りつけるのも簡単です。すごく良い。
この感覚は、「WordとかExcelではなくて、Google Docsでいいや」っていう感じで、もちろんワープロとか表計算ソフトとしてはWordとかExcelの方が出来は良いのですが、でもみんなで見ながら試行錯誤するような場合にはGoogle Docsの方がはるかに都合が良いという。
もうしばらく使ってみます。
Nullarihyon 1.7
Xcode8にNullability Violationをチェックする機能が入ると聞いて一晩泣き明かしましたが、それはそれとしてちまちま開発を続けています。Xcode8との比較はまたそのうち。
Nullarihyon 1.6では「nonnullなインスタンス変数に代入があるかどうかを確認する機能」を実装しています。
NS_ASSUME_NONNULL_BEGIN @interface Person : NSObject @property (nonatomic) NSString *name; @property (nonatomic) NSString *address; - (instancetype)initWithName:(NSString *)name address:(NSString *)address; @end @implementation Person - (instancetype)initWithName:(NSString *)name address:(NSString *)address { self = [self init]; self.name = name; // addressを設定するのを忘れている return self; } @end NS_ASSUME_NONNULL_END
こういうプログラムがあったときに、警告を出すようになっています。init
的なメソッド全てで検査すればいい気もしますが、ひとまずはオプトインで __attribute__((annotate("nlh_initializer")))
がついたものだけ検査します。
- (instancetype)initWithName:(NSString *)name address:(NSString *)address __attribute__((annotate("nlh_initializer")));
などとつけましょう。
この属性がついたメソッドでは、
- 全てのnonnullなインスタンス変数に何かを代入する
- 別の
__attribute__((annotate("nlh_initializer")))
なメソッドを呼ぶ
のどちらかの条件が成り立っていないといけません。そうでない場合は、nonnullなインスタンス変数が初期化されずに残っているとみなして、警告を出します。
この機能で、Nullarihyonで検査してokなプログラムは(キャストによって確信犯的に導入されたもの除けば)nonnullなメソッド・プロパティがnil
を返すことはないことが保証できるようになったはずです。nonnullをつけたい場合には、
- Nullarihyonをインストール、セットアップする
- 型にnonnullをつけて回る
init
にattributeをつける- コンパイルして、警告をちまちま直す
とすれば、かなり安全に作業が進められます。逆に言うと、この「init
でnonnullなインスタンス変数が初期化されずに残る問題」はかなり厄介で危険なので、Nullarihyonを使わないのであれば気軽にnonnullとか付けるべきではないとも言えます(強気)。
homebrewで簡単に入るので、ぜひおためしください。
1.7の話
1.7では、検査したいクラスのフィルタに正規表現が書けるようになりました。nullfilter
に
/ViewController/
などと書くと、クラス名にViewController
を含むものだけ検査するようになります。
本当は、negationが書きたくて、例えば
!/ViewController/ !/^NS/
などと書いて「ViewControllerとNSなんとか以外のクラスを検査する」としたいのですが、フィルタの意味を考え込んでしまってまだできていません。
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のプロジェクトではなんかそれっぽい結果がでるようにはなっていますが、全然雑に作っているので、なにか上手く動いていない感じとかありましたら、ぜひ教えてください。