関数(2)

目次

関数のプロトタイプ宣言

C言語のソースファイルは関数の集合であることはすでに述べたが,ソースコード中で関数を定義する場所と,呼び出しを記述する順序に注意が必要である.
というのは,コンパイラがソースファイルの構文解析を行う際に,ソースコードの上から下へとを行うため, 関数を呼び出す時点で,その関数名や引数についての情報が既出である必要がある.
(注:最近の賢いコンパイラは,ソースコード下方から関数の定義を探してくるものもある.)

例として,2つの整数の和を返す関数を考えてみよう.

#include <stdio.h>

int wa_no_keisan(int a, int b)    // 関数の定義
{
    return a+b;
}

int main(void)
{
    int x = 1, y = 3, z;
    z = wa_no_keisan(x, y);    // 関数の「呼び出し」
    printf("%d\n", z);
    return 0;
}

これはうまくコンパイルできるはずである.
次に,関数の中身は変えないで,main()wa_no_keisan()の記述順序を逆にしてみよう.
コンパイルするとどうなるだろうか.

//
//  書かれている内容は全く同一だが・・・
//
#include <stdio.h>

int main(void)
{
    int x = 1, y = 3, z;
    z = wa_no_keisan(x, y);    // 関数の「呼び出し」
    printf("%d\n", z);
    return 0;
}

int wa_no_keisan(int a, int b)    // 関数の定義
{
    return a+b;
}

この例は,関数の中身はそれぞれ先のコードと全く同じであるが,コンパイルエラーまたは警告が出る.
理由は,main() 関数内の wa_no_keisan() の呼び出しよりも下方に関数 wa_no_keisan() の定義が書かれている点である.

この例のように,関数の定義を呼び出す前に書くことが可能であれば良いが,関数が増えてくると呼び出しの依存関係が複雑になったり,また,2つの関数が処理でお互いを呼びあったりする場合は,どちらを先に書いてもエラーとなってしまう.

そこで,関数の形(戻り値の型,関数名,仮引数の個数と型)を,あらかじめ宣言する方法が用意されている.
これを関数のプロトタイプ宣言,という. (プロトタイプ=試作品,原型)

以下の例で確かめてみよう.

#include <stdio.h>

int wa_no_keisan(int a, int b);    // これが関数のプロトタイプ宣言 「;」が必要.
    
int main(void)
{
    int x = 1, y = 3, z;
    z = wa_no_keisan(x, y);    // 関数の「呼び出し」
    printf("%d\n", z);
    return 0;
}

int wa_no_keisan(int a, int b)    // 関数の定義
{
    return a+b;
}

なぜプロトタイプ宣言だけで良いかといえば,関数を呼び出す際には,差し当って,関数の名前,引数の型と個数,戻り値の型といった関数の形に関する情報があれば十分だからである.
関数本体の処理内容(定義)は,コンパイル後のリンク作業で結合され,実行ファイルが作られる.

練習問題

  1. 上記のソースコードをそれぞれコンパイルしてみよう.
  2. プロトタイプ宣言の行を削除してコンパイルすると,どうなるか.
  3. プロトタイプ宣言の行を残して,関数定義を削除してコンパイルすると,どうなるか.

プロトタイプ宣言とヘッダファイル

この関数プロトタイプ宣言は,その関数の定義が「同じソースコード内には無い」場合に重宝される.
例えば,printf()定義は皆さんが作っているソースコードの中には無いですね.
実は,おまじないの #include を記述している stdio.h という名前のヘッダファイル中に,printf 関数のプロトタイプ宣言が書かれている.

