📕

Understandable Code ~ わかりやすいコード:コードを語る芸術

2024/06/06に公開

はじめに

こんにちは、クラウドエースのバックエンドエンジニアリング部のニコです。
この記事では、コードの可読性を高めるための書き方と規則について解説します。

対象読者

  • 初心者
  • わかりやすいコードの仕様について混乱を感じている方
  • わかりやすいコードを意識したいと感じている方

説明すること

  • わかりやすいコードの規則
  • わかりやすいコードの事例
  • わかりにくいコードとわかりやすいコードの違い

わかりやすいコードとは?

コードには、以下の2つの重要な役割があります。

  1. 正しく実行すること
  2. 他のエンジニアが理解しやすい

コーディングは個人行動ではなく、ソーシャルイベントです。一つ目はもちろんですが、二つ目の役割は特に、コードを共有する開発チームや将来の保守性を考慮する上で非常に重要です。

なぜわかりやすいコードが必要なのか

「動けばいい」と思ったことはありませんか?この考え方は、短期的な視点では問題ないように思えるかもしれません。しかし、以下の理由から、長期的な視点では大きなリスクとなります。

  • 保守性の低下: コードがわかりにくいと、バグ修正や機能追加などの保守作業に時間がかかり、開発コストが増加します。
  • チームワークの低下: コードがわかりにくいと、チームメンバー間でコミュニケーションが阻害され、開発効率が低下します。
  • 技術負債の増加: わかりにくいコードは、技術負債となり、将来的に大きな問題を引き起こす可能性があります。

シナリオ

ある案件で新しい Web アプリの開発を担当しています。開発とテストのフェーズが完了し、アプリは無事リリースされました。その後、数ヶ月が経過し、問い合わせはほとんどありませんでした。

ある日、アプリにバグが見つかり、修正依頼が入ります。2年前に自分が書いたコードを読み解くことから作業を始めます。しかし、コードがわかりにくく、以下の問題が発生します。

  1. コードをあまり覚えていない、意味が理解できない
  2. そのため、バグの場所を見つけられない
  3. 修正に時間がかかり、納期に間に合うかどうかが判断しづらい

このシナリオが発生しないように、コードを読みやすくしないといけません。

わかりやすいコードはどう書けばいいのか

わかりやすいコードを書きたい方が当然多いと思います。
皆さんのコードの書き方はそれぞれかもしれませんが、わかりやすいコードを書くために意識した方がよい原則があります。その原則の一部、この記事で紹介したいと思います。

  1. Single Responsibility Principle (単一責任の原則)
  2. 良い構造
  3. ネーミングの原則
  4. コメントを書くときの思考
  5. 変更しやすいコード (Easy to Change or ETC)
  6. Don't Repeat Yourself (DRY)

わかりやすいコードの規則

Single Responsibility

プログラミングには、様々な原則や思考があります。その中に、SOLID という原則が存在しています。SOLID は、ロバート・C・マーチンによって提唱された5つのオブジェクト指向設計(OOD)原則の頭字語です。これらの原則は、ソフトウェアの設計をより理解しやすく、柔軟で、保守しやすくすることを目的としています。SOLID は以下の原則を表しています:

  1. Single Responsibility Principle (「単一責任の原則」/「SRP」)
  2. Open/Closed Principle (「オープン/クローズドの原則」/「OCP」)
  3. Liskov Substitution Principle (「リスコフの置換原則」/「LSP」)
  4. Interface Segregation Principle (「インターフェース分離の原則」/「ISP」)
  5. Dependency Inversion Principle (「依存性逆転の原則」/「DIP」)

この記事では、SOLID 原則の中から、Single Responsibility Principle (単一責任の原則) について簡単に紹介します。

Single Responsibility Principle (以下「SRP」) (単一責任の原則) は、コードの全てのパーツ (関数、クラス、変数) が一つの役割しかない。その上、修正が必要の場合、その修正の理由は絶対に一つしかないようにします。

SRP に従わないコード

type User struct {
  ID        int64  `json:"id"`
  Username  string `json:"username"`
  Email     string `json:"email"`
  Password  string `json:"password"` // Hashed password
}

func (u *User) CreateUser(db *sql.DB) error {
  // Hash password before saving
  hashedPassword, err := hashPassword(u.Password)
  if err != nil {
      return err
  }
  u.Password = hashedPassword

  // Connect to database and save user
  stmt, err := db.Prepare("INSERT INTO users (username, email, password) VALUES (?, ?, ?)")
  if (err != nil) {
      return err
  }
  defer stmt.Close()

  _, err = stmt.Exec(u.Username, u.Email, u.Password)
  return err
}

