2018-01-02

Cプログラマのための、C++簡単裏道アプローチ(4)

スコープ

C言語だけを使っていると、スコープという言葉はあまり登場しません。これは、変数名や関数名のアクセスできる範囲、見える範囲のことです。

Cの場合、グローバル、ソースファイルローカル、関数ローカル、の主に3種類しか存在しません。これらはプログラミングのため、というよりも、半ば古いコンパイラの都合に合わせた仕様のように感じられます。

これらの選択肢で、グローバル変数の使用は通常例外的なものです。場合によってはコーディング規約でグローバル変数の使用を禁止するところもあるでしょう。理由は説明するまでもないことですが、いつ、どこからアクセスされているか判らないからです。それが判らないとコードの保守性が著しく悪化し、バグ対応で無駄に多くの時間を奪われることになりますし、機能追加や変更時にもソースコードの解析に無駄に労力を割くことになってしまいます。
コーディング規約といえば、goto文を使用しないように指導しているところも多いですが、多重ループからの抜け出し等、戻りジャンプをしない約束で例外的に使用することは効果的であり、威力を発揮することがあります。同様に、グローバル変数もシステム設定値などでの使用を例外的認めることで効果的、効率的なプログラミングが可能になります。

構造化プログラミングができるC言語は、関数ローカルな変数があります。自動(auto)変数でも静的(static)変数でもスコープは一緒です(ただし、生存期間は異なります)。関数のパラメータも自動変数の仲間ですね。

せっかく、グローバルとローカルという区別があってそれがヒントになっているはずなのに、関数内グローバル変数といって、関数の先頭にまとめてローカル変数を宣言する人が未だに存在します。

そもそも、関数の先頭にまとめて変数を定義するのは、大昔のコンパイラの制限でした。何かの処理の後に変数を定義するとコンパイルエラーになりました。つまり、プログラミングの正当性(ソフトウエア工学)とはなんの関係もありません。なので、その制限はとうの昔に取り払われています。

変数というものは、使わないで済むなら使わない方がいいのです。必要悪と言ってもいいでしょう。もしどうしても使うのであれば、できるだけ小さな範囲に限定して使うべきです。そうすることで、いつ何処でアクセスされているかを調べることがより簡単になり、コードの保守性が高くなるからです。これに関しては疑う余地はありません。もし、これを否定するのであれば、全てグローバル変数で書けばいいだけの話です。もはやローカル変数さえ必要ないでしょう。どうぞ、暗黒時代に逆戻りしてください。

変数は必要になった時点で宣言すればいいのです。そうすれば、それ以前にどう使われているか心配しなくていいからです。

しかし、残念ながら一度定義した変数を定義前の状態に戻すことはできません。また、僕はまだそれを行える言語を知りません。それに近いことは、CやC++なら中括弧(この括弧のこと→{})で囲めば実現できますが、これはこの中に定義した変数全てが対象になるので本質的ではありません。

コード1

void func1()
{
    int v1 = 1;

    {  // スコープのためだけの中括弧
        int v2 = 2;
        int v3 = 3;
        printf("v1 = %d, v2 = %d, v3 = %d 合計は %d\n", v1, v2, v3, v1 + v2 + v3);
        // 以下、v3は不要だが、スコープに含まれる
        printf("小計 = %d\n", v1 + v2 );
    }
    // v2とv3はスコープを外れるのでコンパイルエラー
    printf("合計 = %d\n", v1 + v2 + v3);
}

// こんなことができたらいいかも
void func2()
{
    int v1 = 1;
    int v2 = 2;
    int v3 = 3;
    printf("v1 = %d, v2 = %d, v3 = %d 合計は %d\n", v1, v2, v3, v1 + v2 + v3);

    undef v3; // 以下不要なので変数を捨てる
    printf("小計 = %d\n", v1 + v2 );

    // v3はここでは存在しないのでコンパイルエラー
    printf("v3 = %d\n", v3);
}
func1()では中括弧を変数のスコープを制限するためだけに使用しています。文法上は問題ないのですが、違和感があり、つい、中括弧の先頭にifやwhile等を探してしまいます。ややこしいので僕はこの方法を使うことはほぼありません。たまたまifやループの処理部分などで中括弧で囲まれた部分があれば、積極的にスコープの制限を利用します。