また,printf()関数の定義は,事前にコンパイルされ,ライブラリーファイル(.a, .libファイル)としてまとめられており,これを自作のプログラムに結合する作業は,リンク処理で行われる.
通常はコンパイルすると,以下のように自動的に「リンク」処理まで行われる.

  1. プリプロセス:#include の展開や #define マクロの置き換え.
  2. コンパイル:.cppファイルごとにオブジェクトファイル(.obj, .o, 機械語の一部)をそれぞれ生成.(この時点では関数の定義は無くても良い,プロトタイプ宣言のみでOK)
    どこか(別のファイル)に関数があるものとしてコンパイルされる. 文法に誤りがあれば,この時点で「文法エラー」が発生する.
  3. リンク:複数のオブジェクトファイルを結合し,単一の実行ファイルを生成する.この際,必要に応じてライブラリファイル内の printf(), scanf() などの関数の機械語コードを結合する.
    関数の定義が見つからなければ,「リンクエラー」が出る.

練習問題

  1. 一番最初に書いた Hello World! プログラムにおいて,#includeの行を削除してコンパイルしてみよう.
    どのようなエラーが出るだろうか.
  2. PCのドライブ,またはUSBメモリ上の開発環境のフォルダから stdio.h というファイル(複数ある?)を探し,その中からprintf, scanf関数のプロトタイプ宣言を探し出せ.

ライブラリ関数

一般的には,C言語の開発環境には「ライブラリ関数」と呼ばれる既成品の関数群が用意されている.
本来は,ライブラリ関数はコンパイラとは別の製品であるが,gccやbcc, Microsoft Cなど市販の or 無償利用できるコンパイラの多くは,ライブラリ関数とともに配布されている.
いままで用いてきた printf(), scanf()は,ライブラリ関数の代表例である.

ライブラリ関数を利用するには,そのプロトタイプ宣言が書かれたヘッダファイルを必要に応じて #include する必要がある.
これはほんの一例であるので,詳しくは各種メーカー提供のライブラリ関数リファレンスを参照のこと.

以下,いずれも外部サイトへのリンクです.

以下によく使われるライブラリ関数の例を示す.

  1. 標準入出力printf(), scanf()
  2. 標準ライブラリexit(), rand()
  3. 算術sqrt(), sin(), cos(), tan(), exp(), log()
  4. ファイル入出力 fopen(), fclose(), fprintf(), fscanf()
  5. 文字列操作 strcpy(), strlen(), atoi(), atof()
  6. メモリ malloc(), calloc(), free()
  7. 時間・時刻 time(), clock()

ファイル入出力や,文字列操作などは,別の回に説明する.

練習問題

1.算術関数

算術関数ライブラリを実際に使ってみよう.
math.h内で宣言されている sin, cos, tan 関数のプロトタイプは,

double sin(double x);
double cos(double x);
double tan(double x);
である.

下記のプログラムは,1周期分の正弦 sin の値を,数値として出力する.
このソースを実行出来たら,cos, tanについても同様に計算してみよう.

周期関数の引数は,ラジアン単位である事に注意.
わかりにくいようであれば,表示する際に,ラジアン→度に変換してみよう.

#include <stdio.h>
#include <math.h>       // これが必要

const double PI = 3.14159265358979;

int main(void)
{
    double theta;

    printf("theta , sin(theta) \n" );
    for(theta=0.0; theta<=2.0*PI; theta += 0.1*PI ) {
        printf("%lf , %lf\n", theta, sin(theta) );  // ラジアン表示.
    }
    return 0;
}

2.乱数

乱数を発生させる関数 rand() を使ってみよう.
この関数のプロトタイプは,

int rand(void);

である.
関数が呼ばれるたびに,0 から RAND_MAX(stdlib.h内で定義されている整数)以下のランダムな整数を返す.

#include <stdio.h>
#include <stdlib.h>       // これが必要

int main(void)
{
    for(int i=0; i<100; i++ ) {
        printf("%d\n", rand() );
    }
    return 0;
}

これを変更して,0.0 から 1.0 までの実数の乱数を発生させてみよう.
詳しい使い方は,ライブラリ関数リファレンスを参照のこと.


変数の有効範囲

ローカル と グローバル