func (u *User) ValidateCredentials(db *sql.DB, username string, password string) (bool, error) {
  // Fetch user from database
  var user User
  err := db.QueryRow("SELECT * FROM users WHERE username = ?", username).Scan(&user.ID, &user.Username, &user.Email, &user.Password)
  if err != nil {
      return false, err
  }

  // Compare hashed passwords
  return comparePassword(password, user.Password), nil
}

SRP に従うには、一つの役割を担います。

SRP に従うコード

type User struct {
    ID        int64  `json:"id"`
    Username  string `json:"username"`
    Email     string `json:"email"`
}

// UserService handles user data storage and retrieval
type UserService struct {
    db *sql.DB
}

func (us *UserService) CreateUser(u *User) error {
    // Hash password before saving (ここも別の関数にしてもOK)
    hashedPassword, err := hashPassword(u.Password)
    if err != nil {
        return err
    }
    u.Password = hashedPassword

    // Connect to database and save user
    stmt, err := us.db.Prepare("INSERT INTO users (username, email, password) VALUES (?, ?, ?)")
    if err != nil {
        return err
    }
    defer stmt.Close()

    _, err = stmt.Exec(u.Username, u.Email, u.Password)
    return err
}

// AuthService handles user authentication
type AuthService struct {
    db *sql.DB
}

func (as *AuthService) ValidateCredentials(username string, password string) (bool, error) {
    // Fetch user from database
    var user User
    err := as.db.QueryRow("SELECT * FROM users WHERE username = ?", username).Scan(&user.ID, &user.Username, &user.Email, &user.Password)
    if err != nil {
        return false, err
    }

    // Compare hashed passwords
    return comparePassword(password, user.Password), nil
}

良い構造

コードベースのナビゲーションを容易にするためには、以下の要素が重要です。

1. 一貫性のある構造

  • 関数、クラス、モジュール、ディレクトリなどの命名規則が統一されている
  • ファイルやディレクトリの階層構造が論理的に整理されている
  • コードベース全体で共通のデザインパターンやアーキテクチャが使用されている

2. 明確な役割分担

  • 各ファイル、クラス、モジュールは、明確な役割を持つ
  • コードベース全体で重複や矛盾がない
  • 依存関係が明確で、循環依存がない

3. 適切な粒度

  • 関数やクラスは、適切な大きさに分割されている
  • コードは、必要以上に複雑化されていない
  • テストが容易なように、コードは独立した単位に分割されている

具体的な例

// ディレクトリ構成
├── api/
│   ├── v1/
│   │   └── users.go
│   └── v2/
│       └── users.go
├── cmd/
│   └── main.go
├── domain/
│   └── user.go
├── infra/
│   └── db.go
├── repository/
│   └── user.go
└── service/
    └── user.go

上記の例では、以下の点に注意しています。

  1. ディレクトリ構成は、機能ごとに整理されている
  2. ファイル名は、そのファイルの内容を明確に表している
  3. 関数やクラスは、適切な大きさに分割されている
  4. コードベース全体で統一された命名規則を使用している

メリット

  1. コードベース全体を理解しやすくなる
  2. コードの保守性 (Maintainability) が向上する
  3. コードの再利用性 (Reusability) が向上する
  4. チームメンバー間のコミュニケーションが円滑になる

ネーミングの原則

変数名、クラス名、関数名を付けるとき、以下の注意点を心がけましょう。

  1. わかりやすさ
  2. 一貫性
  3. 簡潔さ

以下は、変数や関数の命名におけるベストプラクティスだと考えられます。

記述的な名前を使用する

名前は記述的で、変数や関数の目的を伝えるべきです。

悪い例

下記の例では、abc は記述的ではなく、それらが何を表しているのかについての文脈を提供しません。

a := 5
b := 10
c := a + b
良い例

下記の例では、 passwordMinLengthpasswordMaxLength がパスワードの長さを意味する変数であると情報を提供してくれます。

passwordMinLength := 5
passwordMaxLength := 10

一貫した命名規則を使用する

命名規則の一貫性は、コードを読みやすく理解しやすくします。例えば、変数名にcamelCaseを使用している場合、コード全体でそれを維持するべきです。

悪い例
myVariable := 5
my_variable := 10
MyVariable := myVariable + my_variable

上記の例では、myVariablemy_variableMyVariableはそれぞれ異なる命名規則(camelCase、snake_case、PascalCase)を使用しています。

