🧐

CS放浪記 〜関数を抽象化するために意識していること〜

2021/08/24に公開

はじめに

こんにちは。ひろです。CS放浪記へようこそ!
前回に引き続き「関数の抽象化」について書きたいと思います。前回は「なぜ関数を抽象化するのか」にフォーカスを当てて記事を書きました。今回はHowの部分として「関数を抽象化するために意識するべきこと」について解説していきたいと思います。少し長めのサンプルコードを題材に、関数を抽象化する過程を通してどうやって関数を抽象化するといいのかを考えていきたいと思います。

何を意識すべきなのか

サンプルコードを書く前に、関数を抽象化する中で何を意識すべきなのかを書きます。色々な意見があると思いますが、私が意識しているのは以下の4つです。

  1. DRYを守っているか
  2. 単一責任を守っているか
  3. 必要によって関数を組み合わせているか
  4. 関数名、引数名、戻り値は適切か

サンプルコードを改善していく過程を見ながらこれらを深掘りしていきましょう。
今回のサンプルコードは「遊園地の入園料計算システム」です。この遊園地では、以下のような仕組みで料金が設定されています。

  • 引数は「次の日の曜日」、「会員番号(会員でない場合は0)」、「入場人数」の3つ
  • 人数によって料金が変わります。5人以下なら1人4500円、6人以上15人以下なら4000円、16人以上なら3500円になります。
  • 次の日が土日かどうかでも料金が決まります。休日であれば料金が1000円割増になります。ただし、会員番号が閏年の場合、割増は適用されません。
  • 会員であれば、料金が変わる可能性があります。会員番号が閏年の場合、料金から500円引いた額になります。

それではコードを見ていきましょう。全てを1つの関数にまとめたコードから見ていきます。

package main

import "fmt"

// 遊園地の入場料を計算する
func func1(a string, b, c int) int {
	var fee int
	// 入場料金を決める
	if c <= 5 {
		fee = 4500 * c
	} else if c >= 6 && c <= 15 {
		fee = 4000 * c
	} else {
		fee = 3500 * c
	}
	// 土日で会員番号が閏年でないなら割増
	if a == "Saturday" || a == "Sunday" {
		var leapYearFlg bool
		// 閏年かどうか判定
		if b%4 == 0 {
			if b%400 == 0 {
				leapYearFlg = true
			} else if b%100 == 0 {
				leapYearFlg = false
			} else {
				leapYearFlg = true
			}
		} else {
			leapYearFlg = false
		}
		// 閏年でなければ割増
		if leapYearFlg == false {
			fee += 1000
		}
	}
	// 会員で閏年の場合、500円引き
	if b > 0 {
		var leapYearFlg bool
		if b%4 == 0 {
			if b%400 == 0 {
				leapYearFlg = true
			} else if b%100 == 0 {
				leapYearFlg = false
			} else {
				leapYearFlg = true
			}
		} else {
			leapYearFlg = false
		}
		if leapYearFlg == true {
			fee -= 500
		}
	}
	return fee
}

func main() {
	fmt.Println(func1("Sunday", 2000, 12))
	fmt.Println(func1("Saturday", 1900, 2))
	fmt.Println(func1("Monday", 2024, 20))
}

とてつもなく読みづらく、何がしたいのかわかりづらいコードが完成しました。これからこのコードを綺麗にしていきます。以下の手順で綺麗にしていきます。

  1. DRYに従って重複する処理を関数にまとめる
  2. 単一責任に従って関数を分解する
  3. 関数を組み合わせる
  4. 関数名、引数名、戻り値の型を適切に決める

それでは1つずつ見ていきましょう。

DRYに従って重複する処理を関数にまとめる

まずは重複する処理をまとめていきます。上の処理で重複している処理は「閏年を判定する処理」です。しかもこの処理はそれなりにステップ数がありますし、if文が入れ子になっている箇所も存在するため非常に読みづらくなっています。まずはこの処理を関数にすることで繰り返しになっている部分を解消していきます。どうやって関数にするかですが、今回は「int型の引数を受け取り、その数字が閏年かどうかbool型で返す」関数を定義することにします。それでは改善していきましょう。まずは閏年を判定する関数です。とりあえず関数名は「func2」にしておきます。

// 閏年を判定する
func func2(num int) bool {
	if num%4 == 0 {
		if num%400 == 0 {
			return true
		} else if num%100 == 0 {
			return false
		} else {
			return true
		}
	} else {
		return false
	}
}

次に、入園料を計算する関数を修正します。重複している処理を関数呼び出しに修正し、それに伴い一部修正を加えます。

