こんにちは。田原です。
全ての手続をmain()関数に書いてしまうと悲惨です。ダラダラと続く眠たいプログラムが延々と続き、デバッグは地獄をみます。そこで、適度なサイズの手続き単位でまとめて階層構造とするための仕組みが「関数」です。
また、その関数をうまく使うためのメモリがスタックです。第5回目冒頭の図に記載した4大メモリ「プログラム用メモリ」、「静的変数用メモリ」、「スタック用メモリ」、「ヒープ用メモリ」の1つです。今回はその触りを解説します。
関数は概ね下記の表に分類できます。今回は基本である「①関数」について解説します。
大分類 | 中分類 | 小分類 |
関数 | 関数 | ①関数 |
②関数テンプレート | ||
メンバ関数 | ③メンバ関数 | |
④メンバ関数テンプレート |
前回説明したように関数も宣言文で宣言されます。その宣言には2種類あります。関数定義とプロトタイプ宣言です。
まず、関数はざっくり次のような書き方で定義します。(もう少し細かい指定ができますが、少し高度な話になるので今は省きます。後日、必要に応じて解説します。)
戻り値の型 関数名(仮引数のリスト) { 0個以上の実行文; }
仮引数のリストは、「型 仮引数名」を ,(カンマ)で区切って並べたものです。
例えば、次のように書きます。
void hello(char const* to, int count) { for (int i=0; i < count; ++i) { std::cout << "Hello, " << to << "!\n"; } }
これは、下表のように定義しています。
名前 | 内容 | 説明 |
---|---|---|
戻り値の型 | void |
戻り値無し |
関数名 | hello |
関数の名前 |
仮引数リスト | char const* to, int count |
C言語文字列へのポインタtoと int型のcount |
0個以上の実行文 | std::cout << "Hello, " << to << "!\n" |
Hello, !表示 |
実際の動作は、指定された相手(to)へ指定された回数(count)挨拶を表示してます。
関数はプログラムの中で1回だけ定義します。つまり、同じ関数を複数回定義してはいけません。(例外がありますが、それはまた後日。)
関数を定義してコンパイルすると、それをマシン語へ翻訳したコンピュータが実行できるプログラムが出力されます。同じものが複数定義されていると同じマシン語のセットが複数回出力されてしまいます。宣言は同じで実行文が異なるものが間違って定義されると混乱するため、同じものを複数定義することは原則として禁止されています。
しかし、関数を呼び出したい場所が複数ある場合もあります。例えば、分割コンパイルした場合などです。このような時にプロトタイプ宣言します。(分割コンパイルについては次回か次々回に解説予定です。)
プロトタイプ宣言をすることで、コンパイラに対してその関数の呼び出し方を教え、その関数を呼び出すマシン語を出力出来るようにします。
プロトタイプ宣言は、関数程度とよく似ています。実行文を囲むブロックがないだけです。
関数定義の時は定義の最後に ;(セミコロン)は不要ですが、こちらは文末に ;(セミコロン)が必要ですので注意下さい。
戻り値の型 関数名(仮引数のリスト);
例えば、下記のような使い方をします。
#include <iostream> // プロトタイプ宣言 void hello(char const* to, int count); // プロトタイプ宣言は複数あっても良い(仮引数は省略しても良い。) void hello(char const*, int); int main() { // 関数定義前でも、プロトタイプ宣言があれば呼び出せる。 hello("C++", 10); return 0; } // 関数定義 void hello(char const* to, int count) { for (int i=0; i < count; ++i) { std::cout << "Hello, " << to << "!\n"; } }
他の多くの言語と異なりCやC++は関数や変数を宣言する順序が重要です。極僅かな例外を除き、使う前に宣言しておく必要が有るのです。上記の例ではmain()関数から、定義前のhello()関数を呼び出してます。このような時にもプロトタイプ宣言をすることで呼び出せるようになります。
関数が終了したら、呼び出し元へ戻ります。どうやって戻っているのでしょう? 目印もないのに迷子にならないのでしょうか? 不思議ですね。その仕組みがどうなっているのかみてみます。
まずは、呼び出したところへ戻るとはどういうことなのか見てみましょう。
次のプログラムをVisual StudioかCode::Blockで実行して下さい。
CMakeLists.txtを使ってプロジェクトを生成し、Visual StudioかCode::Blocksを起動してビルドし、main()関数の先頭にブレーク・ポイントを設定してデバッグ実行して下さい。
復習です:
上記操作は「実践C++入門講座4回目 コンピュータの仕組みについて」のVisual Studio、Code::Blocksにて説明してます。もし、忘れかけているようでしたら見てみて下さい。
デバッグ実行をスタートし、main()関数の頭で停止したら、「ステップ実行実行する」の隣にあるボタン(Visual Studioは左側のステップイン(F11)、Code::Blocksは右側のStep into)を押して、1行づつどんどん実行しましょう。
ステップイン/Step intoとは:
ステップインやStep intoはステップ実行中に関数があったら、その関数の中へ入ってステップ実行を続けます。
ちなみにVisual Studioのステップ・オーバー(F10)とCode::BlockのNext lineは関数から戻ってくるまで実行します。
stack1.cpp
#include <iostream> void foo(char const* from) { std::cout << "foo() called from " << from << ".\n"; } void bar() { foo("bar()"); std::cout << " in bar()\n"; } int main() { foo("main()"); std::cout << " in main()\n"; bar(); return 0; }
CMakeLists.txt
project(stack1) if(MSVC) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc") else() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++11") endif() add_executable(stack1 stack1.cpp)
以上の操作により、main()→foo()→main()→bar()→foo()→bar()→main()の順序で実行されることが確認できると思います。このようにfoo()はmain()から呼ばれた時はmain()のfoo()を呼び出した次の行へ戻り、bar()から呼ばれた時はその呼出した次の行へ戻ります。
つまり、どこかに呼び出し元を記録しておき、戻る時はその戻り先を取り出して戻っている筈です。
その戻り先を記録しているメモリが「スタック」です。
牧場では牛の餌となる牧草を刈取り、山のように積みます。そして、餌として与える時には山の上から取ってきます。
その山は「スタック(Stack)」と呼ばれています。
関数の戻り先を関数を呼び出す度に山の上へ次々と積んでいき、関数から戻る時に山の上から取り出す様子がこの干し草のスタックの使い方と似ています。それで、戻り先を記録するメモリのことも「スタック」と呼ばれるようになりました。
その戻り先がどのように記録されるのか、模式的に表現してみます。
もう一度サンプル・プログラムをステップ実行しながら、スタックにどのように戻り先が積まれるのか見比べて見ると分かりやすいと思います。
1. main()関数実行中
2. main()関数から呼ばれてfoo()関数実行中
3. foo()関数が終了してmain()関数へ戻る
4. main()関数から呼ばれてbar()関数実行中
5. bar()関数から呼ばれてfoo()関数実行中
6. foo()関数が終了してbar()関数へ戻る
7. bar()関数が終了してmain()関数へ戻る
なんとなく雰囲気を掴めたでしょうか?
結構重要な部分ですので、良く理解するよう頑張って下さい。
関数は値を1つ返却することができます。
関数宣言で戻り値の型を定義し、関数の中でreturn文で返却する値を指定します。
関数の戻り値は原則として、その関数を呼び出した文が終わるまで有効です。
例外は参照を返却して参照で受け取った場合ですが、それについては後日解説します。
bool isOdd(unsigned number) { if (number % 2) return true; return false; }
関数isOdd()はbool型を返却する関数で、与えた符号なし整数が奇数ならtrue、偶数ならfalseを返却しています。
2017年4月7日「3-3.関数の戻り値」の解説を追加しました。
今回は関数の基本について解説しました。他のメンバ関数なども動作はほとんど同じです。
また、スタックの考え方は現在のコンピュータ技術のなかでも、非常に有用なものの1つです。
処理手順をまとめて1塊の関数とし、更にそれらを別の関数から使ってより高度な機能を実装することができます。今回解説したfoo()関数のように直接呼ばれたり、他の関数にまとめられたりなど、複雑な呼び出し関係になってもスタックのお陰で戻り先を見失わないのです。最初にスタックを発明した人って本当に凄いと思います。
さて、このように便利なスタックは、驚くべきことに更に有用です。今回でてきた仮引数や「実践C++入門講座5回目 main()関数と基本の型」で説明したローカル変数も実はここに記録されています。これらについて次回解説します。お楽しみに。