良い例
myVariable := 5
myVariable2 := 10
sum := myVariable + myVariable2

予約語の使用を避ける

予約語はプログラミング言語で特別な意味を持っています。それらを変数や関数の名前として使用すると、予期しない挙動を引き起こす可能性があります。

(Pythonの例)

def list():
    // この関数は数のリストを返します
    return [1, 2, 3, 4, 5]
}

上記の例では、listはPythonの予約語なので、関数名として使用すべきではありません。

略語を避ける

略語が広く受け入れられて理解されている(例えば、numを数値のために使用する)場合を除き、略語を避けるべきです。コードを理解しにくくする可能性があります。

名前は短く、しかし意味のあるものにする

長い名前はコードを読みにくくしますが、非常に短い名前は曖昧になる可能性があります。バランスを見つけるように努めてください。

発音可能な名前を使用する

変数や関数の名前を発音できない場合、通常はその名前が良いものではないというサインです。発音可能な名前は、コードについての会話で議論しやすいです。

ドメイン固有の名前を使用する

コードが対処している問題領域に特定の用語や名前がある場合、それらを使用します。これにより、その領域に精通している人々にとってコードが直感的になります。

大文字と小文字の使用を一貫させる

変数と関数の名前の大文字と小文字の使用は、一貫したパターンに従うべきです。これは通常、使用しているプログラミング言語の命名規則によって決まります。

マジックナンバーを避ける

コードで直接数値を使用するのではなく、それらを記述的な名前を持つ変数に割り当てます。これにより、コードが理解しやすく、保守しやすくなります。

関数の名前はその動作を示すべき

関数の名前は、それが何をするのかを示すべきです。関数の実装を見なければ何をするのかわからない場合、その名前は十分に記述的ではありません。

悪い例
func calc(num1, num2 int) int {
    // この関数は二つの数の和を計算します
    return num1 + num2
}

上記の例では、calcは二つの数の和を計算する関数の名前として過度に省略されています。

良い例
func calculateSum(num1, num2 int) int {
    return num1 + num2
}

calculateSumは、その名前から、関数が何をするのかを明確に示しています。

コメントを書くときの思考

プログラミングにおいてコメントはコードの目的と機能を説明するために使用されます。しかし、「コードが何をするのか」を説明することと、「なぜそれをするのか」を説明することを区別することが重要です。

クリーンで理解しやすいコードを書くとき、それはしばしば自身で「何をするのか」を説明します。例えば、適切に命名された関数や変数はコードを自己説明的にします。コメントで「何をするのか」を説明する必要があると感じる場合、それはコードが複雑すぎて、単純化またはリファクタリングが必要であることを示しているかもしれません。

一方、「なぜコードがそれをするのか」を説明することは非常に価値があります。これには、コンテキストの提供、特定の決定の背後にある理由の説明、トレードオフの説明、またはなぜ特定の代替アプローチが使用されなかったのかの注記が含まれます。これらはコード自体では説明できないことであり、将来コードを読んだり保守したりする必要がある人にとって非常に役立ちます。

要約すると、「何をするのか」については自己説明的なコードを書き、「なぜそれがそのように動作するのか」を説明するためにコメントを使用することを目指すべきです。

Easy to Change

Easy to Change (以下「ETC」) は、ソフトウェア開発における基本的な原則であり、コードを「変更しやすく」するという概念があります。

これは、将来的に簡単に修正や拡張ができるようにコードを書くという考え方です。これは重要なことで、要件は時間とともに変化することが多く、これらの変化に迅速かつ効率的に対応する能力が重要となるからです。

ETCが重要な理由

コードを変更しやすくするべきいくつかの理由を以下に示します

  1. 新しい要件への対応:ソフトウェアの要件は、ビジネスニーズの変化、ユーザーフィードバック、技術の進歩など様々な要因で時間とともに変化します。コードが変更しやすければ、これらの新しい要件を満たすために迅速に適応することができます。

  2. バグの修正:どれだけ注意深くても、ソフトウェア開発にはバグが避けられません。コードが変更しやすければ、バグはより迅速かつ効率的に修正することができます。

  3. パフォーマンスの改善:ソフトウェアが使用され、成長するにつれて、パフォーマンスが問題になることがあります。コードが変更しやすければ、パフォーマンスの改善はより容易に行うことができます。

  4. 保守性:変更しやすいコードは、保守も容易です。これは、コードベースの大部分を理解し、修正することなく変更を加えることができるからです。