func2()は架空の文法です。中括弧に頼らず、変数をいつでも廃棄できれば、それより後のコードで間違ってアクセスされてしまう心配がなくなります。その際、同じ変数名をそれ以降に再定義できるかどうかは議論の余地はありそうです(CやC++に追加するなら、できるようにするのでしょうね。中括弧スコープでもそれができますから)。

forループによく使うカウンタ変数「i」。これも、たいていはそのループの中だけで使用するので、大昔はできなかったのですが、forの小括弧の中で定義できるようになりました。

コード2

void func3()
{
    char name1[] = "suzuki";
    char mail[] = "ABC00123";

    // 大昔の書き方
    int i;
    for(i = 0 ; name[i] ; ++ i)
    {
        putchar(name[i]);
    }

    for(i = 0 ; mail[i] ; ++ i)
    {
        putchar(mail[i]);
    }
}

void func4()
{
    char name1[] = "suzuki";
    char email[] = "suzuki@abcnet.x";

    // 現代の普通の書き方
    for(int i = 0 ; name[i] ; ++ i)
    {
        putchar(name[i]);
    }

    for(int i = 0 ; email[i] ; ++ i)
    {
        putchar(email[i]);
    }
}
今の普通の書き方であれば変数の使い回しをせずに済みます。また、なによりforループは変数の定義を含めて構文の一部なので、それをわざわざ外部の変数をループカウンタに使用する意味、意義はまったくありません。

ところで、コード2のfunc4()で、文字列の長さを同時にカウントしたい場合はどうでしょうか。forを抜けたときにはもうiは使えません(MicrosoftのVisual C++では以前、独自拡張でスコープが中括弧外に及んでいた黒歴史があります)。func3()ならforを抜けた直後、iに文字列の長さが結果的に記録されています。

forループに入る前にカウント用の変数をもう1つ作って、ループの中でそちらにiの値を毎回コピーするか、iと同じ場所でインクリメントする。
あるいは、例外的にfunc3()のような書き方をする。ただし、単なるiという名称は避けて、たとえばcounterといった変数名にする。
僕は後者を選びます。前者は変数の使い方に、より厳密性を持たせてはいますが(1つの変数に複数の意味を持たせない)、変数が増え、また実行コードも増えてしまいます。また保守性もむしろ良くはありません。
基本的にはfunc4()の書き方で、例外的に変数を外に出しているのだから、コードを読む人はそれがforを抜けてからも必要になるだろうことがすぐに理解できるでしょう。

もちろん、もっと判りやすいコードに徹すれば、1つの処理に複数の機能を持たせず、文字列長は素直にstrlen()を別途使って調べるというのも現実的には大いに意味のある選択肢です。昔のCPUパワーを含めた貧弱なリソースの環境では避けたくなるところでしたが、今時のハードウェア環境でそれが問題になることは滅多にありません(この話をすると、1文字ずつのputchar()の意義について考えなくてはいけなくなりますが、これは必然性のある処理だと仮定しての話ということでご勘弁を・・・)。


さて、ここまで長くなりましたが、C++でのスコープはどうなっているでしょうか。

まず、ライブラリなどの関数名などについての管理では、グローバルとかファイルローカルという区別だけではなく、namespaceというキーワードでグループ化して管理する機能があります。

C++ではCに比べてより広大なライブラリが標準化されるなどしています。Cのように全てが同じ名前空間にあっては、独自の関数名や変数名、クラス名(構造体のようなもの)がバッティングしてしまう確率も高くなってしまいます。
それを避けるために名前空間を分ける機能が必要なのです。入れ子にして、ファイルシステムのディレクトリ(フォルダ)ツリーのような管理が可能です。

また、C++では通常関数を中心にしたプログラミングをするわけではなく、クラスというものを主体に考えます。オブジェクト指向が絡んでくる話なので詳しくは説明しませんが、Cでは「何々をする」というプログラミングしかしないのですが、通常C++では「何が何々をする」というプログラミングになります。最初に、「何が」の部分がオブジェクト指向でのオブジェクトで、関数はそのオブジェクトに所属するわけです。
そのオブジェクトを定義するのがクラスです。ややこしいですね。
クラスは構造体だと思ってください。構造体のメンバーに関数が定義できると思っていただければいいのです。Cの構造体でも、やろうと思えば構造体のメンバーに関数ポインタ変数を定義することは可能です。C++ではそれをポインタではなく、実際の関数を定義することができるのです。

