対象読者
- 理解しやすい、良いコードを作りたい方
- 文法を使いこなすことはできるが、保守しやすいコードを作れない方
結論
関数(function)とは処理をまとめ上げる仕組みである。プログラミングの文法の初級で登場する基本中の基本であり、使い方も簡単だ。
しかし侮ることなかれ!関数を適切に使いこなせていないプログラマは少なくない。文法を知っているだけで、関数を使う意味やメリットを理解していないからだ。
関数の適切な使用は良いプログラムを作る様々なテクニックの土台であり、かなり重要だ。
本記事を要約すると、「関数の適切な使用によって、煩雑な処理を関数の呼び出し側(クライアント)から隔離することで認識負荷を下げて、プログラムを理解・保守しやすくできる」ということだ。
何を言っているか分からないと思う。そこで、本記事は文法の背景にある考え方を例を通して示し、その後に、関数の不適切な使用(アンチパターン)を述べることで、理解を補足する。
関数とは手続きをまとめる仕組み
関数とは、数学では入力と出力の対応関係である。しかし、プログラミングの関数は別モノだ。でも似たところもある。
プログラムとは、データを変換する過程と捉えることができる。
データ変換って数学の関数と似ている。なぜなら、両者とも入力から何かしらを出力するからだ。しかし、プログラミングの関数は出力がない場合もある。データ変換の準備や補助を行う部分も必要だからだ。
データ変換やそれに関連する処理を「手続き」と呼ぶことにする。プログラムは手続きの系列として表現できる。この捉え方を「手続き型プログラミング」と呼ぶ。
プログラミングの関数(以降、単に関数と書く)とは、複数の手続きを1つにまとめる仕組みだ。ちなみに、関数には色んな別名がある。サブルーチン、プロシージャ、メソッド、などだ。ややこしい。
別々の手続きを1つにまとめるためには、以下の4つの構成要素が必要となる。
- 複数の手続きを詰め込められる入れ物(関数スコープ)
- その入れ物を指示するための名前(関数名)
- 入れ物内への入力機能(引数)
- 入れ物外への出力機能(「戻り値」または「返り値」)
しかし、いきなり関数の構成要素を並べ立てられても理解しづらいので、まずは例を見ていく。
関数の定義・使用の例
複数の手続きを入れ物(関数スコープ)に詰め込んでみる。その入れ物に名前をつけることで関数を定義できる。
手続き1
手続き2
手続き3
↓
function 関数名(){
手続き1
手続き2
手続き3
}
関数を定義することによってまとめられた手続きは1行だけ書けば実行できるようになる(関数呼び出し)。例えば、以下ではもとは9行必要だったのが3行で済むようになる。
関数名()
関数名()
関数名()
関数を定義することで呼び出し側(クライアント)から見て、書く量が減らせられることが分かる。だが、それは重要なことだろうか。
実はメチャクチャ重要だ。現場で用いられるコードはもっと複雑だ。色んな手続きがごちゃ混ぜになっていたら、そのコードが何をしているのか理解しづらくなる。関数を用いて、処理のまとまりを隔離することで、コードをスッキリできる。認識しなければいけない情報量を抑えること(抽象化)がプログラミングの様々なテクニックのエッセンスだ(詳細はこちら)。
では、手続きを具体化して議論を深めていく。
戻り値の導入
以下は三角形の面積を計算する関数だ。手続きを擬似的かつテキトーに書いてみる。
function calc_triangle_area(){
p1, p2, p3 = とある3つの座標を取得
line1 = p1とp2による線分
line2 = p1とp3による線分
len1 = line1の長さ
len2 = line2の長さ
theta = line1とline2が成す角
area = len1 * len2 * sin(theta) / 2
}
変数areaに三角形の面積の計算結果を格納した。でも、計算結果は関数内部に存在するだけであり、関数を呼び出しても、面積を得ることができない。面積を関数外部へ渡す必要がある。そこで、最後の行だけ以下のように書き換えて戻り値を導入する。
area = len1 * len2 * sin(theta) / 2
↓
return len1 * len2 * sin(theta) / 2
この関数を呼び出せば、辺の長さや角度を求めるような煩雑な手続きを気にせずに三角形の面積を求めることができる。
area1 = calc_triangle_area()
area2 = calc_triangle_area()
area3 = calc_triangle_area()
引数の導入
しかし、現状では特定の三角形の面積を計算できるだけで、汎用性がない。area1,2,3は全て同じ値である。
別の三角形の面積を求めたくなった時、新たに関数を定義しなければならないのだろうか。バカらしい。そこで引数の出番だ。
今、汎用性が失われているのは、三角形の頂点座標が固定されているからだ。故に、頂点座標を外部から与えるように書き換えれば、色んな三角形の面積を計算できるようになる。
function calc_triangle_area(p1, p2, p3){
//消去 p1, p2, p3 = とある3つの座標を取得
line1 = p1とp2による線分
line2 = p1とp3による線分
len1 = line1の長さ
len2 = line2の長さ
theta = line1とline2が成す角
return len1 * len2 * sin(theta) / 2
}
area1 = calc_triangle_area(p11, p12, p13)
area2 = calc_triangle_area(p21, p22, p23)
area3 = calc_triangle_area(p31, p32, p33)
座標毎に面積ができるようになった。良い感じ。
関数スコープ・ローカル変数
三角形の面積を計算する過程で、頂点座標、辺の長さ、2辺の成す角度など、いろんな値を扱う必要があった。
この例では擬似的に手続きを書いたため簡略化されているが、実際はもっと多くの変数が必要だ。例えば、辺の長さは座標の各成分の差分を取って2乗和を計算しなければならない。これらの複雑な計算を進める上で、計算途中の値を一時的に格納する変数が必要になるかもしれない。
しかし、計算過程の出発点は頂点座標であり、ゴールは三角形の面積だ。それ以外の細かな情報は関数の目的とは直接関係ないため、関数外部へ見せたくない。詳細を隠してコードの複雑さを隔離し、プログラミングの目的に集中できるようにすることが適切な関数の使い方だ。
重要なのは、関数の目的にとって本質的な変数と手続きの過程でのみ使われる一時的な変数を区別することだ。本質的な変数を引数や戻り値として設定するべきだ。
プログラミングでは、操作する対象を指示するために名前を扱う。この例でも、関数名を書くことで、呼び出したい関数を指定している。しかし、関数の定義が有効範囲(例えば同じファイル内)になかった場合、関数呼び出しは失敗してしまう。名前が有効な範囲内で定義されていなければ、その対象を指示することができない。名前の有効範囲を「スコープ」という。
関数はスコープを持っており、関数スコープ内の変数を「ローカル変数」という。ローカル変数は関数呼び出しが終わったら破棄されるし、関数の外へ影響することもない。故に、ローカル変数によって影響範囲を限定することでクライアントは関数の実行による外部への影響を気にせずに済む。関数を理解するためには関数スコープ内部のみを確認すれば良くなる。本質的でない一時変数はローカル変数にすべきだ。
ローカル変数の定義は言語ごとに異なる。いくつかのローカル変数の宣言の仕方を以下に示す。
// JavaScript
var 変数名 = 値
//bash
local 変数名=値
関数内に複雑な手続きがたくさんあったとしても、ローカル変数によって、使い捨ての変数であることを明示でき、理解しやすいコードを作ることができる。
インターフェース
関数スコープによって複雑さを閉じ込めることが分かりやすいコードに繋がる。しかし、それだけでは不十分だ。
関数の処理内容が関数定義を読まなくてもイメージできなければならない。でなければ、処理を関数として隔離した意味がない。関数を理解するために関数の中身を確認しなければならないのならば、把握しなければならない情報量を減らすことができないからだ。
関数名と引数を確認しただけで処理内容やどんな戻り値が返ってくるかイメージできるようにしなければならない。そのためには、意図を明確に語るような関数名やそれに裏打ちされた引数が必要だ。
関数スコープとその関数の呼び出し側(クライアント)の境目を「インターフェース」と呼ぶ。別の言い方をすれば、インターフェースとは、クライアントから見ることができる関数の姿だ。関数のインターフェースは以下の2つまたは3つの構成要素から成る。
- 関数名
- 引数の情報(e.g. データ型のリスト)
- (戻り値の型)
三角形の面積計算の関数では、”calc_triangle_area”という関数名から返り値は三角形の面積だと推定することができるし、3つの座標を引数とすることも納得することができるはずだ。
C++やJavaのような変数の型を厳格に定義しなければならない言語を静的型付けの言語と呼ぶが、型の情報がインターフェースで明示されると、関数の意図がより明確となる。
しかし、引数が多くなり過ぎないように注意すべきだ。なぜなら、
- 引数の数だけ関数内の処理は複雑になり、動作をイメージしづらくなる
- 引数に何を与えればよいか分かりづらくなり、関数呼び出しの使い勝手が悪くなる
からだ。
このように良いインターフェースならば、安心して詳細を意識せずに関数を利用することができる。
関数のアンチパターン
以上で関数を適切に使うための考え方とそれを実現するための仕組みを論じた。
要約すると、関数は意図を語る明確なインターフェースによって、詳細を確認することなく動作を理解できるようにすることで、関数の呼び出し側(クライアント)は煩雑な詳細の海に溺れずに済む。そのための仕組みが引数や戻り値、関数スコープであった。
関数を不適切に使用すると、折角の関数によるコードの分離が有効でなくなる。このような過ちを犯してしまうプログラマは少なくない。
そこで、以下では関数を適切に使いこなせていない例(アンチパターン)を示す。それぞれの例にいちいち名前を付けて区別しているが、同じ問題に別の言い回しをしているに過ぎないことに注意だ。
グローバル変数
ローカル変数に対して、どこからもアクセスできるような変数を「グローバル変数」と呼ぶ。
グローバル変数にアクセスするような関数はイケてない。なぜなら、影響範囲を限定し、手続きを分離することでコードを理解しやすくする仕組みである「関数スコープ」を活かせなくなるからだ。
function 関数名(arg1, arg2, ...){
global var;
//varの値を用いた処理
}
グローバル変数を使うことで、関数の実行結果を予想することは難しくなる。なぜなら、その関数の実行結果はグローバル変数の値に依存しており、以前に関数呼び出しがあったか、またはそのグローバル変数の更新がないかを把握する必要が生じるからだ。
一方、グローバル変数が定数であれば、実行結果を予測することは簡単だ。しかし、この関数がどのグローバル変数に依存しているかはクライアントからは知る由もなく、結局、関数の処理結果を理解するためには、関数定義を確認する必要に迫られる。これを怠れば、グローバル変数の値を変更した際に、予期せぬプログラムの振舞いの変化に戸惑うことになるかもしれない。
多すぎる引数
グローバル変数を使いたくなる動機は以下が考えられる。
- 複数の関数の引数が共通しており、いちいちその変数を引数で与えるのが面倒
- 引数が多すぎて、いちいち引数を指定するのが面倒
規模が小さいコードならば、グローバル変数によって手間を省けるかもしれない。しかし、コードが大規模な場合、関数呼び出しはグローバル変数の値の推移を追うコストが大きくなってしまい、割に合わない。
グローバル変数を導入する方が関数を簡単に書けるのならば、そもそも関数定義が不適切なのかもしれない。多くの引数が必要ならば、
- 引数を辞書や構造体のような、複数の値をまとめた変数を引数として与える
- 関数を小分けにする
- オブジェクト指向のクラスを導入する
等の対策が必要だ。
長すぎる関数
関数は詳細な手続きを隠蔽するものであった。しかし、隠蔽すればよい、ということではない。隠蔽してもなお、関数の動作をイメージできなければならない。故に、おおよそ30-50行を超えるような長すぎる関数は間違っている。
目的を達成するためには様々な手続きが必要かもしれない。しかし、その関数を構成する処理はより細かく分割できないだろうか。ある目標を小さな目標に分割することと同じように。
うまく手続きが分割されていれば、元々長すぎる関数はより細かい関数呼び出しの組み合わせとして表現し直せる。その結果、関数定義は30-50行を超えることはなくなる。
三角形の面積計算の例でも、2辺を成す角度の計算は別の関数として分離できるはずだ。2つの座標を引数として、辺の長さを返すような関数が作れるはずだ。
そうやって分離した三角形の面積計算の関数は例で示した擬似言語のように簡潔であり、容易に理解できる。
再利用のためだけに定義された関数
書籍やネット上の情報では、関数は「再利用のために手続きをまとめる仕組み」だと解説されることが多いように思う。間違ってはいないが、核心からずれている。
繰り返すが、関数とはある目的に関連した複雑な処理を隔離するために使うべきなのだ。そのために関数を定義した結果、処理を再利用できるのであって、再利用のために関数を作るのではない。
2度以上使われない(再利用されない)としても、関数化は処理のまとまりを分離し、可読性を高める。
再利用のためだけに関数を定義すると、いろんな目的がごちゃ混ぜになって理解しづらい関数、つまり「長すぎる関数」を容認することに繋がる。
そもそも、再利用できるためには関数は汎用的でなければならない。汎用性を上げるための仕組みが引数であった。
長すぎる関数は本来ならば、より小さな関数の組み合わせに分割できることは上述の通りだ。分割によって作られた小さな関数はそれぞれ引数を持っているだろう。
再利用のためだけに定義された関数は本来、さらに分割できたはずのものを怠ったものであり、引数の総数は本来よりも少なくなる。故に汎用性を高める機会を失ってしまい、再利用する機会も失ってしまう。なんだか逆説的だ。
長すぎる関数を全く同じように再利用できる場面は少なく、それぞれ微妙に異なったやり方で再利用したくなることが常だ。鋭く絞った目的ごとに小さな関数を作り、組み合わせることで、微妙な違いがある中でも再利用を実現することができる。
コメント