ETCを実現するための例

例:コードのデカップリング

ユーザーデータの管理とユーザー認証の両方を担当するUserクラスがあるとします。この設計では、Userクラスを変更することが難しくなります。なぜなら、ユーザーデータ管理に関連する変更が認証機能に影響を及ぼす可能性があり、その逆も同様だからです。

より良いアプローチは、これらの責任を2つの別々のクラス、つまりユーザーデータの管理を担当するUserManagerと認証を処理するAuthenticatorに分割することです。このようにすると、ユーザーデータ管理の変更はUserManagerクラスにのみ影響を及ぼし、認証の変更はAuthenticatorクラスにのみ影響を及ぼすため、コードが変更しやすくなります。

ETC原則を適用する前のコード

package main

import (
    "database/sql"
    "fmt"
)

type User struct {
    ID       int64
    Username string
    Password string
}

// SaveToDBメソッドはユーザーデータをデータベースに保存する
func (u *User) SaveToDB(db *sql.DB) error {
    stmt, err := db.Prepare("INSERT INTO users (username, password) VALUES (?, ?)")
    if err != nil {
        return err
    }
    defer stmt.Close()

    _, err = stmt.Exec(u.Username, u.Password)
    return err
}

// Authenticateメソッドはユーザー認証を行う
func (u *User) Authenticate(db *sql.DB, username, password string) (bool, error) {
    var user User
    err := db.QueryRow("SELECT id, username, password FROM users WHERE username = ?", username).Scan(&user.ID, &user.Username, &user.Password)
    if err != nil {
        return false, err
    }

    return user.Password == password, nil
}

func main() {
    // DB接続と使用コード
    fmt.Println("User management example")
}

問題点

  • Userクラスがデータ保存と認証の両方を担当しているため、責任が分かれていない。
  • 認証のロジックを変更する必要がある場合、Userクラス全体に影響を与える可能性がある。

ETC原則を適用した後のコード:

package main

import (
    "database/sql"
    "fmt"
)

type User struct {
    ID       int64
    Username string
    Password string
}

// UserManagerはユーザーデータの保存を担当
type UserManager struct {
    db *sql.DB
}

// UserManagerのSaveToDBメソッドはユーザーデータをデータベースに保存する
func (um *UserManager) SaveToDB(u *User) error {
    stmt, err := um.db.Prepare("INSERT INTO users (username, password) VALUES (?, ?)")
    if err != nil {
        return err
    }
    defer stmt.Close()

    _, err = stmt.Exec(u.Username, u.Password)
    return err
}

// Authenticatorはユーザー認証を担当
type Authenticator struct {
    db *sql.DB
}

// AuthenticatorのAuthenticateメソッドはユーザー認証を行う
func (a *Authenticator) Authenticate(username, password string) (bool, error) {
    var user User
    err := a.db.QueryRow("SELECT id, username, password FROM users WHERE username = ?", username).Scan(&user.ID, &user.Username, &user.Password)
    if err != nil {
        return false, err
    }

    return user.Password == password, nil
}

func main() {
    // DB接続と使用コード
    fmt.Println("User management example")
}

改善点

  • UserManagerクラスはユーザーデータの保存を担当し、Authenticatorクラスはユーザーの認証を担当するように分割しました。
  • これにより、ユーザーデータ管理の変更はUserManagerクラスにのみ影響を与え、認証の変更はAuthenticatorクラスにのみ影響を与えます。
  • コードの責任が明確に分かれることで、各部分の変更がしやすくなり、保守性が向上します。

このように、責任を分割することで、コードの変更がしやすくなり、保守性が向上します。初心者でも理解しやすいように、各クラスとメソッドの役割を明確に分けています。

Don't Repeat Yourself (DRY)

Don't Repeat Yourself (以下「DRY」) は、ソフトウェア開発における重要な原則の一つであり、コードの重複を避けることを目指します。この原則は、同じコードを何度も書くのではなく、一度書いたコードを再利用することを推奨します。

DRY原則を適用することで、以下のような利点があります

  1. 保守性の向上:コードの重複を避けることで、修正や改善が必要な場合に一箇所だけ変更すれば良くなります。これにより、コードの保守が容易になります。

  2. バグの減少:同じコードを何度も書くと、それぞれの場所でバグが発生する可能性があります。コードの重複を避けることで、このリスクを減らすことができます。

  3. コードの可読性の向上:コードが短く、シンプルになるため、他の開発者がコードを理解しやすくなります。

