Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: 静的型付けで解決しない問題とは(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

Ruby: 静的型付けで解決しない問題とは(翻訳)

Brandur Leach氏のブログ記事『Better typing in Ruby』は、Stripeの静的型付けチェッカーSorbetが誕生したときの状況と、Sorbetで得られたメリットについて解説しています。私はこれまで静的型付けのメリットについては割り引いて考えてきましたが、この記事を読むうちに、私がそう考えるに至った原因となる点がいくつも浮かび上がってきました。同記事では、Stripeのコードベースにある問題の中で、静的型チェックが作業の軽減に有用なケースについて言及しています。しかし静的型付けはそれらの問題を「解決」するものではなく、静的な型付けが存在し続けることで別のコストが発生する可能性もあります。

私は何も、Stripeの方法に間違いがあるとか、ほかの方法を採用すべきだったなどと申し上げたいのではありません。私は彼らほど大規模なRubyコードベースで仕事をしたことはなく、はるかに小さなコードベースでの仕事しか経験していないので、彼らの決定を評価する立場にはありません。彼らの場合は問題の発生が避けられず、静的型チェックが問題解決としてベストだった可能性も十分考えられます。

むしろ同記事をきっかけとして、そこで抽象的に説明されている問題について考察し、自分が関わるプロジェクトでその問題が発生したときに自分ならどうするかを考えてみたいと思います。私にとって静的型付けは、最初に手をつけるソリューションではありません。

以上を踏まえて、問題点を見ていくことにしましょう。

問題に適用される静的型付け

Rubyはモジュール化の促進が苦手なので、コードは巨大な不定形のblob(binary large object)のようになり、あらゆる場所からあらゆる場所が呼び出され、コードの境界は本質的に理論止まりになる。

上の文で述べられているのは実際には因果関係です。「Rubyはモジュール化の促進が苦手なので」が原因の部分で、その結果「コードは巨大な不定形のblobのようになり、あらゆる場所からあらゆる場所が呼び出され、コードの境界は本質的に理論止まりになる」という流れです。しかし不定形のblobが生じるのは本当に言語そのものが原因でしょうか?

コードを書くのはRuby言語ではなく開発者であり、Rubyにモジュール化を促進する機能がないとしても「あらゆる場所からあらゆる場所が呼び出されるコード」を書くかどうかを決めるのも開発者です。言語による効果とは、ある方向にどれだけ強く働きかけるかということです。「Rubyにはモジュール化を強力に促進する機能がないので、開発者はコードをモジュール化する形で書くべきなのに、そこに注意を払っていなかった」とも解釈できそうです。

(中略)エンジニアは新しいコードを書くよりも既存のコードを修正することがほとんどでした。コードのどの部分を精査するときでも、その型を知るために必要なのは名前だけで済み(中略)型がすぐにわからない場合でも、パスにpryを投げ込んでその部分を実行するテストケースを探し、実行時に変数を検査すれば十分調べられます(中略)

(中略)コードはテストの有無にかかわらず解析されるので、テストカバレッジに漏れがある場合の保護層にもなります。

コードのかなりの部分がテストされていないとしたら問題です。私のテストカバレッジは100%ではありませんし、外部システムやライブラリとの統合/アダプター層のテストを書くことはほとんどありませんが、アプリケーションを構成するコードについては徹底的にテストでカバーしたいと思っています。

これは「既存のコードを修正する」プロセスにおいて、コードをカバーするテストが存在するのかやテストがどこにあるかがわからない状況での話です。このケースに該当する場合、変数の型がわかるだけでは保証が不十分です。私は自分の変更によってビジネスロジックが壊れないことを確認したいので、そのコードがテストされている必要があります。

(中略)該当部分を実行するテストケースを探し出して、実行時に変数を検査するのは耐え難いほど時間のかかる作業でした(Rubyはインタプリタ言語なので、コードが増えると起動時のオーバーヘッドが増えます)(中略)
(中略) 静的解析の実行速度はテストスイート1件(つまり1つのファイル)よりも高速で、多くの場合テストケース1件よりも速くなりました(当社のテストは起動時のオーバーヘッドが大きい)。

Rubyでは、テストが遅いのはインタプリタそのものが遅いことよりもフレームワーク全体を起動してしまうことが原因です。フレームワークを起動するようなテストを書くよりも、フレームワークや他の外部オブジェクトに依存しないオブジェクトを実行する単体テストを書くのがベストです。そのようなテストではフレームワークが起動されず、インタプリタで実行するコードの量も最小限で済みます。

コード作者が当初意図した変数の型は1種類だったかもしれませんが、その後新たな用途が生まれ、可能な型を多数含む形でインターフェースが拡張される可能性もあります(中略)

(中略)元々は整数の利用が想定されていたのが、あるとき誰かが文字列でも動作することに気づき、文字列も渡すようになる可能性もあります。

コードベースの開発者は、そのコードがどう使われるかを理解しておくことが重要です。確かにコード片の使われ方はコードのライフタイムの間に移り変わるものですが、コード片を再利用できるというのは大きなメリットです。このような場合には、そのコードを使う他の人にもわかるように、コードに関する知識を周知することが重要です。コードベースが大規模になると、プルリクをひとつ残らずレビューすることも、アプリケーションのあらゆる細部を把握することも不可能になるので、コードの細かな使い方を周知するには他の方法が必要です。入力テストはそのための方法のひとつであり、しかも実行可能であるというメリットがあります。

同記事の著者は入力される型のテストについても言及していますが、その主張にはあまり同意していません。もちろん、メソッドを変更するときにその変更が入力型をすべて網羅しきれていないことがテストで判明したら、その点を考慮する必要があります。しかしこれではメソッドに型制約を与えるのと大差ありません。あるメソッドが複数の型で使われるなら、それらの型をサポートするように書く必要があります。静的型がなくても、そのときに必要な作業量は増えたりしません。

開発者が何かを変更すると、テストスイートはすべてパスしてひとつも失敗しなかったので問題は存在しないという誤った仮定を立ててしまいがちで、テストされなかったパスが本番で500回ほど実行されたときにやっと気づきます(なお「理論上は」テストをそこまで増やすべきではないという建前ですが、実際の私たちは大量のテストを書いています)。

テスト戦略がパスをろくにカバーしていないとしたら、静的型を使っているかどうかに関わらず非常にまずいことになります。重要なのは各テストのパスでビジネスロジックが正常に動くことを確認できることであり、静的型ではそうした点を確かめられません。

私はテストカバレッジを徹底するために、単体テストと受け入れテストを両方を用いることで、互いの強みを活かし弱みを補うという戦略を採用しています。

  • 単体テスト: 各クラスを徹底的にテストすることで、すべてのコードパスのカバーを含めて振舞いを十分規定できる
  • 受け入れテスト: アプリケーション全体を通してテストを実施することで、単体テストされた部分が協調動作していることを確認できる

受け入れテストであらゆるパスの組み合わせをカバーしようとすると、大変なコストがかかります。代わりに、単体テストで保証されている部分を考慮して、必要な部分を受け入れテストでカバーするように設計します。大規模アプリケーションでは、変更をかけるたびに受け入れテストをすべて実行するのは無理だとしても、リリース前なら確実に実行できますし、実行すべきです。私の経験では、これで潜在的なエラーを残らず検出とまではいかなくても、大半を検出できるはずです。

静的解析は確かに素晴らしいものですが、型シグネチャは人間のためのものでもあることを忘れてはいけません。コードを読むときに特定の変数やメソッドで期待される型がわかれば、理解を深めるうえで非常に有用です。もちろん優秀なIDEでも同じことはできますが、両方使える方がいいと思いませんか?Rubyの静的解析は無料です。

この記事では型シグネチャのメリットを説明するときにデメリットを伏せているのみならず、型シグネチャが「無料」である点を強調していて、あたかも「欠点は存在しない」かのように見えます。しかし多くの人々が動的言語での型シグネチャのメリットについて議論を重ね、「型シグネチャにはトレードオフもある」と述べています。どんなトレードオフでしょうか。

極端なケースでは、複雑な型シグネチャによってコードが乱雑になり、ロジック自体がかすんでしまうこともあります。書籍『Swift Style』に掲載されているSwift言語で書かれた例を見てみましょう。

Excerpt from *Swift Style* showing function with many more lines of type signatures than implementation

Swriftが作り出すコードのスタイルはひときわトップヘビーになります。このため、メソッドやイニシャライザの本体に到達する前にSwiftの処理が多数発生する可能性があります。ここで紹介するのは、双方向のコレクションのインスタンスを初期化するSwiftの標準ライブラリの例です。
元記事の引用画像より

わかりにくいと思いますが、アプリケーションコードと言えるのは末尾のself.base = baseの部分だけで、それ以外の部分は複雑極まる型シグネチャがびっしり並んでいます。

型アノテーションは、シンプルで典型的なケースでもコードが煩雑になってしまいます。私がRubyで気に入っている点のひとつは、クリアな構文によってビジネスロジックが前面にくっきりと浮かび上がってくることです。対照的に、型アノテーションを用いる言語のコードは、ほとんどの場合情報がてんこ盛りになってしまうことが私の目には明らかです。

MatzはRubyConf 2019の基調講演で、Rubyが型アノテーションを追加せずに別のファイルに置く理由を説明した際に同様の点に触れています(動画↓)。型シグネチャはDRYではないので、型アノテーションをRubyコードの外に追いやることでコードをDRYにできると述べています。

重複はコストを強います。コードを読むたびに脳が型の記号を処理しなければならなくなり、既にわかりきった型ではその労力に何のメリットもありません。たとえ労力はわずかでも、時間とともに降り積もっていきます。

静的型が有用な場合とは

以上の引用から、静的型付けのメリットを最大化できる場面が浮かび上がってきます。

静的型付けはモジュール性の低いアプリケーションを書くときに有用: しかし、そもそもモジュール性の低いアプリケーションを書かないのがベストであり、静的型付けがあってもモジュール性の低さは将来に渡って問題になります。私は大規模なチームで大規模なモノリスを書いて良好なモジュール性を失わずに済んだという経験がないので、実践は難しそうです。しかし少しでもその境地に近づけるなら、それに越したことはありません。新しい開発者をしっかり教育し、徹底したコードレビューを行うことで前進できると思います。

静的型付けはコードがテストで一貫してカバーされていない場合に有用:  テストされていないコードは何をしているのかわかりにくく、変更したときに安全かどうかも見当がつきません。静的型付けはそのような場合に有用です。しかし静的型付けを行う場合でもコードをテストすることは引き続き重要です。コードの単位を個別にテストすると同時に、アプリケーション全体のユーザーフローもテストする必要があります。動的型付け言語ではテストの重要性が他の言語より高くなるので、Rubyでもテストの重要性が強調されています。ほとんどの型システムでは、静的型付けではビジネスロジックが正しいことを保証できないので、やはりそのためのテストが必要です(使ったことはありませんがIdris言語のような例外もあるようです)。コードベースやチームの規模に関わらず、ほとんどのコードでテストを要求することは理にかなっています。徹底したコードレビュー(私の好みです)やコードカバレッジのメトリクス (これはこれで大きな問題がありますが、徹底したコードレビューが行われないなら何もしないよりマシでしょう) を使うかどうかにかかわらず、コードのほとんどの部分がテストされるようにすることは可能です。

静的型付けは書いたテストが遅いときに有用: ある時期にRubyコミュニティで高速なテストが求められてきた理由がこれです。結合テストを高速化する回避方法はいくつかありますが、最も信頼できる方法は、フレームワークを使わないPORO(plain old Ruby object)を書き、それらのオブジェクトを分離した状態でテストする「真の単体テスト」を書くことです。これを実践するには規律の徹底が必要で、しかも最初のうちは規律の必要性もはっきりしません。しかし静的型付けを用いるにしても、ビジネスロジックの動作確認は欠かせないので、高速なテストはどちらにしても重要です。

静的型付けはコードがどこでどう使われているかわからないときに有用: ある目的を持ったコードが目的外の形で使われると、理解できない形でコードが使われていることになります。しかし自分のコードがどこでどう使われているかを把握しておくことは重要です。型が一致したとしても、コードが自分のあずかり知らない方法で使われればバグやメンテナンスコストの増加につながります。コードを理解していれば、そのコードが何に使われているかをいつでも把握できます。あるコードの用途が変われば、それに対する理解も変える必要があります。新しいケースをカバーするテストを追加したり(#addを整数の他に文字列にも使うなど)、適用範囲を拡大するためにメソッド名やクラス名を変更したり、ドキュメントを更新したりすることで理解を深められます。これはアプリケーションを進化させるための規律あるアプローチです。静的型付けはこのアプローチを強制し、忘れた場合にキャッチする方法ですが、前述のように認知のオーバーヘッドを増やしますし、型は同じでもコードのビジネスユースケースが異なる場合はキャッチできません。

まとめると、静的型付けは「自分が把握していない」「モジュール性が低い」「テストが不完全」「テストが遅い」コードで特に有用です。そのような状況では静的型付けがおそらく役に立つでしょう。

よりよいソリューション

しかし静的型付けがあっても、モジュール性が低く、テストも不完全で遅い、自分の把握していないコードベースを相手にするのは困った状況です。やはり自分が把握しているモジュール性の高いコードベースで、徹底的なテストを高速に実行したいものです。そうなれば静的型付けの値打ちは大幅に下落し、代わりに可読性のコストが増加します。

最後にもう一度申し上げます。私は大規模なチームで大規模なRubyコードベースを相手に仕事をした経験がないので、コードベースを前述のような理想的な状態に保つことがどのくらい難しいかは見当がつきません。そうした状況では静的型付けが有用という業界常識も数多く見かけます。しかし、そう言う人たちが動的型付けのメリットについて合意が取れているかどうかについては、私には何とも言えません。「コードの把握」「モジュール化」「徹底した高速テスト」を追求するのであれば、おそらく静的型付けはある程度のガードレールとして有用でしょう。

しかし小規模プロジェクトや少人数チームであれば、コードベースの深い理解、モジュール化、徹底した高速テストの実施は可能であることを経験から知っています。静的型付けと動的型付けのどちらを使うにしても、そこを目指しましょう。これが実現され、Rubyコードの見た目や動作に惚れ込んでいるのであれば、静的型付けは使わなくてもよいかもしれません。

関連記事

Railsでブラウザテストを「正しく」行う方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。