C++入門/ポインタ

柴田祐樹,東京都立大学,情報科学科
戻る

データとアドレス

計算機で演算の対象とするものは,0, 1, など,我々が存在を定義しうる実体に限られる.変数には何かしらの実体が入っていて,変数に対する演算はそれら実体に対して行われる.この実体はデータなどとも呼ばれる.データには名前をつけるわけであるが,このなまえは計算機が理解可能な形式で与えなければならず,その形式を普通符号と呼び,デジタルコンピュータでは整数値で表される.データを用意できたら,それが保存されている場所を計算機に指定しなければならない.それは,保存されている場所に名前をつけることで実現される.この保存されている場所に名前を付けた状態のものを変数と呼ぶわけである.変数はデータのそのものの名前ではない.変数についても計算機が理解可能は形式,つまり符号で表さなければならないが,それはC++の世界でアドレスと呼ばれる.データと同様に,これも整数値で表現される.

例えば,データ自体に名前を付けた例として,1, 2, 3, などの数値が挙げられるだろう.1 + 2 と書けばそれはいつでも 3を表し,結果が変わることはない.だがこれでは似たようなすべての処理を個別に記載する必要があり,苦労である.例えば,1 + 2 + 3 と 2 + 3 + 4, 3 + 4 + 5 はどれも3つの連続した数を足すことを意味するが,この似たような処理を上記のようにそれぞれ別途記載することは無駄である.そこで,この表記には規則があるため,知っての通り 3n+3 と表して,nに1, 2, 3 の値を入れれば同様の3つの結果を少ない記載量で示すことができる.これは重大な事実であり,例えば今3通りの例を挙げたが,nによる記載の方は無限の場合を表現している.規則を発見すると作業量が無限から定数へ減るのである.このnが変数というわけである.以下のことを覚えてもらいたい.

変数は言葉でいうところの代名詞に相当するため,馴染み深いはずと思う.我々は文字を認識でき,アルファベットを使ったり,漢字を使ったり,それらを組み合わせたりと言った具合で様々な名前を考えることができる.だが計算機で認識できる名前は,前述したとおり,非負の整数のみである.アドレス(保存場所,変数),データ(実体)どちらも整数値で表現する.に概念を簡単に示す.左にアドレス,右にデータが記載されている.

Central Processing Unit がアドレスを用いてデータを処理する様子と,文字による名前の対応関係の例

計算機は整数値の名前を使うと述べたが,人が読むためにプログラムは文字で記載したほうがよいらしい.つまりのようにアドレスを示すより,のように書いたほうがよいそうだ.

int main(){
    int x;
    int y;
    x = 128 + 52;
    y = x + 1;
}
アドレスを説明するための簡単なプログラムの例

精確な説明ではないが大まかに言えば,をコンパイルすると実際にのようにアドレスが割り当てられる.つまりコード中に128など実体(数値)を書いたとしても,その実体を保存するための場所が用意され,アドレスが割り当てられる.先ほど説明したとおりアドレスは計算機が使う領域の名前であり,変数名でもある.つまり,結局の所,全て変数に保存してから実行が始まるわけであるが,これはプログラムが読み込まれたときに行われるよう,コンパイラが必要な初期化コードを関数の冒頭に記載することとなっている.この状況を慣れているC言語の構文で表せば,これも精確ではないが以下のように書き換えられるということを想像して欲しい.

int main(){
    int c1 = 128;
    int c2 = 52;
    int c3 = 1;
    int x;
    int y;
    x = c1 + c2;
    y = x + c3;
}
に対する初期化コードの追加例

現在の計算機は全て書き換え可能な記憶域にデータが存在することを想定している.実体をコード中に記載したとしても,変数をそれ用に用意して必要な実体をそこに格納する必要が有る,とも言えるだろう.この実体の変数での置き換え理解が,配列のCでの仕組みを理解する上で重要になる.

データの解釈とアドレス

ここから実際の例を扱っていく.データは記憶域に記憶されているが,計算機の記憶域は整数値しか記憶することができない.この整数値を文字と解釈するか,浮動小数点数と解釈するか,そのまま整数値と解釈するかをコンパイラに伝えるために型が存在する.

