Rubyでnilとfalseを区別する方法が他にもある話

soutaro.hatenablog.com

他にもいろいろあるという話をTwitterで聞きました。下のツイートから大体見られるんじゃないかな。

TracePointで私のアイディアを騙すとか、引数の評価に入れるとTracePointを回避できるとか、 $@false を代入すると例外が上がるとか、 false..nil にして確実に例外が上がるようにするとか、いろいろな方法があることがわかりました。Rubyの勉強になりますね。

今の時点で決定版かなーと思っているのは

ですかねぇ。(わかりませんが。)

一つだけわかったこととしては、「Ruby 2.3より前では nilfalse が区別できなかった」というのは間違いかも、というところで、一筋縄ではいきませんでしたが2.3以降でやるときと同程度の仮定で nilfalse を区別できていたような気がします。

ちなみに、「 is_nil? を呼び出す方法がない」というのがオチのつもりでした。(誰かに上書きされないことが保証できないので。)

Rubyでnilとfalseを区別する方法

補足があります。 Rubyでnilとfalseを区別する方法 - soutaroブログ

Rubyでは、ついこの間の2.3のリリースまで、 nilfalse を区別する方法がありませんでした。

nilfalse 」とそれ以外を区別することはできます。 if とか unless でも良いですし、 && でも良いです。Rubyの構文の中には、真理値に応じてなんらかの処理をしたりしなかったりするものがありますので、「偽」である「 nil または false 」と、「真」である「それ以外の値」は区別できるのです。が、「偽」同士の nilfalse は区別することができませんでした。

と、進めると、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 のときのみメソッドが呼ばれません。つまり、 falsenil を区別することができるようになったのです。さらに、一昨日くらいに気づいたのですが、 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でプログラミングしているときに、なにか違いを感じるとすれば、それは(ほとんど)型付きの言語と型なしの言語の差と考えて良い。

RubyJavaを比較するのとは、全然話が違う。構文も意味も違うし、ランタイムも違うし、使えるライブラリも違う。当然アプリケーションも違う。RubyJavaを比較して、型付き言語と型なし言語の差を感じ取るのは、かなり難しい。


型なしのプログラミング言語に型検査器を入れると、型付きのプログラミング言語になる。逆の方がわかりやすいかな……型付きのプログラミング言語の型検査器を常にacceptするように変更すると、型なしのプログラミング言語ができる。さて、型検査器は、型なしの言語で書かれたプログラムの一部をrejectするものなのだから、型なしのプログラミング言語でプログラムとして受け付けられるものの集合は、型付き言語のそれよりも大きい集合になる。自明だ。

TypeScriptの型検査器をハックして常にacceptするようにすれば、JavaScriptと同じことができる。*1TypeScriptは、JavaScriptプログラムのうちの一部を型エラーが含まれるとしてrejectするものだから、TypeScriptで書けるプログラムの集合は、JavaScriptで書けるプログラムの集合の部分集合になる。TypeScriptプログラムは常にJavaScriptプログラムになるが、JavaScriptプログラムの中にはTypeScriptプログラムでないものがいる。自明だ。

ところがここに「プロダクションにデプロイするプログラム」と注釈をつけると、この部分集合の関係は必ずしも成り立たないことを最近実感している。型検査器を前提にしたときに書けるプログラムの一部は型検査器なしでは怖くて書けないし、型検査器を前提にしたときにできるプログラミングのうちには型検査器なしでは現実的でない行為がある。


代数的データ構造を例として挙げよう。代数的データ構造自体は、型検査器の存在を前提としない。JavaScriptでも書ける。

適当にAjax的なリクエストの結果だと思って見てほしい。

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でこんなコードを書くのは、結構肝が据わっていないとできないことだと思う。どう考えても問題があるコードで、

  • codebody を間違えたら困る
  • 将来的に新しい結果の種類を増やしたいときに、全ての 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プログラムを実行しても、 NoMethodErrorArgumentError が発生しないこと

この性質を「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プログラムを実行しないこと
  • 型推論によって既存の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よりは網羅的に検査するので foo false でも問題が検出できる
  • 実行時の検査なので String#bar を追加するようなメタプログラミングにも対応ができる

