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

Cのauto

Unix考古学を読んでいて、ちょっと面白い記述を見つけた。

たとえば、Dennis Ritchieのコンパイラは、局所的な自動変数をautoという型で定義したり、あるいは変数定義を省略することを許していましたが、型を明確に宣言するという今日のスタイルに変更されました。

Unix考古学 Truth of the Legend

Unix考古学 Truth of the Legend

これだけを読むと、

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 be auto.

なんのことはない、型を省略するとintになるのだった。関数宣言が見つからないときとかそうだった気がするので、まあ一貫性がある。

*1:Wikipediaによる

*2:多相型も関数もないので、全然制限されているけど

*3:1975年のものと書いてある

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

になっているはず。

*1:UIとしては現実的な桁数に入力を制限するべきである。

*2:多分こうだし、本質的には同等の表現なことはかなり自信があるが、まったく同じ値かどうかはわからない。