2014年4月13日日曜日

C/C++ 処理の流れ その3 繰り返し

コンピュータが一番得意とする処理として、繰り返し(ループ)処理があります。同じ作業を何度も繰り返すことは人の作業でもよくあることですが、コンピュータなら、速く失敗なく何度も繰り返すことができます。

for文


繰り返し処理は、条件分岐とジャンプの組み合わせで記述することもできますが、上述の様に重要かつよく使われる機能なので、特別な記述方法が用意されています。一番よく使用されるのが、for文で、以下のような文法になります。

    for (初期値設定式 ; 繰り返し条件式 ; 繰り返し付加式)
        処理



初期値設定式は、繰り返しの前に実行される式です。繰り返し条件式の値が真(0以外)である間、処理を繰り返します。処理の最後には繰り返し付加式が実行されます。繰り返し付加式では基本的には繰り返し条件に関する更新内容のみ記述します。

例えば、以下のように掛けば、10回"Hello World¥n"を表示することになります。

        int i;

        for (i = 0; i < 10; i++) {
                printf("Hello World¥n");
        }

iという短い変数名が使用されていますが、これはiterator(イテレータ:反復子)のiの意味があり、非常によく使われます。続きでj, kと使われることも多いです。

まずi = 0;が実行されます。そしてi < 10が評価され、評価値は真であるため、printf()が実行され、その後i++が行われます。そしてi < 10の評価に戻り繰り返します。

10回繰り返すには、(i = 1; i <=10; i++)の方が分かりやすいと言う方もいると思いますが、上記のように0で初め、終わりの値は10を含めない書き方が普通です。配列の操作などにおいて、コンピュータではそれが便利だからです。

処理においては、break文とcontinue文を書くことが可能です。break文は、繰り返しの処理をそこで終了し、for文の次の文にジャンプします。continue文は繰り返しの処理をそこで終了し、繰り返し付加式にジャンプします。

break文もcontinue文もジャンプというgoto文と同じ性質から構造化プログラミングの観点でふさわしくないとルールで禁止される場合があります。しかし個人的には禁止は反対です。無理に禁止することで返ってネストが深くなりプログラムが見にくくなったり、処理がわかりにくなったりすることも多いと思います。

少し条件分岐に比べて複雑なので、フローチャートで書くと以下のような処理に流れとなります。


while文


for文の初期値設定式繰り返し付加式を取り除いた文法です。既に初期値が設定されており、記述が必要ない場合で、よく使われます。

    while (繰り返し条件式)
        処理


do while文


for文やwhile文においては、繰り返し条件式が繰り返しを行う前に必ず評価されます。その評価が必要なく、処理を必ず一回は行いたい場合に使用します。あまり使うことはないです。

    do
        処理
    while (繰り返し条件式);


無限ループ


初期値設定式繰り返し条件式繰り返し付加式は記述をしなくても良いです。繰り返し条件式は記述しなかった場合は1と等価であり、常に真になるので、無限に繰り返し処理を行うことになります。

繰り返し条件式が複雑でbreak文で終了したり、 他の並列処理から終了したりする場合に使われることがあります。while文でもfor文でも良いですが、for文を使用して、for( ; ; )と書くのが一般的のようです。


配列のループ(C言語)


ループを一番よく使用するケースとして、複数のデータの集合である「配列」の処理を行うことが多いです。配列についてはまだ説明をしていないのですが、色々な種類があります。まずはC言語の場合です。

#include <stdio.h>

int main()
{
        int arr[] = {1, 12, 123};
        int i;

        for (i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) {
                printf("%d¥n", arr[i]);
        };
        return 0;
}

sizeof()は演算子でデータのバイト数を取得します。配列のサイズを取得するのに、配列全体のサイズから配列一つのサイズを除算して求めています。環境によってはマクロが用意されている場合があります(LinuxカーネルソースではARRAY_SIZE()マクロ、Windowsでは_ARRAYSIZE()マクロなど)。

配列のループ(C++98)


C++では、ライブラリで定義されたstd::vector等の配列のデータ型があります。これらのデータ型の場合は、メンバ関数としてbegin()関数とend()関数が用意されているため、それらを使用して、開始と終了の値を取得します。取得されたデータ型は〜::iterator型となります(他にもconst_iteratorやreverse_iterator等の型もあります)。iterator型の値はポインタ(これもいつか説明する)の様に*を先頭につけることで配列の値となります。

#include <stdio.h>
#include <vector>