DRY原則を適用する前と後のコード例

DRY原則を適用する前のコード

func calculateAreaRectangle(length, width int) int {
    return length * width
}

func calculateAreaSquare(side int) int {
    return side * side
}

上記のコードでは、長方形と正方形の面積を計算するために、同じ乗算の操作を2回行っています。

DRY原則を適用した後のコード

func calculateArea(length int, width ...int) int {
    if len(width) == 0 {
        return length * length
    }
    return length * width[0]
}

上記のコードでは、calculateArea関数は長方形と正方形の両方の面積を計算することができます。正方形の場合、幅は指定せずに長さだけを引数として渡します。これにより、乗算の操作が一箇所にまとめられ、コードの重複が避けられています。

より複雑なシナリオでのDRY原則の適用

DRY原則を適用する前のコード

import (
    "net/smtp"
)

func sendEmailToUser(userEmail, subject, body string) {
    auth := smtp.PlainAuth("", "user@example.com", "password", "smtp.example.com")
    msg := "Subject: " + subject + "\n" + body
    smtp.SendMail("smtp.example.com:25", auth, "sender@example.com", []string{userEmail}, []byte(msg))
}

func sendEmailToAdmin(adminEmail, subject, body string) {
    auth := smtp.PlainAuth("", "user@example.com", "password", "smtp.example.com")
    msg := "Subject: " + subject + "\n" + body
    smtp.SendMail("smtp.example.com:25", auth, "sender@example.com", []string{adminEmail}, []byte(msg))
}

上記のコードでは、ユーザーと管理者にメールを送信するための関数がそれぞれありますが、重複するコードが多く含まれています。

DRY原則を適用した後のコード

import (
    "net/smtp"
)

func sendEmail(recipientEmail, subject, body string) {
    auth := smtp.PlainAuth("", "user@example.com", "password", "smtp.example.com")
    msg := "Subject: " + subject + "\n" + body
    smtp.SendMail("smtp.example.com:25", auth, "sender@example.com", []string{recipientEmail}, []byte(msg))
}

// 直接関数を呼び出す
sendEmail(userEmail, subject, body)
sendEmail(adminEmail, subject, body)

この改善されたコードでは、sendEmail関数を再利用することで、重複するメール送信のロジックを一箇所にまとめています。これにより、メール送信の方法を変更する場合、一箇所の変更で済むため、保守性が向上します。

DRY原則を適用することで、コードの重複を避け、保守性と可読性を向上させ、バグの発生を減らすことをできます。より複雑なシナリオでも、この原則を適用することでコードの品質を高めることをできます。

例1:

// 重複したコードの例
func validateEmail(email string) bool {
    // メールアドレスの検証ロジック
}

func validateUsername(username string) bool {
    // ユーザー名の検証ロジック
}

// 初めからDRYにしない
func validateUser(email string, username string) bool {
    if !validateEmail(email) {
        return false
    }
    if !validateUsername(username) {
        return false
    }
    return true
}

例2 (早まってDRYにする例):

// 抽象化しすぎた例
func validate(input string, inputType string) bool {
    switch inputType {
    case "email":
        // メールアドレスの検証ロジック
    case "username":
        // ユーザー名の検証ロジック
    default:
        return false
    }
    return true
}

例1は、DRY原則に違反しているように見えますが、メールアドレスとユーザー名の検証ロジックは本質的に異なる可能性があります。 もし将来的にユーザー名の検証に新しいロジックが必要になった場合、例1のコードでは簡単に追加できますが、例2のコードでは変更が侵襲的になりがちです。

Googleのブログによると、DRYに従う前に、コードが重複しているか、似ているだけかを確認し、コードの重複が問題を引き起こしているかどうかを考えることが重要です。

まとめ

わかりやすいコードを書くためには、以下のポイントに注意することが重要です

  1. Single Responsibility: 各コンポーネントが一つの役割を持つように設計する。
  2. 良い構造: 一貫性のある構造、明確な役割分担、適切な粒度を保つ。
  3. ネーミングの原則: わかりやすく、一貫性があり、簡潔な名前を使う。
  4. コメント: 「何をするのか」ではなく、「なぜそれをするのか」を説明する。
  5. ETC: コードを将来的に変更しやすくする。
  6. DRY: コードの重複を避ける。(注意しながら)

開発チーム全体の効率も向上するでしょう。
最後まで読んでいただいた方はぜひ試していただき、わかりやすいコードを量産してくれたら嬉しいです。

参考 (おすすめの本)

Discussion