なぜそんなことをするかはここでは説明しませんが、その構造体のメンバである関数を構造体の外から呼出すことが、当然ながら可能です。

コード3

// 構造体の定義
struct A  // C++ではtypedefが不要
{
    void func1(){ puts("私はA::func1()"); }
};

// 呼出
void main()
{
    A a;
    a.func1();    // "私はA::func1()"
}
でも、メンバ関数の全てを外部の誰からでも呼ばれては困ることもあります。ちょうど、C言語でもファイル内部からだけ呼ばれることを許可するために static を使用して制限するのと似ています。
そこで、プライベートな関数にはprivateというキーワードを使います。

コード4


// 構造体の定義
struct B
{
private:
    void func1(){ puts("B::func1()"); }
public:
    void func2()
    {
        printf("私はB::func2()&");
        func1();
    }
}

// 呼出
void main()
{
    B b;
    b.func2();    // "私はB::func2()&B::func1()"
    b.func1();    // コンパイルエラー
}

構造体の private: 以後は全てプライベートになってしまうので、再度外部からアクセスできるようにその後 public: を使用しています。
B::func1()はメンバ関数からのみアクセスが可能で、外部からアクセスしようとするとコンパイルエラーになります。
これは関数だけではなく、メンバ変数についても同様です。構造体のメンバ関数からだけアクセスできる変数が定義できるわけです。

このように、C++では隠蔽という概念が反映されていて、アクセス制御という概念を積極的に取り入れています。ここで紹介したこと以外にも、更に信頼性や保守性を高めるための機能が実装されています(若干、実行時のパフォーマンスを犠牲にしてでも安全性や保守性を採る仕組みも含まれます)。


今回は少々雑然とした説明に終始してしまいましたが、悪用されないように必要最小限に名前は公開する、という考え方がより強力に実現できる機能がC++には実装されている、ということが解っていただければと思います。

そして、普段Cを使っていて、必要以上に名前が公開されてしまうことに不満を感じることはとても重要なセンスだということです。不満を我慢しているうちに忘れてしまったりしないで、自分なりの工夫をすることが大切です。
僕はC++に出会ったとき、そういった不満が解消されることが痛快でもありました。


コラム:変数の使い回し


僕自身も初心者の頃には良くやっていました。関数の中で、例えば work という名前の変数を使用することです。これはその中身に何を入れてもいい変数ですから、1つだけならまだしも、work1、work2等と増えていくと、これはソースコードを暗号化することに等しい行為になってしまいます。

もちろん、僕が初心者の頃というのはMS-DOSの16bitメモリ空間、極端に制約されたリソース環境でしたから、変数の使い回しもある程度仕方がない面もありました。

しかし、現在のコンピュータは当時とは比較にならないくらい広大で潤沢なリソース環境である場合がほとんどです(一部の組込みなど、特殊な環境でない限り)。なので、リソースの節約よりもコーディングの効率、安全性、保守性により重点を置くことが求められます。

そういった環境での変数の使い回しはごく一部の例外に限らなければなりません。

以前、機能追加の仕事で今から10年位前にプログラミングされたコード解析する必要がありました。その実際の処理内容は実にシンプルであるにもかかわらず、通常の10倍程度の時間を要してしまいました。原因は変数の使い回し。同じ変数をいろんな目的に使っていたのですが、更に酷いのは、最終的に格納する領域さえも、一旦それとは全く関係のない内容を格納し、編集の一時領域に使用していたりしたことです(アセンブラのレジストリ操作のようですね)。本当に、暗号化されたプログラムの解析をしているような気分になりました。

あまりに酷いので、その部分は全て書き直してしまいました。そのコードの一部に手を入れるなど、不可能に近かったからです。作った人は初心者に近い人だったに違いありませんが、それにしても酷すぎました。それまで良く動いていたと思います。


0 件のコメント:

コメントを投稿