// 遊園地の入場料を計算する
func func1(a string, b, c int) int {
	var fee int
	// 入場料金を決める
	if c <= 5 {
		fee = 4500 * c
	} else if c >= 6 && c <= 15 {
		fee = 4000 * c
	} else {
		fee = 3500 * c
	}
	// 土日で会員番号が閏年でないなら割増
	if a == "Saturday" || a == "Sunday" {
		// 閏年でなければ割増
		if func2(b) == false {
			fee += 1000
		}
	}
	// 会員で閏年の場合、500円引き
	if b > 0 {
		if func2(b) == true {
			fee -= 500
		}
	}
	return fee
}

重複がスッキリしたことで複雑さも改善しました。重複していた処理を関数に切り出したことで前よりもコードが読みやすくなりましたね。この改善から、なぜ重複を取り除く必要があるか理解できたと思います。同じ処理を何回も書いている処理があれば、関数にまとめましょう。

単一責任に従って関数を分解する

次に取り組み改善は、単一責任に従った関数分解です。入園料を計算する関数は重複した処理を別の関数にまとめたとはいえ、まだまだ単一責任が守られていません。なぜなら、この関数のタスクは「入園料を決定する」なのに、「人数によって入園料を決める」、「休日割増が適用されるか判定する」、「会員割引が適用されるか判定する」処理の詳細が書かれているからです。今回はこれらの処理を別の関数にしていきます。それぞれの処理を別の関数にすることで各関数のタスクが 1つになり、単一責任を守ることができます。
それでは実際に見て行きましょう。まずは「人数によって入園料を決める」、「休日割増が適用されるか判定する」、「会員割引が適用されるか判定する」処理をそれぞれ別の関数にしていきましょう。それぞれ「func3」、「func4」、「func5」とします。
まずはfunc3から書いていきます。func1の入場料金を決める分岐をそのまま移植し、分岐の中をreturnに変更します。

// 人数によって入園料を決める
func func3(num int) int {
	if num <= 5 {
		return 4500 * num
	} else if num >= 6 && num <= 15 {
		return 4000 * num
	} else {
		return 3500 * num
	}
}

次にfunc4です。休日割増が適用されるか判定する処理の中は「休日かどうか判定する」処理と「会員番号が閏年かどうか判定する」処理が同居しています。これでは単一責任にはならないため、「休日かどうか判定する」処理のみを切り出しましょう。切り出した結果は以下になります。

// 休日かどうか判定する
func func4(str string) bool {
	return str == "Saturday" || str == "Sunday"
}

次はfunc5です。会員割引が適用されるか判定する処理です。func4と同様、「会員かどうか」と「会員番号が閏年か」が同居しているので、これも「会員かどうか」を判定する処理のみ切り出すことにします。切り出した結果は以下になります。

// 会員かどうか判定する
func func5(num int) bool {
	return num > 0
}

次に、入園料を計算する関数を修正します。関数呼び出しに修正し、それに伴い一部修正を加えます。

// 遊園地の入場料を計算する
func func1(a string, b, c int) int {
	// 人数によって入園料を決める
	fee := func3(c)
	// 土日で会員番号が閏年でないなら割増
	if func4(a) == true && func2(b) == false {
		fee += 1000
	}
	// 会員で閏年の場合、500円引き
	if func5(b) == true && func2(b) == true {
		fee -= 500
	}
	return fee
}

初めとは見違えるくらいコードが綺麗になったと思います。これだとコメントがあれば何をしているのかわかるようになってきました。単一責任を守ってコードを書くことで、各関数が何をしているのかがわかりやすくなり、かつその関数を理解するために必要な情報のみ記載されるようになることでコードを読む他の開発者にも意図がはっきりわかるようになります。

関数を組み合わせる

DRY原則、単一責任に従って関数を作成することで、全てをmain関数に記載していた頃よりもわかりやすくなりました。しかし、まだ改善できるところがあります。今回改善するところは、func1のif文です。func1のif文は、2つの条件を「&&」でつなげているところがあります。この部分はいじらなくてもいいかもしれませんが、初見でどんな分岐なのかわからないと思う方もいるかもしれません。そんな人のために、今回は関数を新たに作成します。作る関数は以下の2つです。

  1. 休日でかつ会員番号が閏年ではないかどうか判定
  2. 会員でかつ会員番号が閏年かどうか判定

分岐自体はそこまで難しくないので早速関数を作ってみましょう。例に倣って関数名はそれぞれ「func6」「func7」とします。

// 休日かつ会員番号が閏年かどうか判定する
func func6(str string, num int) bool {
	return func4(str) == true && func2(num) == false
}
// 会員でかつ会員番号が閏年かどうか判定する
func func7(num int) bool {
	return func5(num) == true && func2(num) == true
}

次はこの関数をfunc1で呼び出します。