int main()
{
        std::vector<int> arr;

        arr.push_back(1);
        arr.push_back(12);
        arr.push_back(123);

        for (std::vector<int>::iterator i = arr.begin(); i < arr.end(); i++) {
                printf("%d¥n", *i);
        }
        return 0;
}

その他にもC++では配列のループ処理を助ける関数として<algolithm>にfor_each()関数が定義されたりしています。


配列のループ(C++11)


C++にもバージョンが色々とありますが、1998年に出来たC++98に比べて、2011年に出来たC++11では配列の操作関数が色々と強化されています。

例として、以下のようになります。

#include <stdio.h>
#include <iterator>
#include <vector>

int main()
{
        int arr[] = {1, 12, 123};
        std::vector<int> arr2 = {2, 23, 234};
        int i;

        for (auto i = std::begin(arr); i < std::end(arr); i++) {
                printf("%d¥n", *i);
        }
        for (auto i = std::begin(arr2); i < std::end(arr2); i++) {
                printf("%d¥n", *i);
        }
        for (auto i : arr2) {
                printf("%d¥n", i);
        }
        return 0;
}


まず、メンバ関数のbegin()やend()ではなく、通常の関数のstd::begin()やstd::end()関数が<iterator>に用意され、その使用が奨励されています。一番目のループと2番目のループのようにstd::vectorなどの定義型だけでなく、通常のint型にも使えるのが利点です。

autoは直接ループとは関係無いですが、型推論と呼ばれるもので、型を自動的に判別する仕組みです。std::vector<int>::iteratorなどと長い記述をする必要がなくなりましたので、かなり楽になります。

また、std::vector等において、{1, 2, 3}の記述で値を代入する初期化リストにも対応し、int型等の通常の配列と同様に初期化ができるようになっています。

そして、新しい構文としてbegin()やend()を使用せずに配列内全ての処理を行う以下の構文が追加されました。

    for( : 配列)
        処理


再帰


関数から同じ関数を呼び出すことで、繰り返しを行うこともできます。これを再帰と言います。以前に階乗の処理を紹介しました。このときは、for文を使っていましたが、以下のようにfor文を使わず、再帰で表現することも可能です。

下記が再帰を使った例です。do_fact1()関数もdo_fact2()関数も階乗を求める関数ですが、do_fact1()関数はfor文を使用したもので、do_fact2()関数が再帰を使ったものです。

#include <stdio.h>
#include <stdlib.h>

int do_fact1(int value)
{
        int fact, i;

        fact = 1;
        for (i = 1; i <= value; i++) {
                fact = fact * i;
        }
        return fact;
}

int do_fact2(int value)
{
        return value <= 0 ? 1 : value * do_fact2(value - 1);
}

int main(int argc, char *argv[])
{
        int value;

        if (argc != 2) {
                printf("argument error¥n");
                return 1;
        }

        value = atoi(argv[1]);

        if (value &lt 0) {
                printf("value error¥n");
                return 2;
        }

        printf("factorial1 = %d¥n", do_fact1(value));
        printf("factorial2 = %d¥n", do_fact2(value));

        return 0;
}


do_fact2()では階乗の計算が

fact(n) = n * fact(n - 1)
fact(0) = 1

という式で表されることから、do_fact2(value)関数の中で引数を変えて、do_fact2(value -1)関数を呼んでいます。これが再帰です。

再帰を使った繰り返しの利点とては以下があります。
  • 言語としてforの機能が無くても使用できる
  • ハノイの塔やクイックソートなど、再帰でないと記述が難しいアルゴリズムを書ける
一つ目ですが、C++ではテンプレートという機能を使って静的(コンパイル時)に階乗の計算を行うことができますが、このテンプレートにはforの機能がないです。そのため再帰を利用して以下のように書きます。

#include <stdio.h>

template<int N>
class Fact {
public:
        enum { value = N * Fact::value };
};

template<>
class Fact<0> {
public:
        enum { value = 1 };
};

int main()
{
        printf("%d\n", Fact<5>::value);
}

欠点としては以下があります。
  • メモリの使用量が多く、使用量の推定が難しい
関数の呼出は以前に説明したように、スタックというメモリ領域を使用します。そのため、再帰呼出しが多いとそのメモリ領域を多く使うことになります。メモリの少ない場合やどの程度の関数呼び出しがあるか不明な組み込み系などの場合には再帰を使わないほうがいいでしょう。


0 件のコメント:

コメントを投稿