以下,2行目の文により1024 Bの記憶域が確保できる.char 型の変数は 1 Byte であり,それが 1024 個あるため,単純に 1024 B である.これに対し3行目は 4 B 変数が 256 個なので 1024 B,4行目も同様に 8 B 変数が 128個なので 1024 B分の記憶域が確保される.要するに全て同じ容量となる.コンパイラはこのために,プログラム起動時に OS へこれらの記憶域の確保を依頼し,そのアドレスを以下の MC, MI, MD へ格納するコードをコンパイル時に生成して追加する.

int main(){
    char MC[1024];
    int MI[256];
    double MD[128];
    return 0;
}
配列と記憶量

どのような型も記憶域に格納できる整数値で表現しなければならないが,必要な整数値桁(データ容量)数がそれぞれの型で異なる.C言語は,記憶域をある型の配列と解釈するとき,この型のデータ容量から各要素の位置を計算する機能を持っている.この為に記憶域の型という概念が存在する.これらの記憶域の型は以下に示すとおり,それぞれ char*, int*, double* などと * をつけて表される.配列は,型の容量と指定した数の分だけ確保された記憶域であり,それを記憶域の型の変数へ入れて使う例を以下では示している.このコードにおいて,12行目のように,配列と同様に使うことができる.つまり,12行目と13行目は全く同じ動作をする.

int main(){

    char MC[1024];
    int MI[256];
    double MD[128];

    char* pMC = MC;
    int* pMI = MI;
    double* pMD = MD;

    for(int i=0;i<256;++i){
        pMI[i] = 1;
        MI[i] = 1;
    }

    return 0;
}
記憶域の型

さらに,ここで一つのアドレスで指定された場所が保持できるデータ量は 1 Bであるとされ,要素あたり 4 B 必要な int 型の配列を宣言した場合,ある要素の次の要素はアドレスの値(整数値)を4増やしたアドレスに配置される.そして,配列の変数は整数値と加算演算が可能である.具体的には,以下の5, 6行目のような記法が可能である.5行目は MI の先頭アドレスから int 型ひとつ分,つまり 4 を足したアドレスを計算し,pMI へ代入することを意味する.6行目は,MI に 8 を足した値を pMI2 へ代入することを意味する.+1, +2 と書いてあるからと言って,単純に 1 や 2 が足されるわけではないことに注意されたい.7行目は char 型のアドレスに加算を行っており,この場合 2 が加算されて pMC2 に代入される.

int main(){
    int MI[256];
    char MC[1024];

    int* pMI = MI+1;
    int* pMI2 = MI+2;
    char* pMC2 = MC+2;

    for(int i=0;i<64;++i){
        pMI[i] = 1;
        MI[i+1] = 1;
    }

    for(int i=0;i<64;++i){
        pMI2[i] = 1;
        MI[i+2] = 1;
        MC[i+2] = 1;
        pMC2[i] = 1;
    }
}

実際に確認してみよう.以下のコードを用意したので実行してもらいたい.(int64_t)は 64 bit OS で実行されることを想定して,アドレスを 64 bit 整数値へ変換するコードである.アドレスの値は整数値であるため,わざわざこのように書かなくても良いはずであるが,文字列の記法で述べる慣習があるため,明示的に整数値として解釈するように指示しなければうまく行かないことがある.この点はややこしい.

#include <iostream>
int main(){

    int MI[256];

    int* pMI = MI+1;
    int* pMI2 = MI+2;

    std::cout << (int64_t)MI << '\n';
    std::cout << (int64_t)pMI << '\n';
    std::cout << (int64_t)pMI2 << '\n';

    return 0;
}

実行結果は次のとおりである.

 140735126180304
 140735126180308
 140735126180312

確かに,4 ずつ値が増えていっていることが分かる.以上のようにアドレスを内容として持ち,型の容量を計算して,要素のアドレスを特定する機能を持つ変数をポインタと呼ぶ.

ポインタのアドレスが示す場所に格納されている値は * をポインタの左側につけることで参照することができる.例えば,以下のとおりである.配列は型情報を持ったアドレスを保持する変数なので,これはポインタである.つまり, * を付けて参照することが出来る.このコードの5行目と7行目は同値である.配列名の変数は,その配列用の記憶域の先頭のアドレスを保持するため,先頭の要素を参照することと単に * を付けて参照することが同じになるということである.

#include <iostream>
int main(){
    int MI[256];

    *MI = 2;
    std::cout << *MI << '\n';
    MI[0] = 2;
    return 0;
}

