関数
基本型がプログラムデータの構成要素であるように、関数はプログラムの動作の構成要素である。
関数は、プログラミングの職人技とも密接に関連している。経験豊富なプログラマーが書く関数は、簡潔で、シンプルで、明確だ。これは逆も同様で、プログラムのより小さな構成要素を特定して記述しようとする行為そのものが、より優れたプログラマーを育てる。
これまでの章では、基本的な文と式について説明した。後の章ではさらに多くの文や式を扱うが、これまで見てきたものは Dでよく使われる機能だ。しかし、それだけでは大規模なプログラムを書くには不十分だ。これまで書いたプログラムはどれも非常に短く、言語の単純な機能しか示していない。関数を使わずに、ある程度複雑なプログラムを書こうとすると、非常に困難でバグが発生しやすくなる。
この章では、関数の基本的な機能についてのみ説明する。関数については、以下の章で詳しく説明する。
関数は、文や式をプログラム実行の単位としてまとめる機能だ。こうした文や式は、その集合が達成する内容を表す名前が付けられる。その名前を使って、関数を呼び出す(実行する) ことができる。
一連のステップに名前を付けるという概念は、私たちの日常生活でもよく見られる。例えば、オムレツを作るという行為は、次のステップで一定の詳細度で表現することができる。
- フライパンを用意する
- バターを用意する
- 卵を用意する
- コンロをつける
- フライパンを火にかける
- フライパンが熱くなったらバターを入れる
- バターが溶けたら卵を入れる
- 卵が焼けたらフライパンを火から下ろす
- コンロの火を消す
これほど詳細な手順は明らかに過剰なので、関連する手順は1つの名前でまとめる:
- 準備をする(フライパン、バター、卵を用意する)
- コンロをつける
- 卵を焼く(フライパンを火にかけるなど)
- コンロを消す
さらに進めると、すべての手順を単一の名称で表すこともできる:
- 1つの卵のオムレツを作る(すべてのステップ)
関数も同様の概念に基づいている。つまり、全体としてまとめて名前を付けることができる手順をまとめて、関数を作成する。例として、メニューを表示するタスクを実行する次のコード行から始めよう。
これらの行をまとめてprintMenu
と命名するのが理にかなっているので、次の構文を使用して、これらの行をまとめて関数にすることができる。
この関数の内容は、その名前を使用するだけで、main()
内から実行することができる。
printMenu()
とmain()
の定義の類似性から、main()
も関数であることは明らかだ。Dプログラムの実行は、main()
という名前の関数から始まり、そこから他の関数に分岐する。
パラメーター
関数の能力の一部は、その動作がパラメータによって調整できることに由来している。
オムレツの例を続けて、オムレツを 1 枚ではなく 5 枚作るように変更しよう。手順はまったく同じで、使用する卵の数が異なるだけだ。上記のより一般的な記述を、それに応じて変更することができる。
- 準備をする(フライパン、バター、5つの卵を用意する)
- コンロを点ける
- 卵を炒める(フライパンを火にかけるなど)
- コンロを消す
同様に、最も一般的な単一のステップは次のようにになる:
- 5個の卵のオムレツを作る(すべてのステップ)
今回は、一部のステップに関する追加情報がある:"卵を5個用意する"、"卵を炒める"、"5つの卵のオムレツを作る"。
関数の動作は、オムレツの例と同じように調整することができる。関数の動作を調整するために使用する情報は、パラメータと呼ばれる。パラメータは、コンマで区切られた関数パラメータリストで指定する。パラメータリストは、関数の名の後に続く括弧の中に記述する。
上記のprintMenu()
関数は、常に同じメニューを表示するため、パラメータリストは空で定義されている。メニューは、状況に応じて異なる表示をする必要がある場合があるとする。例えば、その時点で実行されているプログラムの部分に応じて、最初の項目を"Exit"ではなく"Return"と表示したほうがいい場合などである。
このような場合、メニューの最初の項目をパラメータ化して、パラメータリストで定義することができる。そうすることで、関数はリテラルではなく、そのパラメータの値を使用する。 "Exit"
:
firstEntry
パラメータが伝える情報はテキストの一部であるため、その型はパラメータリストでstring
として指定されていることに注意。これで、この関数を異なるパラメータ値で呼び出して、最初のエントリが異なるメニューを表示することができるようになった。必要なことは、関数が呼び出されている場所に応じて、適切なstring
値を使用することだけだ。
注釈: string
型のパラメータを持つ独自の関数を作成して使用する場合、コンパイルエラーが発生する可能性がある。上記のprintMenu()
は、char[]
型のパラメータ値では呼び出すことができない。例えば、次のコードはコンパイルエラーになる。
一方、printMenu()
をパラメーターとしてchar[]
を取るように定義した場合、string
のような値で呼び出すことはできない。 "Exit"
。これは、不変性(immutability)とimmutable
キーワードの概念に関連しており、これらは次の章で説明する。
メニュー関数について続けてみよう。メニューの選択番号を常に0から開始するのは適切ではないとする。その場合、開始番号を2番目のパラメータとして関数に渡すこともできる。関数のパラメータはコンマで区切らなければならない。
これで、関数に開始番号を指定することができるようになった。
関数の呼び出し
関数のタスクを実行するために関数を起動することを、関数の呼び出しと呼ぶ。関数の呼び出しの構文は次の通りである。
関数に渡される実際のパラメータの値は、関数引数と呼ばれる。文献では、パラメータと 引数は同じ意味で使用されることもあるが、これらは異なる概念を表している。
引数は、パラメータが定義されている順に1つずつパラメータと照合される。例えば、上記のprintMenu()
の最後の呼び出しでは、引数"Return"
と1
を使用しており、それぞれパラメータ firstEntry
とfirstNumber
に対応している。
各引数の型は、対応するパラメータの型と一致する必要がある。
作業を行う
これまでの章では、式を"作業を行う実体"と定義してきた。関数呼び出しも式であり、何らかの作業を行う。作業を行うということは、値を生成すること、あるいは副作用を持つことを意味する。
- 値を生成する: 一部の操作は、値のみを生成する。例えば、数値を加算する関数は、その加算の結果を生成する。別の例としては、学生の名前と住所を使用して
Student
オブジェクトを作成する関数は、Student
オブジェクトを生成する。 -
副作用を持つ:副作用とは、プログラムまたはその環境の状態の変化のことだ。一部の操作は副作用のみを持つ。その例としては、上記の
printMenu()
関数がstdout
に出力することで、 を変更することが挙げられる。別の例としては、Student
オブジェクトを学生コンテナに追加する関数も副作用を持つ。この関数は、コンテナを拡大させる副作用がある。要約すると、プログラムの状態に変化を引き起こす操作は副作用を持つ。
- 副作用と値の生成:いくつかの操作は、その両方を実行する。例えば、
stdin
から2つの値を読み取り、その和を返す関数は、stdin
の状態を変化させる副作用があり、また2つの値の和を生成する。 - 操作なし:すべての関数は上記の3つのカテゴリのいずれかに分類されるが、コンパイル時または実行時の特定の条件によっては、まったく動作しない関数もある。
戻り値
関数の動作の結果として生成される値は、戻り値と呼ばれる。この用語は、プログラムの実行が関数に分岐すると、最終的にはその関数が呼び出された場所に戻ってくるという事実から付けられた。関数は呼び出され、値を返す。
他の値と同様に、戻り値にも型がある。戻り値の型は、関数の名前、つまり関数が定義されている箇所の直前に指定する。例えば、int
型の2つの値を加算し、その和もint
として返す関数は、次のように定義する。
関数が返す値は、関数呼び出し自体の代わりとなる。例えば、関数呼び出しadd(5, 7)
が値12
を生成すると仮定すると、次の2行は同等になる。
上記の1行目では、 add()
が呼び出される前に、writeln()
関数が引数5
および7
で呼び出される。関数が返す値12
は、2番目の引数としてwriteln()
に渡される。
これにより、関数の戻り値を他の関数に渡して、複雑な式を形成することができる。
上記の行では、studentCount()
の戻り値がdivide()
の2番目の引数として渡され、divide()
の戻り値がadd()
の2番目の引数として渡され、最終的にadd()
の戻り値がwriteln()
の2番目の引数として渡される。
return
文
関数の戻り値は、return
キーワードで指定する。
関数の戻り値は、文、式、および場合によっては他の関数の呼び出しを利用して指定される。関数は、return
キーワードによってその値を返し、その時点で関数の実行は終了する。
関数には複数のreturn
文を含めることができる。特定の呼び出しに対する関数の戻り値は、最初に実行されたreturn
文の値によって決まる。
上記の関数は、2つのパラメータが等しい場合は0
を返し、異なる場合はそれらの値の積を返す。
void
関数
値を返さない関数の戻り値の型は、void
と指定する。これまでは、main()
関数や、上記のprintMenu()
関数で何度もこの型を見た。これらの関数は呼び出し元に値を返さないので、戻り値の型はvoid
と定義されている。(注釈: main()
は、int
を返す関数として定義することもできる。これについては、後の章で説明する。)
関数の名前
関数の名前は、その関数の目的を明確に伝えるように選択する必要がある。例えば、add
およびprintMenu
という名前は、それぞれ2つの値を足し、メニューを出力するという目的に適している。
関数名には、addやprintなどの動詞を含めるという一般的なガイドラインがある。このガイドラインに従うと、addition()
やmenu()
といった名前は理想的とは言えない。
ただし、関数に副作用がない場合は、関数の名前を単に名詞で指定してもかまわない。例えば、現在の気温を返す関数の名前は、getCurrentTemperature()
ではなくcurrentTemperature()
と指定することができる。
明確で短く、一貫性のある名前を考えることは、プログラミングの微妙な技術の一部である。
関数によるコードの品質向上
関数はコードの品質を向上させることができる。責任の少ない小さな関数を使用すると、プログラムのメンテナンスが容易になる。
コードの重複は有害
プログラムの品質に悪影響を与える要因の一つに、コードの重複がある。コードの重複とは、プログラム内に同じタスクを実行するコードが複数存在することだ。
これは、コードの行をコピーして移動させることで意図的に発生することもあるが、別々のコードを書く際に偶然に発生することもある。
本質的に同じ機能を複製したコードには、バグが発生する可能性が高くなるという問題がある。このようなバグが発生して修正する必要が生じた場合、その問題の原因が複数の場所に分散している可能性があり、すべての場所を確実に修正することは困難だ。逆に、コードがプログラム内の1か所だけに存在する場合、その1か所だけを修正すれば、バグを完全に除去できる。
前述したように、関数はプログラミングの職人技と密接に関連している。経験豊富なプログラマは、常にコードの重複に注意を払っている。彼らは、コードの共通点を絶えず見つけ、共通するコードを別の関数(または、後の章で説明する共通構造体、クラス、テンプレートなど)に移動しようとしている。
コードの重複を含むプログラムから始めよう。コードを関数に移動して(つまり、コードをリファクタリングして)、その重複を削除する方法を見てみよう。次のプログラムは、入力から数字を読み込み、それらが到着した順番で、そして数値の順番で出力する。
このプログラムでは、重複しているコード行がいくつかある。数字を出力するために使用されている最後の2つのforeach
ループはまったく同じである。print()
という適切な名前の関数を定義すると、この重複を削除できる。この関数は、スライスをパラメータとして受け取り、それを出力する。
パラメータは、元のより具体的な名前numbers
ではなく、より一般的な名前slice
を使用して参照されていることに注意。その理由は、関数はスライスの要素が具体的に何を表しているかを認識できないからだ。それは、関数が呼び出された場所でしかわからない。要素は、学生 ID、パスワードの一部などである可能性がある。print()
関数ではそれを認識できないため、その実装ではslice
やelement
などの一般的な名前が使用されている。
新しい関数は、スライスを出力する必要がある2つの場所から呼び出すことができる。
さらにやるべきことがある。スライスの要素を出力する直前に、常にタイトル行が出力されていることに注意しよう。タイトルは異なるが、タスクは同じだ。タイトルの出力もスライスの出力の一部と見なせる場合、タイトルもパラメーターとして渡すことができる。以下の変更を加えた:
この手順には、2つのprint()
呼び出しの直前に表示されるコメントを削除できるという追加の利点がある。関数の名前は、その機能を明確に表しているので、これらのコメントは不要だ。
微妙だが、このプログラムにはさらにコードの重複がある。count
とnumber
の値はまったく同じ方法で読み込まれる。唯一の違いは、ユーザーに表示されるメッセージと変数の名前だけだ。
readInt()
という適切な名前の新しい関数を利用すれば、コードはさらに良くなる。新しい関数は、メッセージをパラメータとして受け取り、そのメッセージを表示し、入力からint
を読み取り、そのint
を返す。
count
は、この新しい関数の呼び出しの戻り値によって直接初期化できるようになった。
number
は、number
を読み込む際に表示されるメッセージの一部としてループカウンターi
が含まれているため、同じように単純に初期化できない。これは、format
を活用することで解決できる:
さらに、number
はforeach
ループの1箇所だけで使用されているため、その定義を完全に削除し、その代わりにreadInt()
の戻り値を直接使用することができる。
このプログラムに最後の変更を加えて、数字を読み込む行を別の関数に移動しよう。これにより、新しい関数の名前にはその情報がすでに含まれているため、"数字を読み込む"というコメントも不要になる。
新しいreadNumbers()
関数は、そのタスクを完了するためにパラメータを一切必要としない。いくつかの数値を読み取り、それらをスライスとして返す。以下は、プログラムの最終版である。
このバージョンのプログラムと最初のバージョンを比較してみてみよう。新しいプログラムのmain()
関数では、プログラムの主な手順が非常に明確になっている。一方、最初のプログラムのmain()
関数では、そのプログラムの目的を理解するために、関数を注意深く調べる必要があった。
この例では、2つのバージョンのプログラムの非自明な行の総数は同じになったが、一般的には、関数を使用するとプログラムが短くなる。この効果は、この単純なプログラムでは明らかではない。例えば、readInt()
関数が定義される前は、入力からint
を読み込むには3行のコードが必要でした。readInt()
の定義後は、同じ目的を1行のコードで達成できる。さらに、readInt()
の定義により、変数number
の定義を完全に削除することができた。
関数としてコメントアウトされたコード行
一連のコードの目的を説明するためにコメントを書く必要がある場合は、その一連のコードを新しく定義した関数に移動したほうがいい場合がある。関数の名前が十分に説明的であれば、コメントも不要になる。
プログラムの最初のバージョンでコメントアウトされていた3つの行は、同じタスクを実行する新しい関数の定義に使用された。
コメント行を削除するもう1つの重要な利点は、コードが時間の経過とともに変更されるにつれて、コメントが古くなる傾向があることだ。コードを更新する際に、プログラマーは関連するコメントの更新を忘れてしまうことがあり、その結果、これらのコメントは役に立たなくなるか、さらに悪いことに誤解を招くものになってしまうことがある。そのため、コメントを必要としないプログラムを書くよう努めることが有益である。
演習
printMenu()
関数を変更して、メニュー項目全体をパラメータとして受け取るように。例えば、メニュー項目は、次のコードのように関数に渡すことができる。プログラムが以下の出力を生成するようにしよう:
1 Black 2 Red 3 Green 4 Blue 5 White - 次のプログラムは、2 次元配列をキャンバスとして使用している。このプログラムから始めて、さらに機能を追加して改良しよう。