📖

コードに対する信頼性について

2024/10/23に公開

はじめに

この記事は、自分がこれまでソフトウェアのコードを読み書きしてきて、漠然と大切だなと感じていたことを明確に言語化できてきた気がするので、それを書き起こしてみようという記事です。
全体的に僕個人の感覚的な部分も多くあるので、共感できる部分を参考にしてもらうのが良いかなと思います。サンプルコードは書き慣れているgoで書いています。

コードに対する信頼性とは

この記事では、エンジニアがアプリケーションコードを読んでいてそのコードをどのくらい信頼できるか、ということをコードに対する信頼性と呼ぶことにします。
信頼できるという状態がふわっとしているので、まずそこを明確にします。

信頼できるコードとは

僕が信頼できると思うコードは、 コードを読む量(コスト)が少ないまま、必要な情報を入手できるコードです。これはコードの書き方がテクくて記述量が少ないということではなく、コードを読む人に対して必要な情報をいかに少ないコードリーディング量で渡せるか、ということです。もちろん本当に詳細にシステムの挙動を把握するには全てのコードを読む必要がありますが、全ての場合でそれが必要ではないと思います。
なので、より少ないコードリーディング量で、読む人にとって必要で正確な情報を渡せるコードが信頼できるコード。ということが最近自分の中で言語化できてきた部分です。
逆に、必要な情報をコードから取得するのに読まなければならないコード量が膨大なコードは、最終的に何をするにも全てのコードを読まないと安心できない。常に疑って読む必要がある。という状況になってしまうので信頼性という表現を用いました。

コードの信頼性を上げるために

信頼できるコードとはどんなコードかがなんとなく理解してもらえたかなと思うので、ここからは自分が思う信頼性の高いコードを書くためのtipsを書いていきたいと思います。

命名と処理を一致させる

めちゃくちゃ当たり前のことを書いていますが、例えばこんなコードがあったとします。

add.go
func Add(a,b int) (*int, error) {
    res := a + b
    countRepo := NewCountRepository()
    if err := countRepo.Store(res); err != nil {
        return nil, error
    }
    return res
}
main.go
func main() {
    // 何かしら処理してきて
    val1 := 1
    val2 := 2
    res, err := Add(a,b)
    if err != nil {
        panic(err)
    }
    // 処理が続く
}

main.goを読んでいてこのAdd関数を見ると、内部でデータを保存しているとは考えずらいです。例えばこれが、Repository部分での障害調査だった場合、Add関数の実装自体は読み飛ばしてしまい、原因究明が遅れるかもしれません。
これは読み手にとって必要な情報が伝えられていない状況と言えそうです。
これに対して解決策が、処理と命名を一致させることで、関数の命名を addWithStore なんかにしておくと、保存しているんだな。ということも読み手にも伝わりそうです。

処理の副作用の有無を命名でわかるようにする

上記の例と同じコードを例に取ります。先ほどの例では、足し算をする処理とそれを(おそらく)DBに保存する処理が書かれていました。しかし、Addという関数名からはデータが保存されるという副作用がある処理であることは伝わりません。
なぜ命名で副作用があるかを明確にするべきかというと、障害の中でも副作用がある部分が関わるとデータの不整合などにつながり障害の規模が大きくなりがちなので、その処理の副作用の有無は命名でわかるようにすると良いと考えています。

命名の統一性

例えば、以下のようなコードがあった時にあなたはどう思いますか?

main.go
func main() {
    // 何かしら処理
    resA, err := StoreEntityA(entityA)
    if err := nil {
        panic(err)
    }
    resB, err := SaveEntityB(entityB)
    if err := nil {
        panic(err)
    }
    // 処理が続く
}

自分は何か保存処理が違うのかな?と思います。実際に見に行ってみて、StoreはDBに保存、Saveではinner_apiで保存リクエストを投げていた。であれば読み手に正しく情報が伝わっていそうです。ただ、どちらも同じくDBに保存しているだけだった。となると、無駄にコードを読みに行ったことになります。
この場合、StoreとSaveという命名の違いによる余計なコンテキストが付与されたせいで読む量が増えてしまっています。なので、可能な限り同じ責務の処理をしているのであれば同じ命名をするのが読む人にとっては負担の少ないコードになりそうです。
ただ、ガチガチにチームで「dbからの取得はget」「api経由での取得はfetch」とかルールを作るとそれを守るための負担が高くなるのでやりすぎは禁物かもしれません。
この場合、Dao.get()InnerHttpClient.get()のようにclientのメソッドとすることで、適切なコンテキストがコード上に反映され仕組み的にコードを読む負担を減らすことができそうです。

