Unix C プログラミング入門資料(高崎金久)   6.関数の定義と呼び出し 目次 1.今回のテーマについて 2.関数定義の例 3.再帰的呼び出しの例 4.まとめと補足 -------------------------------------------------------------------------- 1.今回のテーマについて 1)関数とは何か いままでは main 関数1個だけからなるプログラムを扱って来ました.これは話を 簡単にするためです.C言語のプログラムは一般に複数の関数から構成されていま す.この章ではこのような関数の定義の仕方と使い方を学びます. 入り口と出口に注目すれば,関数というのは一定の個数の値を受け取って,それか ら決まる値を返す箱のようなものです. f : (x_1, ..., x_n) -----> y 数学の関数と似ていますが,数学では x_1, ..., x_n を変数と呼ぶのに対して, C言語を含む手続き型プログラム言語では x_1, ..., x_n を引数(ひきすう)と 呼びます.また関数が返す値 y を返戻値(へんれいち)と呼びます. x_1, ..., x_n : 引数 y : 返戻値 2)関数の役割 手続き型プログラミング言語における「関数」の役割は,一連の指令を一まとめに して名前(関数名)で呼び出せるようにすることにあります.同様のものに「手続 き」があります.一般に,プログラムは個々の指令を密接に関連するものごとにま とめて記述することでわかりやすくなります.関数や手続きはそのような指令群を プログラム本体から分離して名前をつけたもので,いわばプログラムの部品です. C言語の関数はかなりいいかげんな仕様になっていて,引数がないものや返戻値が ないものも許されます.返戻値がない関数はPascalでいう手続きに相当しま す.Pascalでは関数と手続きが構文上区別されていますが,C言語には手続 きという構文はありません.そのかわり,返戻値のない関数が事実上手続きとして 機能します. さらに,C言語の場合には,返戻値があっても使わずに捨ててよいようになってい ます.たとえば,printf には実は返戻値があるのですが,普通は printf("Hello, world!\n"); というように返戻値を利用しません.つまり呼び出し時に関数を手続きとして使え るようになっています. 3)変数の視野 関数と変数の関係もこの章で扱う重要なテーマです.変数はプログラムのどの場所 でも参照できるわけではありません.変数が参照できる範囲をその変数の視野(ス コープ)といいます.変数の視野はそれが宣言されている場所や関数との関係で決 まります.変数には大別して,プログラム全体を視野とする「大域変数」と,ある 関数の定義部分だけを視野とする「局所変数」があります. なお,正確に言えば,局所変数は一般に { ... } で囲まれたブロックの先頭で宣 言されるもので,そのブロック内が視野となります.関数定義もブロックの一種な ので,局所変数が宣言できるわけです. 4)再帰呼び出し 関数はお互いに呼び出しあって使うことができますが,CやPascalなど多く の構造化されたプログラミング言語では関数が自分自身を呼び出すことができます. (Fortran77ではそれができません.)このような呼び出し方を再帰呼び 出しといいます.これは基本的なプログラミング技法の一つで,複雑な処理が再帰 呼び出しによって非常にコンパクトな形で記述できることがしばしばあります.整 列や探索などの古典的な高速アルゴリズムの多くは再帰的な形で記述されています. 2.関数定義の例 前回のニュートン法による平方根の計算のプログラムでニュートン法の本体部分を 独立の関数に書き直してみましょう.ただし,簡単のため,反復回数の表示はしな いことにします. 1:/************************************************************** 2: 与えられた正数にニュートン法で平方根の近似値を計算し、その値と 3: 残差を表示する 4: **************************************************************/ 5: 6:#include 7:#include 8: 9:int main(void) 10:{ 11: double a, x, eps = 0.000001; /* eps は収束パラメータ */ 12: double sqroot(double, double); 13: printf("正実数の平方根を求めます.\n"); 14: printf("正実数を入力して下さい:"); scanf("%lf", &a); 15: if (a < 0){ 16: printf("それは正実数ではありません.\n"); exit(1); 17: } 18: x = sqroot(a, eps); 19: printf("平方根の近似値 %.10lf, 残差 %.10lf\n", x,x*x-a); 20: return 0; 21:} 22: 23:double sqroot(double a, double eps) 24:{ 25: double x; 26: int n; 27: x = (a + 1.)*0.5; n = 1; 28: while(fabs(x*x - a) > eps){ 29: x = (x + a/x)*0.5; n = n +1; 30: } 31: return x; 32:} 以下,プログラムについて簡単に解説します. 12行:これは sqroot() という関数の引数と返戻値の型をあらかじめ宣言しておく もので,関数プロトタイプと呼ばれます.変数の宣言は変数の領域確保を指示する ものですが,関数プロトタイプはそれとは異質なもので,後で呼び出す関数の引数 と返戻値の型をコンパイラにあらかじめ知らせておくものです.これがないとコン パイラは関数の引数と返戻値が int であると解釈してコンパイルしようとします. その結果,警告メッセージ(type mismatch 云々)が出ます. 18行:ここでは sqroot() に a と eps の値を渡して,返ってくる値を変数 x に 格納しています.sqroot() はニュートン法で平方根の近似値を計算する関数で, 23行以下で定義されます. 20行:関数の返戻値はこのように「return」という指令を用いる文(return 文) で指定します.ただし,ここでの return は main 関数の返戻値を指定するもので す.普通の関数の場合と違って,main 関数の返礼値はプログラム終了時にシステ ムに渡されます.main関数をこのような形に定義するのは 1. main関数を改めて別の関数として再利用する 2. 関数をmain関数としてテストする などの場合に変更を容易にするためです.実際,これによって,変更は「main」を 目的の関数の関数名に変えるだけで済みます. 23行:ここから sqroot() の定義が始まります.関数プロトタイプでは引数そのも を書くのを省略しますが,ここでは具体的に与えます.a と eps が引数ですが, これは「仮引数」と呼ばれるもので,関数定義を記述するために用いられます.こ れに対して,18行のように実際に関数を呼び出すときに渡される引数は「実引数」 と呼ばれます.今の場合はたまたま同じ名前を使っていますが,実体はまったく別 のものです. なお,C言語における関数定義の書き方には昔からある「非ANSI」版とその後標準 規格として定められた「ANSI」版があります.ここではANSIの書き方に従っていま す. 25行 - 26行:関数定義の中で用いる変数(局所変数)x, n を宣言しています.こ れらの変数の視野はこの関数定義の中に限られています.main関数の中でも x と いう変数を宣言していますが,名前が共通なだけで,お互いに別のものです. 27行 - 30行:ニュートン法の反復計算を行う部分です. 31行:return文によって,x の値を関数の返戻値として返しています. 3.再帰的呼び出しの例 1)再帰的呼び出しとは何か 関数(あるいは手続き)の再帰的呼び出しとは, ... f(...) { ... ... f(...) ... ... ... f(...) ... ... } というように,関数定義のなかでその関数(手続き)自体を呼び出すことをいいま す.多くのプログラミング言語では再帰が可能ですが,再帰呼び出しができない言 語もあります. 再帰呼び出しの簡単な例は階乗やフィボナッチ数列を計算する関数です. [例:階乗を計算する関数] long factorial(long n) { if (n <= 0) return 1; else return n * factorial(n-1); } [例:フィボナッチ数列を計算する関数] long fibonacci(long n) { if (n <= 0) return 0; else if (n == 1) return 1; else return fibonacci(n-1) + fibonacci(n-2); } いずれも数列が数学的には漸化式 factorial(n) = n * factorial(n-1), factorial(0) = 1, fibonacci(n) = fibonacci(n-1) + fibonacci(n-2), fibonacci(0) = 0, fibonacci(1) = 1, で定義されることを反映しています. 再帰的呼び出しでプログラムをつくる場合,呼び出しがかならず有限回で終わるこ とが保証されなければなりません.上のプログラム例では n の範囲について if 文で調べている部分がこれを保証します.普通の for 文や while 文と違って,再 帰的呼び出しは無限には続けられません.これは関数呼び出しの仕組み(スタック フレーム)と関係がありますが,ここではこれ以上の説明は省きます. 2)再帰的呼び出しを使うと効率が悪くなることがある 階乗やフィボナッチ数列の値を求める方法としてはこれはあまり勧められません. いずれも単純な反復計算の方が効率がよいのです.特にフィボナッチ数列の場合, 上のような再帰呼び出しは無駄が多いのです.このことは計算過程(関数が呼び出 される様子)を木構造で表現してみればよくわかります. たとえば fibonacci(5) の計算過程は次のような木になります.(記号を簡単にす るため,fibonacci を単に f と書きました.) f(5) --- f(4) --- f(3) --- f(2) --- f(1) | | | | | | | |-- f(0) | | | | | |-- f(1) | | | |-- f(2) --- f(1) | | | |-- f(0) | |-- f(3) --- f(2) --- f(1) | | | |-- f(0) | |-- f(1) 横線は函数の呼び出しに対応します.例えば f(5) は f(4) と f(3) を呼び出し, 呼び出された f(4) は f(3) と f(2) を呼び出して,...というように計算が進 行します.f(1) と f(0) は新たに f を呼び出さずに値を返すので,木の成長はそ こで止まります(つまり葉の部分です).これを見るとまったく同じ部分的計算が 何度も繰り返されていることがわかります.例えば f(2) は3カ所も現れています. n が大きくなると,この無駄は相当な量に上ります. 階乗の方は枝別れのない木構造になりますから,その意味での無駄はありませんが, 関数呼び出しはそれだけで若干の時間(とメモリーの消費)を伴いますので,やは り単純な反復計算で済ませる方が有利なのです. 実際には階乗もフィボナッチ数列も単純な反復計算で求められます.こういう問題 では再帰的呼び出しよりも反復形でプログラムを書くほうが望ましいと言えます. 他方,もっと複雑な問題では,再帰的な方法を使って初めて見通しのよい解法が得 られることがしばしばあります.そのような例として,有名なハノイの塔がありま すが,ここでは省略します. 4.まとめと補足 1)関数定義 C言語の函数定義は一般にANSI規格か非ANSI規格かによって次のどちらか の形をとります. [ANSI] type0 function_name(type1 x1, type2 x2,... ) { 変数や函数の宣言 処理の本体 } または [非ANSI] type0 function_name(x1, x2,... ) type1 x1; type2 x2; ... { 変数や函数の宣言 処理の本体 } function_name は函数の名前,x1,x2,... は引数の名前,type0 は函数の返戻値の データ型,type1,type2, ... は各引数のデータ型をあらわします. なお,通常の関数の type0, type1, ... を省略するとコンパイラはそれらを int と仮定してコンパイ ルします.特に,戻り値が int でない函数の場合,戻り値のデータ型の指定を忘 れると,"type mismatch" (データ型が合わない)というエラーの原因になります. 返戻値のない函数では type0 を void と指定します.引数が一つもない場合も同 様に function_name(void) と表わしますが,function_name() のように引数を書 かないで空欄にしても意味は同じです. main以外の函数の名前は処理系の予約語や組み込みの函数とぶつからなければ何で も構いませんが,プログラムを読みやすくするために,その函数の機能を象徴する ような名前を付ける方がよいでしょう.昔は関数や変数の名前をかなり短く制限す るコンパイラがあり,そのため省略形した名前をつけることが多く,意味不明の名 前に出会うことがよくありましたが,今はそういう制限はかなり緩和されています. 特にウィンドウシステム関係のライブラリの影響で "PrintOnWindow" というよう に機能をそのまま表わすような名前のつけ方が多くなっています. なお,通常の関数の場合,返戻値は関数の呼び出し側に返りますが,main 関数の 返戻値はプログラム終了時にシステム側に返ります(シェルでそれを利用すること ができます). 2)仮引数と実引数 函数の定義に用いる引数 x1, x2, ... を仮引数といいます.仮引数の視野は関数 定義の本体({ } で囲まれた部分)に限られます.それに対して関数にわたされる を実引数といいます.これらはこれらは記号上一致する必要がありません. 仮引数というのは実引数として渡された値を入れておく容器だと思ってください. 仮引数の視野はその関数の定義本体に限られるので,別の函数の定義で同じ名前の 仮引数を用いても構いません.たとえば int funct1(int x, int y) { ... ... } int funct2(int x, int y) { ... ... } というように同じ x, y という文字を2つの関数の定義で仮引数として使えます. 意味や機能が同じ仮引数をこのように同じ文字であらわす方がプログラムはわかり やすくなります. 3)変数の視野と寿命 変数の宣言部ではこの函数の定義の中で用いる局所変数を宣言します.これらの変 数の視野はこの関数定義の中に限られます.仮引数と同様,函数が異なれば虚所変 数は(たとえ変数の名前が同じでも)別のものとみなされます. これに対してすべての函数の外(通常はプログラムの先頭)で定義される変数が大 域変数です.この変数の視野はプログラム全体にわたります.(プログラムが複数 のファイルにわかれているときには,特別な指定をしなければ大域変数の視野は宣 言されたファイルの中に限られます.詳しくは参考書を見て下さい.) さらに,変数には寿命があります.これについても簡単に説明しておきます. 局所変数は特に指定しなければ自動変数というものになります.これは関数が呼び 出されたときに初めて(メモリー上に)生成され,呼び出しが終わると消滅します. つまり変数の寿命は関数が呼び出されて実行されている間だけです. 局所変数を宣言するときに static というキーワードをつけると静的変数というも のになります.たとえば ... function(...) { static int previous; int current; ... } という例では previous は静的局所変数,current は自動局所変数です.静的変数 はプログラムの実行を通して生き続けます.従って,同じ関数が再度呼び出された とき,静的局所変数は前の呼び出し時に設定された値を保持しています.もちろん, いずれにしても局所変数は関数の外からは見えません. これに対して大域変数はすべて静的変数です. 以上まとめると,次のようになります. 変数の種類   |所在  |参照できる範囲|寿命    ----------------------------------------------------------- 局所変数(自動)|函数の中|函数の中   |函数の呼び出し中  局所変数(静的)|函数の中|函数の中   |プログラムの実行中 大域変数    |函数の外|プログラム全域|プログラムの実行中 ただし,main 関数の局所変数はやや特別です.main 関数の実行の終了はプログラ ムの終了に他なりませんから,その局所変数は事実上プログラムの実行中を通じて 存在し続けます. 4)函数プロトタイプ宣言 プログラム中で呼び出す関数についてもあらかじめ宣言をしておく必要があります. (これを省くと引数も返戻値も int であると解釈されてしまいます.) 関数のプロトタイプ宣言は ●その関数を使う関数定義の宣言部分 ●関数の外側で,その関数が呼び出される前の任意の場所 のいずれかで行います.後者の場合,プログラムの最初の方(プリプロセス命令と 最初の関数定義の間)で行うことが多く,特に,大量の関数宣言を行う場合には "....h" という名前のファイルに記述して #include で取り込む方法がとられます. 函数のプロトタイプ宣言は,ANSI規格に準拠したコンパイラの場合, type0 function_name(type1,type2,... ) というように引数の型も指定した形式(これを函数プロトタイプという)をとりま す.非ANSIの場合には引数の型指定は単に無視されます. なお,関数の定義が常に実際の呼び出しに先行する場合には,関数プロトタイプ宣 言がなくても問題なくコンパイルできます.この場合には main 関数が最後にくる 形式のプログラムになります.関数の呼び出し関係(依存関係)が木構造の場合に はこういうプログラムが可能です.しかし,複数の関数がお互いに呼び出し合うよ うなプログラムの場合にはそれは不可能で,あらかじめ関数プロトタイプ宣言をし ておくことが必要です. 5)返戻値の指定 関数の返戻値は return 文で記述します. return 返戻値; ただし,関数に返戻値があっても,それを利用しないで手続きのように呼び出すこ とがC言語では許されています. 6)再帰的呼び出し 関数は自分自身を呼び出すこと(再帰的呼び出し)が可能です.再帰的呼び出しは 強力なプログラミング技法ですが,注意すべきこともいろいろあります: ●再帰的呼び出しはかならず有限回で終わる(底打ちする)ように しなければならない. ●再帰的呼び出しを使うと実行効率が悪くなることがある.効率を 上げるには反復に書き直すとよい. ●Fortran77など再帰的呼び出しが言語仕様に組み込まれ ていない場合は当然使えない. 複数の関数が互いに呼び出しあうような形のプログラムもあります.こういうもの を相互再帰と呼びます.たとえば f, g 2個の関数が ... f(...) { ... ... g ... ... } ... g(...) { ... ... f ... ... } というように定義されているような場合です.この場合も単独の再帰呼び出しと同 様の注意が必要です.さらに,f, g の定義に先立って大域的に f, gの関数プロト タイプ宣言をしておく必要があります.