関数の範囲は,波括弧{ }で表されるコードブロックで表される.
関数定義の中で宣言される変数は,その関数中でのみ有効であり,関数が終了し処理がブロック外に出ると,その変数は自動的にメモリ上から破棄され使用できなくなる.
このような変数は自動変数とよばれる. 通常の変数は,この自動変数となる.
(参考:自動変数の対義語としてstatic(静的)変数というのもある.)

関数「内」で宣言される変数のほかに,関数「外」でも変数の宣言をすることができる.
関数の外で宣言された変数はどの関数にも属さないため,(その変数が宣言された行以降の)全ての関数から使用できる.

#include <stdio.h>

//  関数外での変数宣言=グローバル変数
int global = 100;
float data = 2.99;
    
int main(void)
{
    int i;     // 関数内での変数宣言

    i=1;           // これはもちろんOK
    data = 5.11;   // これもOK
    return 0;
}

変数が利用可能なソースコード中の範囲を「変数のスコープ」と呼び,その範囲は変数が宣言された場所で決まり,

と呼ぶ.
ローカル変数,グローバル変数は,それぞれ局所変数,大域変数とも呼ばれる.

注:関数の仮引数は,見かけ上関数のブロック { } の外側にあるが,これは関数内でのみ有効なローカル変数である.

グローバル変数の宣言と有効範囲

グローバル変数は,どの関数でも変更可能なので,あちらこちらで自由に参照できるので,一見すると非常に便利に思えるが,実は副作用の方が大きい.
特に,プログラムの規模が大きくなるにつれ,次第にその管理が難しくなることから,安易に使わない方が良いとされている.
関数間でデータをやり取りする場合は,引数や戻り値を適切に受渡しするのが良い.

グローバル変数の使用が適している例として,プログラム全体で共通して使用する定数や,円周率や重力加速度などの物理定数などがある.
このような定数の場合は,定数を誤って変更しないように const を追加で指定しておくと安全である.

#include <stdio.h>

// この位置では,定義前なので変数は使用不能.
// global_val = 1.0;    // これはエラー


//  グローバル変数.定義した行以降で有効
const double pi = 3.14159265358979;   // const 指定すると,値の変更がコンパイルエラーとなり安全.
float global_val = 0;


float area(float r)    // 仮引数 r は,関数 area のローカル変数
{
    float A;        //  ローカル変数.
    A = pi * r * r;   //  グローバル変数 pi を使用
    return A;
}
    
void func(void)
{
    float r = 10.0;  // ローカル変数.関数area()内の r とは同名でも全く別物
    float L; 
    L = 2.0 * pi * r;
    global_val = 100;   //  グローバル変数に代入.この変更はコード全体に影響する.
}

int main(void)
{
    ...
}

ローカル変数とグローバル変数,使い分けのヒント:

引数の渡しかた

関数に引数を渡す方には,3種類ある(すべて重要).

1.値渡し(call by value)
通常の渡し方.呼ばれた関数の仮引数に,実引数の値がコピーされて渡される.
従って,仮引数をどれだけ変更しても,実引数(元の変数)には影響を与えない

2.アドレス渡し (call by address)
値のコピーではなく,値のある場所(アドレス)を渡す方法である.
仮引数の値を変更すると,呼び出し元の実引数の値も変更される.
つまり,引数として渡された変数の値を参照・変更できる.
return による戻り値では,1つしか値を返せない)

3.参照渡し(call by reference)
C++ の拡張機能である.仮引数は,呼び出し側の実引数の「参照」が渡される.
アドレス渡しと同じく関数中で仮引数の値を変更すると,呼び出し元の実引数の値も変更される.
(ソースコードの見た目は「値渡し」に見えるが,本質は「アドレス渡し」である.*&を書かなくても済む.)

値渡し アドレス渡し(ポインタ変数) 参照渡し,C++のみ
#include <stdio.h>

void clear(int x)
{
    // 仮引数を変更
    x = 0;
}

int main(void)
{
    int a = 100;

    printf("a=%d\n",a);

    clear(a);

    printf("a=%d\n",a);

    return 0;
}
#include <stdio.h>

