コードを読めばそのまま仕様が理解できる、といったようなコードをなるべく作成したいと思っています。
厳密な仕様書を用意するにせよ、色々設計してからテストベースで仕様を定めるにせよ、AIベースでコーディングするにせよ、実際にシステムを動かしているのはそのコードなので、コードの信頼性を重要視したい、という考えです。
手始めに、なんとなくでif-elseを使うのをやめてみます。
例
まずは、なんとなくで記述したif-elseの分岐処理です。(コード例はTypescriptです。)
type AnimalKind = 'dog' | 'cat';
// 動物に関する何かしらの処理
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 何かしらの処理
} else {
// 何かしらの処理
}
}
現在の仕様では、AnimalKind(動物の種類)は、dogとcatの2つしか想定されていません。
が、しかし、もしかすると今後の仕様変更の際、rabbitなどが追加され、AnimaKindの種類が増える可能性は十分にあります。
これを前提に考えたとき、果たして上記のコードは良いコードでしょうか?
それとも、悪いコードでしょうか?
・
・
・
答えは、「仕様による」です。
例えば次のような場合、これは特に文句の付けようもないコードです。
type AnimalKind = 'dog' | 'cat';
// 動物に関する何かしらの処理
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else {
// 「dog」以外の動物に関する処理
}
}
しかしながら次の場合だと、これはあまり良くないコードになってしまいます。
type AnimalKind = 'dog' | 'cat';
// 動物に関する何かしらの処理
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else {
// 「cat」に関する処理
}
}
この良くないコードを改善するには、次のようにします。
type AnimalKind = 'dog' | 'cat';
// 動物に関する何かしらの処理
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else if (kind === 'cat') {
// 「cat」に関する処理
} else {
throw new Error("Unexpected AnimalKind");
}
}
別に仕様が変わった時に修正すれば良くない?
それはそうです。
正直、仕様通りに動くなら何でも良いと思います。
上記くらいのシンプルな例であれば、間違えようも無いでしょう。仕様変更時の修正も簡単です。
新しくrabbitが追加されたところで、よくないコードとして紹介したものであっても容易に修正できます。
type AnimalKind = 'dog' | 'cat' | 'rabbit';
// 動物に関する何かしらの処理
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else if (kind === 'cat') {
// 「cat」に関する処理
} else {
// 「rabbit」に関する処理
}
}
しかしながら今の時代、実際に構築するWEBシステムが上記のようなシンプルさで収まることはあり得ません。
もっともっと複雑で膨大になることでしょう。
そういうシステムにおける仕様変更というのは怖いもので、修正はできても、正しく修正できる保証はあまりないです。
だからこそ、正しく修正できた否かを確かめるために動作確認を行うのであり、テスト駆動なんてものもあるわけですが、こういう時に一番怖いのは、「 たまたま偶然、正しく動いてしまった 」みたいなケースです。
エラーが出るのなんて全然怖くないです。むしろ安心するくらいです。
複雑なシステムにおける難解な仕様変更の際に、例えば以下のような変更漏れが発生したとしましょう。
type AnimalKind = 'dog' | 'cat' | 'rabbit' | 'horse'; // <-「horseを追加」
// 動物に関する何かしらの処理(...修正されず)
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else if (kind === 'cat') {
// 「cat」に関する処理
} else {
// 「rabbit」に関する処理
}
}
今回の仕様変更では、新たにhorse(馬)が追加されましたが、この部分の修正が漏れて以前の仕様のままになってしまいました。
しかしながら、動作確認では何の問題も発生せず、テストコードで実行されたテストもクリアしたので、仕様通りに動いているものとしてOKが出されてしまいました。
何故かというと、ここで行われている『 「rabbit」に関する処理 』が たまたま偶然 、草食動物に対して当てはまるものだったからです。
エサやりか何かのシステムだった場合、「にんじんや草を与える」みたいな処理が行われていたのでしょうね。
しかし、rabbit(ウサギ)とhorse(馬)は全く別の動物です。
今回はたまたま正しく動きましたが、ウサギや馬に関する仕様がさらに変更された際、バグの原因になる可能性が十分にあります。
そしてその際、修正されたコードが全く別の箇所にあった場合、そのコードを変更したせいでバグが発生した、と誤解を与える原因にもなります。
本当の原因はずっと前の仕様変更の際の修正漏れであるにも関わらず、修正したばかりコードが怪しいと思い込んで、無駄に調査の時間をかけてしまうかもしれません。
仕様通りに、慎重にif-elseを定義していればちゃんとエラーが出る。
type AnimalKind = 'dog' | 'cat' | 'rabbit' | 'horse'; // <-「horseを追加」
// 動物に関する何かしらの処理(...修正されず)
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else if (kind === 'cat') {
// 「cat」に関する処理
} else if (kind === 'rabbit') {
// 「rabbit」に関する処理
} else {
throw new Error("Unexpected AnimalKind"); // 「horse」を渡されたらここでエラーが出る。
}
}
例え修正漏れがあったとしても、このコードであればちゃんとエラーを投げてくれます。
繰り返しますが、エラーが出るのなんて何も怖くないです。むしろ安心するくらいです。
本当に怖いのは、本来想定されている仕様とは間違った仕様で動いているにも関わらず、たまたま偶然仕様通りに動いてしまっているような処理 です。
もちろん、こんなふうに記述したからといって絶対安心のコード!! とはなりませんが、ある程度は信頼性の向上に繋げることができます。
コードがそのまま仕様を表すようにしたい。
上記で挙げたような問題は、なんとなくでelseを記述したときに起こりがちですが、別にelseを使うのが悪いというわけではないです。
既に紹介済みの例でこれも繰り返しになりますが、
type AnimalKind = 'dog' | 'cat';
// 動物に関する何かしらの処理
function handleAnimal(kind: AnimalKind) {
if (kind === 'dog') {
// 「dog」に関する処理
} else {
// 「dog」以外の動物に関する処理
}
}
このように、「dog」だけを特別に処理して、「dog」以外は同じ処理で問題ない、と既に決まっているのであれば、むしろこう書く方が仕様をそのまま表現していて良いコードです。
まとめ
- コードを読めばそのまま仕様が理解できる、といったようなコードをなるべく作成したい。
- そのための第一歩として、if文を記述する際、あるいはコードレビューでif文を読む際は、少し立ち止まって、実際の仕様と照らし合わせながらこれで問題ないか吟味するようにしたい。
余談1
今回の内容は、switch-default文を書く時にもピッタリ当てはまりますね。
余談2
この記事を読んで、ストラテジーパターンみたいなものを使えば、例外を投げるなんて面倒なこともせずに、仕様もそのまま綺麗に表現できるんじゃないか? と思った方がいるかもしれません。
それはまぁ仰る通りなのですが、本記事の主題は高度なデザインパターンを習得することではなく、少しでも実際の仕様と照らし合わせながらコードと向き合ってもらいたい、というものでした。
そのための簡単な具体例としてif分岐を挙げた、という形になります。