つまり,さらに言えば次の4, 5, 6行目と 8, 9, 10行目はそれぞれ互いに同値である.繰り返し文を使った 13, 14行目も互いに同値である.

int main(){
    int MI[256];

    *(MI + 0) = 4;
    *(MI + 1) = 4;
    *(MI + 2) = 4;

    MI[0] = 4;
    MI[1] = 4;
    MI[2] = 4;

    for(int i=0;i<256;++i){
        *(MI + i)  = 3;
        MI[i] = 3;
   }
    return 0;
}

ここまでで分かると思うが,添字演算 M[i]というのは,M のアドレスにその型の i 個分の容量を加算したのち,参照するという複雑な処理を実現する.ここまでの記法を使えば,次のコードは理解に苦しむと思うが,以下の二つの繰り返し分は同等な処理を行う.これはよく使われる.実は,1つ目の繰り返しの方が演算回数が少ないことがわかる.2つ目は,i の値を計算し,添字演算を行うため内部で MI + i を計算した後参照が行われる.これに対し1つ目は,アドレスの値を一つ加算して,参照するだけであるので,加算が一回少ない事が分かる.

int main(){
    int MI[256];

    int* pMI = MI;
    int* pMIEnd = MI + 256;
    while(pMI != pMIEnd){
        *pMI = 3;
        pMI++;
    }
    int i = 0;
    while(i !=256){
        MI[i] = 4;
        i++;
    }
    return 0;
}

これがポインタの演算が採用されたという理由だと聞いたことが有るが,近年のコンパイラを使えば最適化によりどちらも速度に違いは出ないようになっている.つまり2つ目の繰り返し文を書いたとしても,1つ目に変換されるということである.

ずっと述べている通り,ポインタに型があっても同じハードウェアの記憶域のアドレスを保持していることに変わりはない.つまり,以下のように,char 型の配列の記憶域を 64 bit 整数型 int64_t として解釈し,値を代入することが可能である.MC を全て 0 に初期化したいのだが,素直に行えば 1024 回の繰り返しが必要である.しかし,8倍の容量の 64 bit ずつ処理すれば,128回で処理は終わる.このために 9行目で int64_t 型のポインタへ変換し,11から13行目で 0 を代入している.ついでに,0以外の値を代入する例を15行目に示してある.これは MC の全要素を 2 にするコードである.MC は 8 bit 区切りであるため,4 bit 区切りの 16 進数を使えば 16進数の2桁分が char 型ひとつ分になるため値を考えやすくなる.この技法もよく使われる.最近は酷いものだと 256 bit整数を使って char 16個分を一度に処理したりしており,この用途はコンパイラによる最適化が難しいため未だに現役である.

#include <iostream>
int main(){
    char MC[1024];

    for(int i=0;i<1024;++i){
        MC[i] = 2;
    }

    int64_t* pMI = (int64_t*)MC;

    for(int i=0;i<128;++i){
        pMI[i] = 0;
    }
    for(int i=0;i<128;++i){
        pMI[i] = 0x0202020202020202;
    }

    return 0;
}

計算機の記憶域の容量が少ないときは,上記方法は代入の高速化のためだけではなく,別の型同士で記憶域を共有し節約するために使われたりもする.

* は間接演算子と言う.変数でなく実体であることを明示して代入や改変を禁止したい場合 const を型の前に付けて宣言する.以下に例を示す.この場合 a や c は変数ではなく,数値の実体 2 の別名であると解釈したほうがいい.

#include <iostream>
int main(){
    const int a = 1;

    a = 2; // 改変は不可.この部分はコンパイル不能
    int b = a; // 読み取りは可能
    const int c = a; // 実体の複製も可能

    return 0;
}

この const を使えば のコンパイラによる書き換えを精確に表現することができる.は以下にコンパイラによって書き換えられると思って良い.たまに 1 = 2 などを回たときにコンパイルエラーで const に代入は出来ないなどと表示されるため,その時はこのことを思い出しせることを祈っている.

int main(){
    const int c1 = 128;
    const int c2 = 52;
    const int c3 = 1;
    int x;
    int y;
    x = c1 + c2;
    y = x + c3;
}
https://krectmt3.sd.tmu.ac.jp/ProBaseII/