void clear(int *px)   // アドレス渡し
{
    // ポインタ変数に*をつける
    *px = 0;
}

int main(void)
{
    int a = 100;

    printf("a=%d\n",a);

    clear(&a); // アドレスを渡す

    printf("a=%d\n",a);

    return 0;
}
#include <stdio.h>

void clear(int& x) // 参照渡し
{
    // x は,main()中の a の「参照」
    x = 0;
}

int main(void)
{
    int a = 100;

    printf("a=%d\n",a);

    clear(a);  // 値渡しと同じ?

    printf("a=%d\n",a);

    return 0;
}

ポインタ渡しの例と参照渡しの例に,同じアンパサンド(アンド)記号 & が使われているが,異なる用法なので注意.
(同様に,アスタリスク * 記号も掛け算と同じ記号であるが,まったく意味が異なる.さらに細かく言えば,アドレス渡しの例内の2つの * も,異なる役割である.)
ポインタ変数の使い方については,ポインタの回で詳しく説明する.

練習問題

上記のプログラムをそれぞれ実行して,結果を比較しよう.
特に,値渡しと参照渡しでは,main 関数は全く同じであるが,結果が異なるので注意!

再帰関数

C言語では関数の中で「自分」を呼び出すことができる.
参考書などでは定番の書法であるが,実用的な使い道はさほど多くない.
例えば,以下は階乗の計算の例である.

#include <stdio.h>

int kaijo(int x)
{
    int n = 0;

    if(x < 0)
        n = 0;           // エラー処理
        
    else if(x == 0)
        n = 1;           // 0! = 1

    else
        n = x * kaijo(x-1); // 再帰呼び出し

    return n;
}


int main(void)
{
    for(int i=0; i<10; i++)
        printf("%d! = %d\n", i, kaijo(i));
    return 0;
}

階乗の計算は,例えば,
$5! = 5*4*3*2*1$
である.これは,
$5! = 5 * 4!$
に等しい.
一般項で考えると,
$x! = x * (x-1)!$
となる.
つまり,計算過程に,自分自身を含むような処理の場合に再帰関数を用いることができる.

関数の再帰呼び出しにおいては,条件分岐に再帰呼び出しではない処理(この場合は x==0return が 1)があり,ここに至って初めて値が定まり, 次々にリターンして(戻って)くる.
このように,再帰関数では処理の中で,必ず再帰呼び出しを終わらせるロジックを慎重に記述しなければならない.

練習問題

  1. 上記のプログラムをそれぞれ実行して,結果を確認しよう.
  2. 大きな整数(20〜30程度以上)の階乗計算にトライしてみよう.何か問題が起こる?
    次に,どのようにしたら解決できるだろうか? 方針を立てるために,デバッグ技法を活用してみよう.

まとめ

Q and A

Q and A
値渡し,アドレス渡し,参照渡しの使い分けはどう考えればよいですか?
渡した先の関数で値を変更させたい場合はアドレス渡し or 参照渡しが必須です.それ以外は値渡しです.
(原則として,配列はアドレス渡しです.巨大な配列を値渡しすると,処理効率が悪いからです.また,構造体は値渡しになります.)
グローバル変数はなぜ使わないほうが良いのでしょうか
グローバル変数はどの関数からでも変更できてしまうので,ソースコードのどこで値が変化するかが管理しにくいからです.
逆に,プログラム全体に影響するような値(動作モードなど)や,物理定数(円周率やネイピア数)の保持には最適ですが,値が不用意に変更されないように,constをつけておくと良いです.
プロトタイプ宣言の必要性が分かりません
複数のソースファイルを含むような中規模以上のプロジェクトや,大人数で共同作業するプログラム開発では必須です.
どのような機能の関数を,どのように組み合わせてソフトウエアを構築するかは,ソフトウエアの設計として,とても重要です.
プログラムの全体設計を先に済ませ,必要となる部品の仕様(=関数のプロトタイプ)を先に決めて,そのあと関数本体のコーディング・デバッグを書くような工程もあります.
また,市販のライブラリ関数などを使用する場合も,ソースコードは提供されないのが一般的ですので,ヘッダファイルのインクルードやライブラリの結合などには必要な技法となります.

