🤔

CS放浪記 〜関数はなぜ抽象化すべきなの?〜

2021/08/23に公開

はじめに

こんにちは。ひろです。CS放浪記へようこそ!
記念すべき第1回は「関数の抽象化」について書きたいと思います。ここを理解することで、関数を抽象化するとは何か、なぜ関数を抽象化すべきなのかを理解し、よりわかりやすいコードが書けるようになると思うので覗いてみてください。

関数の抽象化とは何か

関数を抽象化するとは「関数において不必要な情報をできるだけ取り除き、一度に注目する概念を減らすこと」とここでは定義します。関数の抽象化を意識することで他の開発者が効率コードを読むことができるようになります。

例を見て関数の抽象化を理解する

実際に例を見て、関数の抽象化に触れてみましょう。「0~100までのランダムな数字を10回取り出し、その数字が偶数かどうかを判断し、偶数の時だけ配列に入れて返す」処理を作成することを考えます。処理の流れは以下になると思います。

  1. 0から100までのランダムな数字を取り出す
  2. 取り出した数字が偶数かどうかを判断する
  3. 偶数の場合、配列に追加する
  4. 上記を10回繰り返し、配列を返す

コードを書いてみる

関数の抽象化していないコード

実際にコードを書いてみましょう。まずは関数の抽象化をしていない処理から書いてみます。
(これから書くコードはGolangで記載しています)

package main

import (
	"fmt"
	"time"
	"math/rand"
)

func main() {
	rand.Seed(time.Now().UnixNano())
	var result []int
	for i:=0; i<10; i++ {
		rand_num := rand.Intn(101)
		if rand_num % 2 == 0 {
			result = append(result, rand_num)
		}
	}
	fmt.Println(result)
}

全ての処理がmainに記載されています。また、最初の処理であるrand.Seed(time.Now().UnixNano())が何をしているのかすぐには判断しづらいかと思います[1]。for文やif文もmain関数の中に記載されており、他の開発者からみてどの部分が重要なのかがわかりづらくなっていると思います。
このように、全ての処理をmain関数に記載すると、何の処理をしているのかすぐに判断できず、他の開発者からするとどこを注目して読むべきかわからないなどの問題を引き起こすことになります。

関数の抽象化を採用した書き方

先ほど見つけた問題を解決するために、関数の抽象化を使いましょう。解説は後ほど記載しますのでまずはコードを見てみましょう。

package main

import (
	"fmt"
	"time"
	"math/rand"
)

func main() {
	randInit()
	result := evenFilter()
	fmt.Println(result)
}

func randInit() {
	rand.Seed(time.Now().UnixNano())
}

func evenFilter() []int {
	var result []int
	for i:=0; i<10; i++ {
		rand_num := rand.Intn(101)
		if isEven(rand_num) == true {
			result = append(result, rand_num)
		}
	}
	return result
}

func isEven(num int) bool {
	return num % 2 == 0
}

簡単にコードを解説します。まずはmain関数から。この処理では、自作した関数とfmtライブラリの組み込みメソッドであるPrintlnしか使っていません。この処理の中では以下のことをしています。

  1. randInit()関数を呼び出し、乱数生成の準備をしている
  2. evenFilter関数を呼び出し、呼び出した結果をresultに入れている
  3. resultを出力している

先程のコードよりもmain関数の処理内容を理解するのに苦労しなくなったのではないでしょうか?main処理の中にfor文やif文をなくし、関数呼び出しのみにしたことでmain関数が何をしているのかがわかりやすくなったかと思います。これが関数の抽象化です。他の開発者がコードを理解するために必要な情報だけを記載し、不必要な情報は関数にしてブラックボックス化することによって可読性を上げることができます。
そのほかの関数も見てみましょう。randInit()関数は、乱数の設定をしている処理で、具体的には乱数の元になるシードを与えています。evenFilter()関数は乱数を受け取って偶数のみを抽出して結果を返しています。isEven()関数は偶数かどうかを判定しています。処理だけだと何をしているのかぱっと見でわからないところが、関数名をつけて呼び出すように変更することで他の開発者にとっても何をしているのかがわかりやすいコードを書くことができます。

なぜ関数の抽象化をするのか

これまで例を見て関数の抽象化とは具体的に何をしているのかを見てきました。それではなぜ関数を抽象化するのでしょうか?関数を抽象化することでコードのステップ数が増えることが多いので、短い行数でまとめて書いたほうがいいのではないかという方もいるかと思います。しかし、ステップ数が増えるデメリットよりも関数を抽象化するメリットの方が大きいので関数を抽象化します。関数を抽象化するメリットは以下です。

  1. main関数を読むだけで処理の流れがわかるようになる
  2. 適切に関数を作成することで単一責任を守ることができる
  3. 処理を関数として一般化することで再利用でき、DRYを守ることができる

それぞれ詳しく見てみましょう。

main関数を読むだけで処理の流れがわかるようになる

例の中でもふれましたが、関数を抽象化することでmain関数を読むだけで何をしているのかがわかるようになります。これを実現するためには「処理の流れを理解するときに不必要な細かい処理を関数に切り出す」「切り出した処理が何をしているのかわかるような関数名にする」ことを意識しなければいけませんが、実現できたなら可読性の高いコードになり、他の開発者に感謝されるでしょう。実際に実務でも「main関数の中には関数、メソッド呼び出しのみとする」旨のコーディングルールが採用されている現場もあります[2]

適切に関数を作成することで単一責任を守ることができる

コンピュータサイエンスで重要な言葉として、「単一責任」という言葉があります。意味は単純で「関数が単一のタスクだけを行う」ことを指します。一つの関数にいろんな処理が入ると、関数を修正するときの影響範囲が増え、影響調査やテストの工数も増えてしまいます。単一責任を守ることで、修正範囲を限定的にすることができます。

処理を関数として一般化することで再利用でき、DRYを守ることができる

これまたコンピュータサイエンスで重要な言葉である「DRY」が出てきました。これは「同じ処理を複数の箇所に記載することを避けること」を意味します。同じ処理を何回も書くと、効率が悪いですし、その処理が複雑だととても読みづらいコードになってしまうので、関数として一般化し、それを呼び出すようにすることでDRYを守った読みやすいコードになります。また、もしその関数の処理を修正する必要が出たとしても、修正する箇所は関数のみになりますから、影響範囲が特定しやすくなることも関数として一般化するメリットになると思います。

さいごに

最後まで見てくださってありがとうございました。感想や誤字脱字、認識の誤りなどございましたらコメントに記載いただけると嬉しいです。

脚注
  1. Golangに慣れている方は「基礎の基礎だろ」と思う方も多くいらっしゃると思いますが、言語によっては乱数の生成処理が異なるのでこのように記載いたしました。 ↩︎

  2. 私の現場ではこのルールが適用されています。ただ、あまり守られてはいないのでとてもコードが読みづらく、毎回読むのに苦労します。。。 ↩︎

Discussion