責務を正しく切り、処理の凝集度を上げる

めちゃくちゃよく聞く話ですし色々なメリットがある話ですが、コードの信頼性観点でもメリットがあります。先ほどのAddの例で

Hoge/add.go
package Hoge
func Add(a,b count) count {
    return a + b
}
Foo/add.go
package Foo
func Add(a,b,c count) count {
    return a + b + c
}
main.go
type count int
func main() {
    // 何かしら処理してきて
    val1 := count(1)
    val2 := count(2)
    res := Hoge.Add(a,b)

    if err := StoreCount(res); err != nil {
        panic(err)
    }
    // 処理が続く
}

こう言った状況の時に、count型のAddでは値を足した後に、10倍するというドメインロジックの変更があったとします。するとmainから読むとHoge.AddがAddの処理だからAddに変更を入れれば良さそうとなります。しかし、実際はFooパッケージのAddも同様のロジックなので、10倍の変更を入れる必要があります。
この場合、修正時のコードを読む量は変わりませんが、後々修正漏れがわかった後は、どこにcount型のAddの処理が書かれているかわからないので、コードを隅々まで読んで、count型の加算のロジックを探すことになります。
この場合

Add/add.go
func Add(counts []count) count {
    var res count
    for _, c := range counts {
        res += c
    }
    return res
}

このようにすることで、変更箇所がAdd/add.goに絞られ、Addのドメインロジックはここだけにあると言える状態になります。これで、Addのドメインロジックを探す旅に出る必要もなくなります。

処理の流れを適切な粒度で関数に切り出す

例えば以下のようなコードがあったとします

main.go
func main() {
    input := "aaa"
    if len(input) < 1 {
        panic("input is too short")
    }
    if strings.contains(input, "/") {
        panic("input contains /")
    }
    if input == " " {
        panic("input only space")
    }

    result := input + "bbb"
    fmt.Println(result)
}

ここで、追加で ccc という文字列も追加するという仕様の変更があったとしましょう。まずどこに追加するか頭から読み始めて探すことになります。この程度のコードであれば特に問題は感じませんが、実際のプロダクトのロジックで考えると、バリデーションが複雑で、様々な処理が実行されていると思います。それが全てmainに書いてあると、処理を追加する箇所を見つけるのも一苦労になるのが想像できます。
処理の流れの意味を考えて以下のように変更します。

main.go
func main() {
    input := "aaa"
    if err := validate(input); err != nil {
        panic(fmt.Printf("input is invalid: %s", err))
    }
    result := process(input)
    fmt.Pringln(result)
}
validate.go
func validate(input string) error {
    if len(input) < 1 {
        panic("input is too short")
    }
    if strings.contains(input, "/") {
        panic("input contains /")
    }
    if input == " " {
        panic("input only space")
    }
}
process.go
func process(input string) string {
    return "aaa" + "bbb"
}

こうすると、cccを追加するにはprocessに追加すれば良いことが分かりやすく、validateの詳細などは読まずとも、処理の追加箇所を見つけられます。

まとめ

いざ書いてみると、別にテクニック自体は散々言われている内容になってしまいましたw
ただ、よく言われている設計のテクニックをエンジニアがコードを読む量という観点でメリットを説明しているのはあまり見ない気がするので、そういう意味では多少は意味があったかなと思います。
業務時間のうちコードを書く時間は1割で、9割はコードを読む時間という話はよく聞く話ですが、この記事を通して、コードを書くときに、読み手の気持ちを考えて、いかに少ないコード量で読み手に必要な情報が伝わるかを考えてコードを書くと後々の生産性が少しだけ良くなるかもね。ということが伝わると嬉しいなと思います。
例で出した程度の複雑さだと、問題点やメリットが伝わりきらない気もするので、ご自身のシステムでこういった問題点がないかを考えてみて、少しでも参考になれば良いなと思います。

Discussion