練習問題(授業時間内に実習)

以下の問それぞれに対応するプログラムを作成しなさい.

  1. 2つの整数を引数にとり,この2つの値を交換する関数,swap を2種類(値渡しと参照渡し)作成してみよう.
    なお,作成した swap 関数が正しく動作することを確認するため,main 関数は以下の通りとすること.
    ヒント:値渡しでは関数に値のコピーが渡されるため,うまくswap(入替え)ができない.参照渡しならOK.

    #include <stdio.h>
    
    void swap_val(...)    // 値渡しバージョン
    {
        ...
    }
    
    void swap_ref(...)    // 参照渡しバージョン
    {
        ...
    }
    
    int main(void)
    {
        //  main 関数は変更しないこと
        int x = 1, y = 3;
        printf("Before swap: x = %d, y = %d \n", x, y);
    
        swap_val(x, y);
        printf("After swap_val() (by value)    : x = %d, y = %d \n", x, y);
    
        swap_ref(x, y);
        printf("After swap_ref() (by reference): x = %d, y = %d \n", x, y);
    
        return 0;
    }
    
  2. 先のソースコードを,プロトタイプ宣言を利用する形に書き換えよ.
    つまり,main() 関数がソースコードの上方に配置されるようにすること.

    #include <stdio.h>
    
    // ここに関数2つのプロトタイプ宣言を追加
    
    // 最初にmain関数
    int main(void)
    {
        //  main 関数は変更しないこと
        int x = 1, y = 3;
        ...
        return 0;
    }
    
    /////////////////////////
    // この下に関数の定義
    /////////////////////////
    
    void swap_val(...)    // 値渡しバージョン
    {
    	...
    }
    
    void swap_ref(...)    // 参照渡しバージョン
    {
    	...
    }
    
    おまけ:余力+時間があれば,swap_val(), swap_ref()を別のソースファイル(.cpp)に移動させ,かつ,プロトタイプ宣言を別途ヘッダファイル(.h)にしてみよう.コンパイル時には,2つのソースファイルを同時にコンパイラに渡すこと.
  3. 戻り値としてboolを返す関数もよく使われる.
    引数などの値などが,ある条件に当てはまっているかどうか=「~であるか否か」を判定して,bool 値を返す関数には Is????(), is_????のように,is(be動詞)から始まる名前をつけることが多い.
    以下の例を参考に,素数の判定を行う関数と,偶数・奇数の判定を行う関数を作成してみよう.

    #include <stdio.h>
    
    bool IsPrime(int n)
    // 素数の判定
    {
        ...
        return ????    // true or false
    }
    
    
    bool IsEven(int n)
    // 偶数の判定
    {
        ...
        return ????    // true or false
    }
    
    		
    int main(void)
    {
        int n;
        printf("n=?");
        scanf("%d", &n);
        
        // 奇数・偶数の判定
        if( IsEven(n) ) {   // IsEven(n) == true  と書いても良い.
            printf("even.\n");
        } else {
            printf("odd.\n");
        }
    
        // 素数の判定
        if( IsPrime(n) ) {   // IsPrime(n) == true  と書いても良い.
            printf("prime number.\n");
        } else {
            printf("non-prime number.\n");
        }
        return 0;
    }
    
  4. 乱数関数 rand() を使って,整数の乱数を大量に発生させ,その数が偶数である確率を求めよ.
    同様に,その数が素数である確率を推定せよ.

    ヒント
    試行回数を整数N = 10000; またはそれ以上として,繰り返し乱数を発生させ,その値が偶数 or 素数であった回数を数え,最後にNで割る.
    RAND_MAXは,32000程度の数であるため,厳密ではないが,試行回数を増やすほど真値に漸近してゆくと考える.