PR

 前回は文字列とポインタの関係について説明しました。C言語では文字列は「文字の配列」であり,ポインタを使うことで文字列を少ないコードでスッキリと扱えることを示しました。ポインタは他の変数のありか(アドレス)を値として持つ変数であり,ポインタの値を一つずつ増やすことで,文字列を1文字ずつ操作していくことができるからでしたね。

 今回解説するのは,関数とポインタの関係です。連載の3回目でC言語の関数の引数は「値渡し」であることを説明しました。ここで,もう一度確認しておきましょう。

#include <stdio.h>

void func1(int a) ;

int main()
{
 int a = 10;

 func1(a);
 printf("aの値は%d (main)\n",a);
 return 0;
}
void func1(int a) {
 a /= 2;
 printf("aの値は%d (func1)\n",a);
}
リスト1●C言語の関数の引数は「値渡し」

図1●リスト1を実行したところ
図1●リスト1を実行したところ

 リスト1は,main関数で変数aを宣言すると同時に10で初期化し,func1関数に引数として渡すプログラムです。実行すると図1のようになります。func1関数ではaを2で割って表示させていますが,main関数に戻って再度aの値を表示させると10のままですね。これが値渡しの効果です。main関数で「int a=10;」としたときに,aという変数がメモリー上に確保されるのと同様に,変数aの値を引数として渡したときに,main関数のaとは別にfunc1関数用の変数aがメモリーの別領域に確保されるのです。ですから,func1側でaの値を更新してもmain側の変数aには何の影響もありません。*1

なぜCは値渡しなのか

 一般的にプログラミング言語の関数への引数の渡し方には,値渡し(call by valueあるいはpass by value)と参照渡し(call by referenceあるいはpass by reference)の2種類があります。値渡しとは前述のように値そのものを渡すことをいいます。値渡しには,関数内のローカル変数の値を他の関数から変更されることがないので,関数の独立性が高まるというメリットがあります。

 これに対し,参照渡しは変数の参照情報を渡します。参照渡しの場合は,仮にリスト1で考えると,func1側に変数aのコピーが作成されるのではなく,main関数側でメモリーに確保した変数aをfunc1からも参照することになります。参照渡しだと,メモリー上に1個だけaという変数名の領域が確保され,main関数側からもfunc1関数側からも同一の変数を更新できるようになるのです。リスト1でいうと,「a/=2」とした後のaをmain関数で表示させると5となります。

 厳密な言い方をすると,C言語では値渡ししかできません。しかし,値渡しだけでは不都合な場合もあります。例えば関数は一つの値しか返しませんので,10と3といった二つの整数を引数とすることはできても,10÷3の「商」と「余り」のような二つの値を返すことはできません。そのような場合にC言語では,ポインタを使って「参照渡しのようなこと」を実装できます。変数のアドレスをポインタで受け取り,間接参照演算子を使って他の関数内で宣言されている変数の値を変更できるのです。具体的な例は後ほど紹介することにして,なぜC言語が値渡しをデフォルトにしているのか,参照渡しのデメリットも考えてみましょう。

 参照渡しだと,Aという関数の中で宣言した変数の値を別の関数Bで変更できてしまいます。となると,値渡しとは逆に関数間の依存性が高くなってしまいます。また,不用意に参照渡しを行うことで,各関数の中で本当に更新される変数はどれかわかりにくくなってしまいます。値渡しの方がシンプルでプログラムの見通しがいいですよね。

 それからもう一つ。プログラマならぜひ覚えておきたい「再帰呼び出し」というテクニックは,値渡しでないと実現できません。そこで以降は,少し回り道になりますが再帰呼び出しから解説し,その後でポインタによる参照渡しへとお話を進めていきましょう。

再帰呼び出しとは

 皆さんは「階乗の計算」を覚えていますか? 5の階乗なら「5!」と表記し,

5! = 1 × 2 × 3 × 4 × 5

と計算します。つまり答えは120です。これをこのまま関数化すると,リスト2のfact関数(1)のようになります。iの値に1を加算し,入力された整数以下の間,掛け算を続けることで階乗を求めています。このプログラムを例に,再帰呼び出しについて考えていきましょう。

リスト2●階乗を計算するプログラム
リスト2●階乗を計算するプログラム

 再帰呼び出し (recursive call)とは,関数の中から自分自身を呼び出すプログラミングのテクニックです。わかりにくいですね。頭の中でロシア民謡を演奏しながら*2図2のマトリョーシカ*3を見てください。マトリョーシカは女の子の形をした人形で,入れ子式になっていて,たいていの場合胴体が上下に分かれ,中から一回り小さな人形が次々と出てきます。このイメージで階乗の計算式を考えてみると,

n! = n × (n-1)!

と定義できることがわかるでしょうか。nの値はマトリョーシカのようにどんどん小さくなっていくわけです。

図2●再帰呼び出しは,ロシアの民芸品マトリョーシカをイメージしよう
図2●再帰呼び出しは,ロシアの民芸品マトリョーシカをイメージしよう

 リスト3のfact関数(1)が,再帰呼び出しによる階乗計算の例です。fact関数の中で「fact(n - 1)」と自分と同じ関数を呼び出していますね。リスト2のfact関数と比べると,forループがなくなっていることもわかります。このように,再帰呼び出しを利用すれば,複雑な処理を単純なコードとして実現できるわけです。

リスト3●再帰呼び出しを使ってリスト2を書き換えた
リスト3●再帰呼び出しを使ってリスト2を書き換えた

再帰呼び出しを使う二つの条件

 再帰呼び出しを行うには,以下の二つの条件を満たす必要があります。

(1)再帰呼び出しには,必ず終点(再帰を止める条件)がなければならない

 例えばリスト3のfact関数では,「n==0」が終了条件です。終了条件がないとプログラムが暴走したり,異常終了します。

(2)問題を徐々に単純にしていかなければならない

 これも当然です。問題が複雑になっていくと,終わりませんからね。「fact(n - 1)」はfact(n)よりシンプルです。


int fact(int n)
  {
  printf("%p \n",&n);
  if (n==0)
  return(1);
  return (n * fact(n - 1));
}
リスト4●リスト3のfact関数に,nのアドレスを表示するコードを追加した

 ここでぜひ忘れないでほしいのは,再帰呼び出しを行うと,関数を呼び出すたびに引数やローカル変数がメモリーを確保するという点です。リスト3だと,fact関数が呼び出されるたびに,新しい変数nがメモリー上に作られるのです。nは決して一つではありません。ですから「n*fact(n-1)」と計算できるのです。リスト3のfact関数にnのアドレスを表示するコード(リスト4)を追加してみれば理解できるでしょう。5の階乗を計算したとき,nが0のときも含めて6個のアドレスが表示されました(図3)。引数のコピーが作成される値渡しでないと,再帰呼び出しが実現できないことがわかります。

図3●リスト3のfact関数を
リスト4に書き換えて実行したところ
図3●リスト3のfact関数をリスト4に書き換えて実行したところ

 もう一つ知ってほしいのは,C言語が変数を記憶するヒープ,データ・セグメント,スタックの三つの領域のうち,引数やローカル変数は比較的狭いエリアであるスタックに記憶されるということです。つまり,階乗計算のような単純なサンプルでも,大きな値の階乗を求めるとスタック用のメモリーを大量に消費するのです。