// 遊園地の入場料を計算する
func func1(a string, b, c int) int {
	// 人数によって入園料を決める
	fee := func3(c)
	// 土日で会員番号が閏年でないなら割増
	if func6(a, b) == true {
		fee += 1000
	}
	// 会員で閏年の場合、500円引き
	if func7(b) == true {
		fee -= 500
	}
	return fee
}

if文がスッキリしてとても読みやすくなりました。関数を分解するだけでなく組み合わせることで、新たな意味を持った関数を作成でき、コードの情報を必要な部分だけにすることができます。ここまできたらほぼ完成ですが、最後にやらなければならない仕上げがあります。

関数名、引数名、戻り値の型を適切に決める

最後の仕上げは名前についてです。今まで、関数名、引数名については言及しませんでした。しかし、今まで作った関数は全て「funcN」の形になっています。これだと何の関数なのか、関数の中を見ないとわかりません。また、引数もabcなど、特に意味のないものにしていました。これでは他の開発者が「何のために使われる引数なんだろう?」と困惑してしまいます。せっかく関数を抽象化してコードをスッキリさせたとしても、名前が適切でないと結局なんのための処理かわからないコードになってしまいます。最後の仕上げとして、関数名、変数名を適切に決めていきましょう。
まずはfunc2~func7の関数名、引数名を修正していきましょう。それぞれの関数の役割は「閏年かどうか判定する」、「人数によって入園料を決める」、「休日かどうか判定する」、「会員かどうか判定する」、「休日かつ会員番号が閏年かどうか判定する」、「会員でかつ会員番号が閏年かどうか判定する」なのでそれぞれ相応しい名前に変更します。

// 閏年かどうか判定する
func isLeapYear(year int) bool {
	if year%4 == 0 {
		if year%400 == 0 {
			return true
		} else if year%100 == 0 {
			return false
		} else {
			return true
		}
	} else {
		return false
	}
}
// 人数によって入園料を決める
func culcBasicPrice(numberOfPeople int) int {
	if numberOfPeople <= 5 {
		return 4500 * numberOfPeople
	} else if numberOfPeople >= 6 && numberOfPeople <= 15 {
		return 4000 * numberOfPeople
	} else {
		return 3500 * numberOfPeople
	}
}
// 休日かどうか判定する
func isHoliday(day string) bool {
	return day == "Saturday" || day == "Sunday"
}
// 会員かどうか判定する
func isMember(membershipNumber int) bool {
	return membershipNumber > 0
}
// 休日かつ会員番号が閏年かどうか判定する
func isHolidayAndLeapYear(day string, membershipNumber int) bool {
	return isHoliday(day) == true && isLeapYear(membershipNumber) == false
}
// 会員でかつ会員番号が閏年かどうか判定する
func isMemberAndLeapYear(membershipNumber int) bool {
	return isMember(membershipNumber) == true && isLeapYear(membershipNumber) == true
}

それぞれの関数が何の意味を持ち、どのような引数を持つのかが明確になりました。最後にfunc1の関数名、引数名を修正します。この関数は「遊園地の入場料を計算する」関数なので、「culcEntranceFee」としましょう。引数も「曜日」、「会員番号」、「人数」なのでそれぞれ「day」、「membershipNumber」、「numberOfPeople」とします。

// 遊園地の入場料を計算する
func culcEntranceFee(day string, membershipNumber, numberOfPeople int) int {
	fee := culcBasicPrice(numberOfPeople)
	if isHolidayAndLeapYear(day, membershipNumber) == true {
		fee += 1000
	}
	if isMemberAndLeapYear(membershipNumber) == true {
		fee -= 500
	}
	return fee
}

どうでしょう?関数名や引数名に意味を持たせることで、それぞれの関数が何をしているのか関数名を見るだけである程度わかるようになったと思います。関数名や引数名を適切にすることは、他の開発者が初めてコードを読んだ時でも処理のイメージがつきやすくなるため、是非とも意識してほしいところです。また、関数を分解したり合成したりを適切に行うためには知識と経験が必要ですが、関数名や引数名を適切なものにすることはすぐに意識できることだと思うので、今日から意識してみてください。

さいごに

これにて関数の抽象化は終了です。今回はかなりボリューミーな内容となっていますが、どうやって関数を抽象化するのかを理解し、他の開発者がコードを読むときに困らないために何を意識すればいいか明確になっていただけると幸いです。
次回からは再帰を扱います。繰り返しの処理を簡潔に書くためには再帰は必要な知識です。初めはとっつきにくい概念ですが、理解できるよう頑張っていきましょう。
感想や誤字脱字、認識の誤りなどございましたらコメントに記載いただけると嬉しいです。それではまた次回お会いしましょう。アディオス!

Discussion