ソフトウェア・エンジニアとしての年月を重ねるうちに、私はコードベースをどのように構成するか、そしてその相対的な成功をどのように評価するかについて、徐々に慎重になってきました。
初期の頃は、コードが今日何ができるかに近視眼的に集中していました。スピードがすべてで、できるだけ早くコードを書き出すことでした。テストはあればいいものでした。「私のマシンで動く」というのが妥当な受け入れ基準だったので。そして「メンテナンス可能」という言葉の定義すら知りませんでした。
あの頃はとても楽しかったのを覚えています。自分では1337のコーダーだと思っていました。
誰も理解できなかったり、拡張やデバッグが難しすぎたり、壊れやすすぎたりして、本番配備後に爆発するようなとんでもないバグが発生しないか、何かを変更することを恐れていたのです。
雲に怒鳴る老人
今、私は年を取り、多くの異なるコードベースで多くの異なるエンジニアと仕事をしてきました。今、私はまったく違う意見を持っています。今、「保守性」は私の語彙の中で最も重要な言葉のひとつです。今、私は、そのコードが明日何ができるようになるかについて、以前よりもずっと気にしています。そしておそらく最も重要なことは、私自身ができるかもしれないことよりも、$nextEngineerが明日このコードに何をさせることができるかをとても気にかけることです。
これらは、ソフトウェアが初期の段階を越えて存続し、成功するためのものです。あなたのビジネスが1年後も現在と同じペースで成長し、進化し続けられることを保証するものであり、メンテナンス不能で拡張性のないコード基盤によってエンジニアリング・チームの速度がゼロに近づいてしまうようなことがないようにするものです。
保守可能なコードベースを確保するために必要なスキルは、経験、先見性、現在および将来のエンジニアが持続可能で高速なコード作業を達成できるようにするための戦力となることです。何行のコードをどれだけ速く作れるかではなく、それらのコードが時間とともに成長・進化するビジネスの基盤としてどれだけ耐えられるかが重要なのです。
煮詰める
時折、私はこの考え方の変遷を振り返り、保守性についての私の考えを、他のエンジニアに伝えられるような具体的なものに絞り出そうとします。そして、最近私が保守性に関して信じていることの精神を最もよく表している包括的なテーマを1つ選ぶとしたら、それは1つの文章に集約することができます:
実行時ではなく、コンパイル時にバグを発見できるようにコードを構成する。
バグを時間的に前倒しする。チームの中長期的なベロシティに、これほど持続的な影響を与えることはありません。
この記事の残りの部分では、この目標に向けてできることの、より戦術的な例を挙げていこうと思います。賢明な読者の皆さんは、これらが私自身の斬新なアイデアではないことにお気づきでしょう。実際、このリストにあるものの多くは、Kotlin、Rust、Clojureなどのモダンな言語で人気のあるコア機能です。特にKotlinは、非常に実用的で親しみやすい言語でありながら、これらのベストプラクティスを強調する素晴らしい仕事をしています。
つまり、これらの言語や他の言語の優秀な言語設計者たちは、これらのアイデアをソフトウェア・エンジニアリングの時流に乗せたのだから、賞賛に値するということです。今日、私は彼らを賞賛するためにここにいます。
(余談だが、さまざまな新しい言語でコードを書いて過ごすことは、視野を広げ、ソフトウェアエンジニアリングのベストプラクティスに関する信念に挑戦する、本当に素晴らしい方法です。ある言語の基本的な機能から学んだ教訓を、その機能を明示的に提供したり強調したりしていない別の言語に適用するのは、思っているよりもずっと簡単なことが多いことに気づくでしょう。私はもう数年Clojureを書いていないが、その言語で書いていた時間は、ソフトウェア・エンジニアとしてのスキルを向上させるのに、これまでしてきたどんなことよりも役立ったと固く信じています)。
では、早速本題に入りましょう。
バグを早期に発見するための5つのパターンと言語機能
1.静的型付け
Python、Ruby、Clojure、その他の動的型付け言語を愛する人々にとって、これは飲み込むのが難しい薬かもしれません。そして、この点について納得してもらえないかもしれません。しかし、この点については長年にわたって何度も痛い目に遭ってきたので、今後も考えを変えることはないでしょう。
動的型付け言語の魅力のひとつは、型の定義やメソッドシグネチャの宣言といったセレモニーに時間をかけなくてもよくなることです。そして、型を操作するのではなく、データを操作する、より柔軟で再利用可能な関数を書くことができます。
特にプロジェクトの初期のプロトタイピングの段階では、こうした議論には真実味があります。しかし、私が繰り返し目にしてきたのは、動的型付け言語のコードベースがある一定以上の規模になると、それを推論したり保守したりするのが難しくなるということです。コードのある部分で作業している善意の開発者が、コードの別の部分にある関数に間違ったオブジェクト・タイプを渡してしまい、その関数の呼び出しが発生するとアプリがクラッシュするというケースを何度も何度も見てきました。
最悪なのは、このエラーが実行時に発生することです。このバグを発見しないままコードを本番環境にデプロイしてしまった場合、顧客向けの障害が発生するかもしれません。バグの程度によっては、顧客を失うことになるかもしれません。最良のシナリオであったとしても、それはストレスフルなものであり、消火活動のためにエンジニアチームの何人かを休ませなければならないため、高い機会損失が発生します。
もしそのようなバグが本番環境まで伝わってしまうのであれば、それはその関数が正しい引数タイプでしか呼び出されないことを保証するためのテストカバレッジを十分に追加していなかった証拠だと主張する人もいます。それに対する私の答えは、「はい、テストカバレッジに十分な注意を払い、テストコード自体にミスがなければ、この分類のバグのほとんどを出荷せずに済むかもしれません。しかし、テストはそれ自体が芸術であり、このハードルをクリアするためには、エンジニアの一人ひとりが一定レベルの熟練度を達成しなければなりません。また、たとえそうであっても、私たちは誰でも時折ミスを犯すものです。」です。
もしや、もしや、もしや、もしやはありません。また、エンジニアの経験レベルの差に依存することもありません。もしエンジニアがこのようなバグを含むコードを書いたとしても、それはコンパイルされないし、PRにすらならなりません。テストカバレッジの良し悪しは関係ありません。エンジニアに負担を強いるのではなく、コンパイラーにこの分野の仕事をオフロードしましょう!
プロトタイプのために動的型付け言語を擁護することはもうやめようと思うほど、私は時間をかけてこのことを確信するようになりました。プロトタイプが製品に昇格することはよくあることで、その理由はコードがすでに書かれているからに他なりません。しかし、プロトタイプを製品に昇格させるのであれば、製品が信頼できることを確認するために十分なテスト・カバレッジを持たなければならないし、その時点でおそらく、KotlinやGoのような静的型付け言語でプロトタイプを最初に構築するのと同じ量のエンジニアリングの労力を投資することになるでしょう。
私はここで、どの言語を愛用すべきかを指示するつもりはありません。しかし、もしあなたがPython、Ruby、JavaScriptのような動的型付け言語でプロダクションコードを書いているのであれば、これらのエコシステムで利用できるようになった型チェックツールを選ぶことを真剣に検討したでしょう。Pythonでは、型ヒントを必須とし、CIにmypyチェックを追加することで、型安全性のバグを前倒しすることを検討しましょう。JavaScriptでは、TypeScriptに徐々に移行することを検討しよう。Rubyでは、Ruby 3.0で追加されたRBS型アノテーションシステムを試してみましょう。
2. NULL Safety
さて、ここからは(できれば)議論の余地の少ない分野に入ることにしよう。NULLリファレンスは10億ドルの間違いだ、という言葉を聞いたことがあるでしょう。また、コンパイル時にNULL・セーフティを提供しない言語で働いたことがある人なら、実行時にヌル・ポインタ例外のクラッシュを引き起こす愚かなバグや、関数呼び出しのたびに最初に定型的なヌル・チェックを行うコード、あるいはその両方に遭遇したことがあるはずです。
ありがたいことに、最近の言語のトレンドは、nullが許されない変数を宣言する方法を提供することで、このようなバグを前に進める手助けをしてくれています。これはC#、Kotlin、TypeScriptなどのコア機能です。Javaでは、nullを許可する代わりにOptionalを使うことができます。そのため、この作業はコンパイラーに任せることができます。
一般的に、最近null可能な変数を使っているとしたら、それはコードのにおいかもしれません。もしそうでなければ、コンパイル時やビルド時のヌル・セーフ・ツールの仕組みがあるかどうか確認してください。
3.イミュータブルな変数とデータ構造
これは少し慣れが必要で、最初は信じられないかもしれないが、こう考えてほしい:コードの中で実際に変数や型をミュータブルにする必要がある場所はほとんどありません。
初めてこのことを言われたのは、Clojureを学んでいたときで、ミュータブル・オブジェクトを表現することすら非常に難しいため、必然的にそうなっていた。ミュータブル・オブジェクトを表現することさえ非常に難しいからです。しかし、ひとたび心を開き、少し練習してみると、それは真実だとわかりました。
イミュータブル変数は、コードの保守性を向上させる非常に強力な方法です。その理由はこうだ:あなたがエンジニアで、まったく馴染みのないコード・ベースに取り組んでいるとき、イミュータブルなデータ構造を型とするイミュータブル変数を定義しているコード行に出会ったとする。なぜなら、その変数はプログラムの他のどの場所でも変更されないことが保証されているからです。
ミュータブル変数やミュータブル・データ構造とは対照的です。これらのいずれかがインスタンス化されたコード行を読んだ後、同じコードの100行先でその変数の状態について推論したい場合、考慮しなければならないことが山ほどあります:
・その間に、そのオブジェクトを変更するような記述がありましたか?
・そのオブジェクトは、それを変更する可能性のある関数に参照渡しされていましたか?
・もしそうなら、それらの関数のソースコードをすべて調べて、状態がどうなるかを確認する必要がありますか?
・私のプログラムには複数のスレッドがあり、もしそうなら、他のスレッドがこのオブジェクトへの参照を持っていて、私がこのオブジェクトを操作している間に、そのスレッドがこのオブジェクトを変異させた可能性はありませんか?
ミュータブル・ステートには、隠れた複雑さがたくさんあります。もし私が、ミュータブル・オブジェクトを扱うすべてのコード行に対して上記のような推論をしなければならないとしたら、その代わりに同じプログラムをイミュータブルな変数とデータ構造だけを使う方法で書くことができるとしたら、複雑さの軽減は驚異的です。それに対応して、エンジニアリングの速度と保守性も向上します。
最近、多くの言語でイミュータブルなローカル変数を定義する方法がある(例:Kotlin val、TypeScript const)。また、多くの言語にはイミュータブルなデータ構造を定義する方法がある(Kotlinのデータクラス、C#のレコードなど)。できる限りこれらを活用しよう。
私が一緒に仕事をしているエンジニアのほとんどは、コレクションを扱うときを除けば、この考えにかなり簡単に納得します。私たちは配列やマップを構築するループを書くことに慣れているので、これが変更可能なデータ構造やループなしでできるという考えに慣れるのは本当に難しいのです。しかし、できるのです!最近のほとんどすべての言語には、コレクションを操作するための関数型プログラミングツール(マップ、フィルター、リデュース/フォールドなど)があります。これらに慣れるには少し時間がかかるかもしれないが、入場料を払う価値は十分にあります。
特にreduce / foldオペレーションは少し学習が必要だが、コード内でミュータブル・コレクションを使用する必要性をなくす鍵となります。これにより、次のようなコードを書き直すことができるようになります:
val pepperNames = listOf("jalapeno", "habanero", "serrano", "poblano")
val pepperNameLengths = mutableMapOf()
for (pepperName in pepperNames) {
pepperNameLengths[pepperName] = pepperName.length
}
// from here forward we need to be cognizant about the pepperNameLengths map being mutated!
ミュータブル・マップなし:
val pepperNameLengths: Map = pepperNames.fold(mapOf()) { accumulator, pepperName ->
accumulator + (pepperName to pepperName.length)
}
// no mutable map to worry about here!
4.永続的コレクション(別名:不変コレクション)
ある同僚が私に、イミュータブル・コレクションを使うべきだと言ったとき、私の直感は、パフォーマンスとメモリ使用量の問題から、これは現実的ではないと思いました。もし私がマップをイミュータブル・コレクションとして表現し、コードのどこかでその中のキーを追加したり変更したりする必要があるとしたら、それは私の変更を含むバージョンを取得するためにデータ構造全体をコピーすることを意味するのではないだろうか?それって、めちゃめちゃ高くつくんじゃないの?
結局のところ、そうではありません。永続的なコレクションを使用している限りは。
私はこのコンセプトにClojureで初めて出会ったが、このトピックに関するRich Hickey氏の素晴らしい講演を強くお勧めする。
・永続データ構造は不変であることが保証されているが、同じ不変性が保証された別の永続データ構造を生成する修飾関数(put、add、removeなど)が用意されています。
・このようなデータ構造はツリーとして実装されており、1つの項目を変更したい場合、元のツリーのほぼすべてのノードを共有する新しいツリーを作成することで対応できます。変更する項目へのパス上にあるツリーの小さなノードセットをコピーして置き換えるだけでよいのです。効率的な実装では、数百万のノードがあるツリーでも、4つ以上のノードをクローンする必要はほとんどありません。残りは共有することができ、メモリ使用量とパフォーマンスの両方で効率的です。
現在、多くの言語で「永続コレクション」や「不変コレクション」ライブラリ(Java PCollections、C# Immutable Collectionsなど)が提供されており、これらのライブラリはあなたのためにすべての重労働をこなしてくれます。通常のコレクションと同じように操作できますが、優れたパフォーマンスを維持しながら、不変の利点をすべて得ることができます。
このコンセプトは、特に並行プログラムでは驚くほど強力です。つまり、コレクションへの参照をプログラムの好きな場所に渡すことができ、多くのスレッドが同時にコレクションを利用することができます。これによって、アプリケーション・コードがどれだけシンプルになるかに驚くでしょう!そして、ロックの競合を心配する必要がなくなることが、どれほど素晴らしいことか想像してみて下さい。
5.代数的データ型と網羅的パターンマッチング
これらは言語によってさまざまな名前で呼ばれています。Kotlinではsealed classと呼ばれる。多くの言語では、これは単なるポリモーフィズムの特殊なフレーバーに終わるかもしれません。私はこれを型の列挙だと考えています。コンパイル時に列挙された型はすべて知っているが、列挙された型はそれぞれ個別のプロパティやメソッドなどを持つことができます。
具体的な例を使って説明するのが一番簡単です。ここでは、Momento Cache APIの例を使うことにします。
キャッシュから値を取得するために、Momento クライアントオブジェクトの get メソッドを呼び出すと、レスポンスは 3 つの異なるタイプのいずれかになります:
・キャッシュ・ヒット。この場合、キャッシュから取得した値も返されます。
・キャッシュミス。この場合、キャッシュ値はありません。
・エラー。リクエストに何か問題があった場合、エラーコードとエラーメッセージが返されます。
代数的なデータ型がない場合、この状況をコードで表現しようとする一般的な方法は、レスポンスがHIT、MISS、ERRORのいずれであるかを識別するために使用できるステータスの列挙型プロパティを持つGetResponseオブジェクトを提供することかもしれません。このオブジェクトには、それぞれのケースに関連するさまざまなデータを保持するフィールドも必要です。これらのフィールドはnullableかoptionalでなければなりません。なぜなら、どのタイプのレスポンスを受け取ったかによって、条件付きでしか利用できないからです。こんな感じです:
enum class GetResponseStatus {
HIT, MISS, ERROR
}
data class GetResponse(
val status: GetResponseStatus,
val value: String?,
val errorCode: Int?,
val errorMessage: String?
)
このAPIを定義する方法としては悪くないが、ひとつ大きな欠点があります。それは、すべての条件を正しくチェックするコードを書くのは開発者の責任であり、コードにバグがあった場合、それは実行時にしか表面化しないということです。例えば、チェックせずにレスポンスがHITであると仮定してコードを書き、valueプロパティにアクセスしようとすると、レスポンスが実際にはHITでなかった場合、実行時にヌル・ポインタ例外が発生します。(上記のKotlinのコード・スニペットでは、Kotlinのnull・セーフティ・ルールのため、これらの値がヌルである可能性に対処するコードを書かざるを得ませんが、他の言語では必ずしもそうではありません)。重要なのは、これらのフィールドのどれがいつnullになる可能性があるのかを推論するのは開発者の責任であるということに変わりはありません。
代数的データ型は、NULL可能なフィールドを一切公開することなく、このAPIを指定するずっと良い方法を提供します。Kotlinのsealedクラスを使うと、このようになります:
sealed interface GetResponse {
data class Hit(val value: String) : GetResponse
object Miss : GetResponse
data class Error(val errorCode: Int, val errorMessage: String) : GetResponse
}
これで、3つのケースそれぞれに個別のクラスができ、その3つのクラスはそれぞれに関連するプロパティだけを持つことになります。そして、それらはもはや無効化できません。
開発者は、パターン・マッチによって適切なクラスにアクセスする。Kotlinでは、これはwhen式を使って行われます:
val getResponse: GetResponse = cacheClient.get("myCacheKey")
when (getResponse) {
is GetResponse.Hit -> {
println("Cache hit! ${getResponse.value}")
}
GetResponse.Miss -> {
println("Cache miss!")
}
is GetResponse.Error -> {
println("Error! ${getResponse.errorMessage}")
}
}
このアプローチは、”どのような場合にvalueが利用できるのか?”といった疑問に対する開発者の知識負担を軽減してくれるので、本当に素晴らしいです。 valueプロパティはHitクラスにしか存在しないので、結果がHitでない限りアクセスできないというコンパイル時の強制力を得ることができます。我々はまたしてもバグを前倒しにしました!
この手法のもうひとつの優れた点は、Kotlinのような言語では、パターンマッチング式が網羅的であることです。つまり、コンパイラーは賢いので、when式で考えられるすべてのケースを処理したかどうかを知ることができ、処理していない場合はコンパイルに失敗します。このようなwhen式が大規模なコード・ベースにいくつも散らばっていて、あるエンジニアが新機能の開発に取り組んでいて、その新機能にはsealedクラスにGetResponseの追加型が含まれている場合を想像してみてください。網羅的なパターン・マッチングがなければ、エンジニアはコードの中で GetResponse とやりとりしている場所をすべて特定し、新しいタイプのレスポンスを適切に処理できるようにする責任を負うことになります。そうでなければ、どうなるでしょうか?実行時まで明らかにならないバグです。
しかし、網羅的なパターン・マッチを使えば、いったん新しい型が追加されれば、その型に対応するために更新が必要な箇所をすべて更新するまで、コードはコンパイルされません。勝ったのです!
閉会のことば
ソフトウェアの堅固な基盤を構築し、製品の寿命が尽きるまでエンジニアリングチームの高い開発速度を維持するための鍵は、コードベースが保守可能であることを確認することです。将来のエンジニアが、迅速かつ安全にコードを改良できるようにすることが極めて重要です。ありがたいことに、最近のプログラミング言語のトレンドは、それを実現し、バグ・クラス全体を実行時からコンパイル時に前倒しするためのツールをどんどん提供してくれています。これはまた、これらのクラスのバグを導入していないことを証明するテストを書くのに費やす必要のない、膨大なエンジニアリング時間を節約することにもなります。(誤解しないでほしい:テストは今でもとても重要です!しかし、NULL化可能なプロパティの挙動や、実際のビジネスとは関係ないような平凡なことのテストを書かなくて済むのは、とてもありがたいことです)。
上記の戦略は、私がここ数年取り組んできたプロジェクトにおいて、特に貴重なものでした。皆さんもぜひ参考にしてください!
もし、バグを時間的に前進させるためのお気に入りのアプローチが他にあれば、ぜひ教えてください。Twitter(@cprice404)か、Momento Discordに参加してディスカッションを始めてく