という嬉しさがあります。嬉しくなさとしては、

  • 実行時の環境に依存するので、ユニットテストとかで変なライブラリが追加されていたりすると変なことが起きるのでは??

と思いますが、まー現実的にはこれは問題にならないとは思います。

既存の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 というクラスを定義して、nameyearというメソッドがあることがわかります。

これを実装する前に、このクラスを使うプログラムを書いてみましょう。適当に test.rb などとファイルを作ってください。

# @type const Conference: Conference.module
# @type var year: Integer

conference = Conference.new(name: :RubyKaigi, year: 2017)
year = conference.name

型注釈は二つです。Conferenceという定数とyearというローカル変数の型を宣言しています。定数の注釈が必要なところが特徴です。このプログラムには(わざと)エラーが含まれていて、Conference.newnameSymbolを指定しているところと、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

nameyearのメソッド定義がないと怒られてしまいました。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を拡張しないとできないことがいくつかあります。

  • 注釈をRubyプログラムの式としてかっこよく書く
  • 実行時に、本当にアノテーションが正しいか検査する

前者はまあ自明だと思うのでおいておいて、後者の話はというと、 Object#is_a?は継承の関係を見ているだけなのでStructural SubtypingなSteepの意味と違っていて役に立たない、という話です。Structural Subtypingの関係をテストする述語が欲しいのですが、そのためには実行時にメソッド定義の型を全部引っ張って回らないといけないので、けっこう大がかりな改修になるんじゃないかと思います。少なくとも、僕が自分で実装しようとは思わない程度には難しそうです。

その他

Steepの機能としては

  • Union Typeがあるので便利!
  • メソッドのオーバーローディングがそのまま型として書けるようになっていて便利
  • interfaceというのを定義できるようになっているので、クラスじゃないやつもなんとかするよ!

などあります。

(でもまだ全然実装はいろいろぶっ壊れているので、動かなくても失望しないでください……)

2017年はFigmaである

最近はSketchではなくFigmaを使っています。

www.figma.com

何が良いかというと、これはブラウザで動いていて(アプリもあるけどブラウザ版をそのまま動かしている感じ)、一つのワークスペースを何人かでそのまま突っつけるのが良いのです。誰かがなにかを変更すると、それは隣で開いている人にすぐに伝わります。これがものすごく快適なのです。

Sketchに残された問題について以前に書いたことがありますが、それは複数人で一つのファイルを変更することが事実上不可能なことです。diffを取って後でマージするというソフトウェア開発で広く行われている方法は、Sketchには通用しません。(なんとかっていう頑張ってSketchをマージする製品もあるそうですが、結局Figmaで良いという気分になったので使ったことがありません。)どうしてdiffを取ってマージしたいかというと、非同期に変更を共有したいからですが、そこでFigmaは同期的にやってしまうことでこの問題を解決したという。

とは言え、やっぱりいろいろと動作が怪しかったり、機能が足りなかったり、プラグインみたいな仕組みがないのでちょっと手が届かないことがあって辛かったり、そもそもでかいファイルになったときにきちんと動作するのかは(私はまだ経験がないので)わからなかったりするのですが、でもしばらく仕事で使ってみた限りで言えばとても良いです。デザインの共有もURLをコピペしてSlackに貼るだけなので、他の開発者にデザインを送りつけるのも簡単です。すごく良い。

この感覚は、「WordとかExcelではなくて、Google Docsでいいや」っていう感じで、もちろんワープロとか表計算ソフトとしてはWordとかExcelの方が出来は良いのですが、でもみんなで見ながら試行錯誤するような場合にはGoogle Docsの方がはるかに都合が良いという。

もうしばらく使ってみます。

Nullarihyon 1.7

Xcode8にNullability Violationをチェックする機能が入ると聞いて一晩泣き明かしましたが、それはそれとしてちまちま開発を続けています。Xcode8との比較はまたそのうち。

github.com

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をつけたい場合には、

  1. Nullarihyonをインストール、セットアップする
  2. 型にnonnullをつけて回る
  3. initにattributeをつける
  4. コンパイルして、警告をちまちま直す

とすれば、かなり安全に作業が進められます。逆に言うと、この「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

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個警告が出ました