ポインタ変数(1)

今回はポインタ変数の入門として,その概念を簡単に紹介する.
ポインタ変数は,C言語の学習の中でも躓きやすいところとされているが,コンピュータのハードウエアを少し理解できれば,そんなに難しい概念ではない.

目次

アドレス

メモリとディスク

コンピュータ内において,文字や数値などの「情報」を記憶しておくハードウエアを,「記憶装置」と呼ぶ.
特に,CPUでの計算や演算中にデータを保管するための領域を「主記憶装置=メモリ」と呼ぶ.
(これに対して,補助記憶装置とは,SSD, HDD,USBメモリ,SDメモリなど,いわゆるディスク類のこと.
近年では「SDメモリ」「USBメモリ」のように,名前はメモリだが補助記憶装置に該当するものがあり,一概に呼び名だけで区別することはできないが,主記憶装置と補助記憶装置の役割分担は明確である.)

主記憶(メモリ)内の情報はコンピュータの電源を切ると消去されるが,読み書きが非常に高速なので,計算処理中の一時保存場所として利用される.(ワーキングメモリ)
一方,補助記憶装置(ディスク類)は,電源OFF後もデータを保持でき,その容量も大きく,メディア自体を取り外して持ち運びできる. その代わり,読み書きの速度は主記憶(メモリ)に対して,かなり遅い.

コンピュータでの処理においては,(文字も含めて)数値情報はメモリやディスクに読み書きすることで記憶・保管される. そのためには,保管場所を正確に指定・管理する必要があり,コンピュータのハードウエアや,Windows・Mac OSなどのOSには,その仕組みが用意されている.

Memory module
Desktop PCに内蔵されているメモリモジュール

変数とアドレス

C言語のプログラム中において

int i;
char moji[100];
float data[10];

のような変数定義をすると,OSにより主記憶装置(メモリ)中に記憶領域が自動的に確保される.
そして,プログラム中からは,i, moji, data[0]などの識別子を使って,変数への代入や,変数の参照などのアクセスができる.
通常の計算処理ではこれで充分であるが,時として変数がメモリ内のどの場所に置かれているかを知る必要がある.
この「メモリ内のデータの置かれている場所」のことをアドレスと呼ぶ.

コンピュータにおけるアドレス値は,実世界のアドレス(=住所・番地)とは異なり,実体は「整数(正確には符号なし整数)」である.
例えば,64bitのOSでは「0x000000001E223FF4DE352BC9」 のような値である.
「アドレス」の概念は C言語固有では無く,一般にコンピュータすべてに共通するものであり,C言語ではアドレスを直接扱う方法が言語仕様に組み込まれている.
逆に.他のプログラミング言語では,アドレスの扱いを極力ユーザーに見えないようにしているものもある.

C言語では,このメモリ内の場所を表す「アドレス値」を格納するための専用の変数が用意されており, それをポインタ変数 と呼んでいる.
ポインタ変数は,単に「ポインタ」と呼ばれることもある.

プログラム中で,ある変数のアドレスを知りたいときは,その変数名の前に & 演算子をつける.
(そうです!,scanf() 関数で,変数の前につけろ,と言われたアレです.)

#include <stdio.h>

int main(void)
{
    int i = 100;

    printf("value   = %d \n",  i );    // 変数のを表示
    printf("address = %p \n", &i );    // 変数のアドレスを表示
    return 0;
}


実行例1:
value   =   100 
address = 000000000061FE1C

実行例2:
value   = 100 
address = 0000004827BFFE3C
address値は,この値になるとは限らない.また,処理系によりアドレス値の先頭に 0x が付く場合がある)

練習問題

上記プログラムを実行し,アドレス値を確認してみよう.
ここに表示された address の値が,変数 i のアドレスである.
16進数表記が分かりにくければ,printf() 中の %p の箇所を %d に変えてもよい.

可能であれば,このプログラムを別のコンパイラで翻訳・実行したり,OSなどの実行環境を変えて試してみよう.

参考までに,C++で記述すると以下のようになる.

#include <iostream>
using namespace std;
        
int main(void)
{
    int i = 100;
        
    cout << "value   = " <<  i << endl;    // 変数の値を表示
    cout << "address = " << &i << endl;    // 変数のアドレスを表示
    return 0;
}


実行例1:
value   = 100
address = 0x61fe1c

実行例2:
value   = 100
address = 0x1ad9dff8dc

これらの例のように,アドレス値は実行のたびに OS が決めるため,この値になるとは限らない.
同一のコンピュータ,同じOS,同じ実行ファイルにおいても,プログラムを実行するたびに変化する場合もあるし,変化しない場合もある.
基本的に,アドレスの値はOSが決めるため,ユーザーが直接管理する必要はなく,変数名で管理すればよい.

ポインタ変数を理解するためには,下図のように「変数の値」と,その「変数のアドレス」の概念をしっかり区別して理解しよう.

Memory
メモリ(箱)に連番で住所(アドレス)がつけられており,何らかの値を格納できる.
プログラム中では識別子 i を使用してアクセスする.
&iと書くと,iのアドレス(ここでは0x0018FF0C)が取り出せる.

アドレスとポインタ変数

アドレスは変数などが置かれている場所であることが理解できたとして,次に,その使い方を解説しよう.

アドレスは「符号なし整数」であるので,unsigned int に格納して利用すれば良さそうに思われるが,以下の理由からポインタ変数という仕組みが用意されている.

一般に,ポインタ変数は「別の変数のアドレス値」を格納する.
重要なことは,単に他の変数のアドレス値を格納しているだけでなく,ポインタを使ってそこに格納されているアドレスに存在する「別の変数」の参照・代入などの操作ができる.
このことから,ポインタ変数は他の変数を「指す」とか,「ポインタ変数が指す変数」という表現をする.
図の矢印のように,ポインタ変数 p は変数は i指している,という言い方をする.

Pointer
ポインタ変数とそれが指す別の変数.
ポインタ変数には別の変数のアドレスが格納される.

ポインタ変数の定義

一般の変数と同様,ポインタ変数も使用前に定義する必要がある.
ポインタ変数では,以下の通り定義時に変数の前にアスタリスク * をつける.

型名 *変数名;

または

型名* 変数名;

ここで,「型名」は「そのポインタ変数が指す相手の変数の型」であり,ポインタ変数自身の値ではない.
なぜならば,ポインタを使用して別の変数にアクセスするために,ポインタが指す相手の変数のサイズ(=何バイト分か)を,ポインタが正しく管理する必要があるためである.
(指す相手が何であろうと,ポインタ自身の値(=アドレス)は「符号なし整数」である.)

型名と変数名の間に置かれる * の前後のスペースは,どちらに入れても良く,以下はそれぞれ同じ意味である.

// 整数型変数を指すポインタ
int *p;
int* p;
// 浮動小数点型変数を指すポインタ
double *p;
double* p;
// 文字型変数を指すポインタ
char *p;
char* p;

ただし,複数のポインタ変数を1行で定義する場合は,注意を要する.

// (1) int *p1;  int p2;  と等価.p1はポインタだが,p2は普通の int 型変数.
int* p1, p2;

// (2) このように書けば, p1 も p2 もポインタ変数となる.
int *p1, *p2;

// (3) 1行に1つ書くスタイル.明瞭かつ変更しやすいのでおすすめ
int *p1;
int *p2;

ヌルポインタ

C言語において,変数は定義直後,中身が「不定」となっているため,ポインタが予期しない場所を指している可能性がある.
そこで,ポインタ変数では特別に「別のどの変数も指していない状態」を設定することができる.

int *p = NULL;
char *p2 = NULL;
double *p3 = NULL;

大文字表記の NULL は,ヌルポインタ (NULL pointer) と呼ばれ,「どの変数も指していない状態の,無効なポインタ」という意味である. (ナルポインタ,と呼ぶ場合もある.)

練習問題

文字列の回で学んだヌル文字 '\0' と,ヌルポインタ NULL の値をそれぞれ調べてみよう.
ヒント:NULL の値を printf の書式指定 %p,または整数 %d などで表示してみよう.

NULL と nullptr

注意:ヌルポインタ,NULL は文字列で学んだ「ヌル文字, '¥0' 」 とは異なる.
(ただし,結果的に双方とも数値の「ゼロ」が割り当てられている場合が多い.)

このような曖昧さを回避するため,最近では NULL ではなく nullptr を使うのが推奨されている.
この授業では,互換性を考慮して NULL を使用する.

ポインタ演算子,*, &

ポインタ変数には,アドレス値(符号なし整数)を直接代入することが可能である.
(ただし,最近のコンパイラでは,ひと工夫しないと警告・エラーとなる場合が多い.)

int *p;
p = 0x1122ffee;

しかし,このような定数の直接的な代入は,通常では行わない
(例外として,マイコンなどの入出力などで直接アドレス指定がされている場合がある.
マイコンや組み込み機器などのハードウエアに近い処理では,今でもC言語が使われている由縁である.)

ではどのようにするかと言うと,ポインタ変数が指したい他の変数のアドレスを & 演算子で取り出し,ポインタ変数に代入する.
逆に,ポインタ変数に* 演算子をつけると,ポインタの指す相手の変数を操作できる.

int i=100;    // int 型のふつうの変数.
int *p;       // int 型変数を指すポインタ p の定義.不定な状態

p = &i;       // p に,i のアドレスを代入 →  ここではじめて p は i を指す状態となる

このような指定をしておくと,変数 i への代入などを,ポインタ p を使って間接的に操作できる.

*p = 20;      // p が指す変数,即ち i=20; と等価

ポインタ操作に関して重要な演算子は,アスタリスク * と,アンパサンド(アンド) & である.

#include <stdio.h>

int main(void)
{
    double r=0.5;   // これはdouble型の変数.
    double *p;      // これはdouble型変数を指すポインタ p の定義.

    p = &r;          // r のアドレス &r を,ポインタ p に代入する.

    printf("  r = %lf ,  &r = %p \n",  r, &r );      // &r は「変数 r が置かれているアドレス」== ???
    printf(" *p = %lf ,   p = %p \n",  *p, p );      // *p は「ポインタ p の指すアドレスにある値」== 5

    return 0;
}

練習問題

上記プログラムを実行し,変数の「値」と「アドレス」の関係を理解しよう.
また,ポインタ演算子を使用した「変数のアドレスを取り出して,ポインタに代入」と,「ポインタの指すアドレスにある値を操作」を理解しよう.

ポインタの指す相手は,プログラムの実行中に自在に変更できる. ポインタがどの変数を指しているのかをよく考えながらプログラムを書く必要がある.

#include <stdio.h>

int main(void)
{
    int a = 1, b = 20;
    int *p = NULL;    // ポインタ変数の定義.NULL で初期化しておく.

    printf("Initial values.\n");
    printf(" a = %3d, b = %3d\n", a, b);      // a,b の値を表示
    printf("&a = %p, &b = %p\n", &a, &b);    // a,b のアドレスを表示
    printf(" p = %p \n", p );               // pの値を表示

    printf("--------------------\n");

    p = &a;                              // ポインタの指す相手を a に設定
    *p = 55;                             // *p の値を変更

    printf("a=%3d, b=%3d, p=%p, *p=%3d \n", a, b, p, *p );    // a,b の値と,p,*pを表示

    printf("--------------------\n");

    p = &b;                             // ポインタの指す相手を b に変更.
    *p = 77;                            // *p の値を変更.

    printf("a=%3d, b=%3d, p=%p, *p=%3d \n", a, b, p, *p );    // a,b の値と,p,*pを表示

    return 0;
}

練習問題

上記プログラムを実行し,a の値,b の値,およびポインタ p の値がどう変わるか確かめてみよう.
ここでは「ポインタの値」とは,「ポインタの指す変数 a,b の値」ではなく,「ポインタ自身の値(=アドレス)」のことである.

配列とポインタの関係

「配列」と「ポインタ」とは非常に親和性が高く,実は [ ] を付けない配列名には,配列の先頭アドレスが格納されている.
次の例で配列のアドレスを確認してみよう.
(アドレス表示の16進数がわかりにくい場合は,%p%d または %u に変えてみよう.)

#include <stdio.h>

int main(void)
{
    int a[] = {10, 20, 30};    // 配列の定義と初期化

    // 配列の各要素のアドレスを表示
    printf("Address of array : %p %p %p \n", &a[0], &a[1], &a[2]);

    // 配列の先頭アドレスを示すようにポインタを初期化. p = &a[0]; と等価.
    int *p = a;

    // p と p の指す相手の値(*p)を表示
    printf("initial   : p = %p, *p = %d \n", p, *p);

    return 0;
}
実行例:
Address of array : 000000000061FE0C 000000000061FE10 000000000061FE14 
initial   : p = 000000000061FE0C, *p = 10

(%p を %d に変更して実行)
Address of array : 6422028 6422032 6422036 
initial   : p = 6422028, *p = 10 

ポインタ変数に整数を加減算(++, --など)すると,「アドレス値」が変更される. 即ち,ポインタの指す相手が変化することを意味する. これは,配列をポインタで操作する際に非常に有用である.

ポインタ変数それ自身は「符号なし整数」であるのに,ポインタの定義に指す相手の型名が必要である理由が,ここに隠されている.
即ち,指す相手のサイズに応じて,ポインタ変数が何バイト分を管理しているのかを決めるためである.

array0 array1
ポインタを配列の先頭に設定したのち,ポインタをインクリメント(++ で1を足す)

ポインタ変数のインクリメントで,アドレス値が実際にどうに変化するかを確認してみよう.

#include <stdio.h>

int main(void)
{
    int a[] = {10, 20, 30};    // 配列の定義と初期化

    int *p = a;

    printf("Address of array : %p %p %p \n", &a[0], &a[1], &a[2]);
    printf("initial   : p = %p, *p = %d \n", p, *p);   // p と p の指す相手の値(*p)を表示

    p++;       // p に 1 を加える
    printf("p++       : p = %p, *p = %d \n", p, *p);

    p++;       // p に 1 を加える
    printf("p++ again : p = %p, *p = %d \n", p, *p);

    return 0;
}

練習問題

上記プログラムを実行し,

  1. ポインタをインクリメント,即ち1を足すと (p++ の部分) ,アドレスの値がどのように増加するか確認しよう.
    16進表記がわかりにくい場合は,%p%dに変えてもよい.
  2. (重要!)配列の型およびポインタの型をそれぞれ,charshort, float, double に変更し,同様にアドレス値がどのように増加するか確かめよう.

このように,ポインタに対する整数の加算(インクリメント)では,指している相手のサイズに応じて,自動的にアドレス加算が行われる.
例えば,char型は 1 byte なので,char*型ポインタの指す相手は 1 byte分だけを考えれば良い.
同様に,intは4 byte ,doubleは 8 byte であるため,あるアドレスを起点として一つのポインタ変数が何バイトを指すかが自動的に計算される.

chararr intarr
(左)char配列の場合,1要素は 1byte.  (右)int配列の場合.サイズが 2 や 8 となる処理系もある.

ポインタによる文字列操作

配列を指すポインタに整数を加減算するとアドレス値が変更されるので,文字列を操作する際に非常に有用である.

#include <stdio.h>

int main(void)
{
    char s[] = "She has a pen and a book.";    // 文字列
    printf("Address of array : %p %p %p \n", &s[0], &s[1], &s[2]); // 最初の3要素のアドレスを表示


    char *p = &s[0];                   // 配列の先頭を示すよう,ポインタを初期化

    printf("initial   : p = %p, *p = %c \n", p, *p);        // p および p の指す相手の値(*p)を表示

    p++;       // p に 1 を加える?
    printf("p++       : p = %p, *p = %c \n", p, *p);

    p++;       // p に 1 を加える?
    printf("p++ again : p = %p, *p = %c \n", p, *p);

    return 0;
}

練習問題

上記プログラムを実行し,p自身の値と,pの指す相手の値の変化をよく理解しよう.

次に,ポインタが示すアドレスに格納された「値」を変更するにはどうすればいいだろうか?

#include <stdio.h>

int main(void)
{
    char s[] = "Meiji University";
    char *p = s;    // 文字列の先頭アドレスを指すよう初期化.s == &s[0] である

    printf("%s \n", s);

    *p += 3;      // ポインタの指す「値」が変更される???.
    *(p+6) += 1;      // ポインタの指す「値」が変更される???.

    printf("%s \n", s);

    return 0;
}

練習問題

上記プログラム*p += 3*(p+6) ... の行の右辺の定数や加減算を変更し,結果がどのように変化するか確かめよ.
演算子の優先順位に注意.

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

  1. 入力した文字列の順序を反転して表示するinverted_print()関数を作成せよ.
    この関数内でprintf()を使用して表示してよい. main関数は変更しないこと.

    #include <stdio.h>
    
    const int MAX_CHAR = 128;   // 入力文字数の最大値
    
    void inverted_print(char* str)
    //void inverted_print(char str[])   // これとほぼ同じ
    {
        // ここで逆転・表示する
    }
    
    int main(void)
    {
        char words[MAX_CHAR];
    
        printf("Type words: ");
        scanf("%[^\n]%*c", words);	    // %[^\n] は,改行コード以外を格納.%*c は,最後の改行コードを読み飛ばす処理.
    
        inverted_print(words);    // words == &words[0] 即ち配列の先頭アドレス
    
        return 0;
    }
    
    【実行例】
    Type words: Hello, world 2024!
    -> !4202 dlrow ,olleH
    
  2. 前問に続き,入力した文字列の順序を反転するinvert_str()関数を作成せよ.
    表示はmain()関数で行うため,invert_str()では表示をせず,文字列を逆順に入れ替えるのみとすること.

    ヒント:invert_str()関数内で
    方法1:別の文字型配列を用意し受け取った仮引数の文字列をいったんコピー,元の配列に結果を上書きしてゆく.
    方法2:仮引数の文字列の先頭と末尾の文字をswap, 2番目と末尾-1番目の文字をswap, ... を,(文字列長さ)/2 まで繰り返す.

    #include <stdio.h>
    
    const int MAX_CHAR = 128;   // 入力文字数の最大値
    
    void invert_str(char* str)
    {
        char tmp[MAX_CHAR];  // 一時保管用の文字列.
    
        // ここでは画面表示を行わないこと
    }
    
    int main(void)
    {
        char words[MAX_CHAR];
    
        printf("Type words: ");
        scanf("%[^\n]%*c", words);	    // %[^\n] は,改行コード以外を格納.%*c は,最後の改行コードを読み飛ばす処理.
    
        invert_str(words);
    
        printf("%s", words);
    
        return 0;
    }
    
    
    【実行例】
    Type words: Hello, world 2024!
    -> !4202 dlrow ,olleH
    
  3. ある文字 c と,続く単語 word を入力したとき, word 中に文字 c が存在すれば 1 を出力,存在しなければ 0 を返す関数hantei()を作成せよ.
    同様に,配列中に見つかった文字 c のアドレスを返す関数get_address()を作成せよ.

    #include <stdio.h>
    
    const int MAX_CHAR = 128;   // 入力文字数の最大値
    
    int hantei(char c, char* str)
    {
        ...
    }
    
    char* get_address(char c, char* str)
    {
        return /* アドレスを返す.見つからない場合はNULLを返す */
    }
    
    int main(void)
    {
        char word[MAX_CHAR];
        char c;
    
        printf("Type words: ");
        scanf("%c", &c);	    // 1文字を入力
        scanf("%[^\n]%*c", word);
    
        if(hantei(...)==0)...
    
        printf("address=%p\n", get_address(...) );
    
        return 0;
    }
    
    【実行例】
    
    入力:a apple
    出力:
    1
    address=0x12345678
    
    入力:b apple
    出力:
    0
    
  4. ある整数 n(>0) と,続く文章 string を入力したとき, string 中の文字を n 個おきに表示する.

    #include <stdio.h>
    
    const int MAX_CHAR = 128;   // 入力文字数の最大値
    
    int main(void)
    {
        int n;
        char word[MAX_CHAR];
        scanf(...);
        
        char *p = &word[0];
        
        // p を用いて処理
        
        return 0;
    }
    
    
    【実行例】
    入力:2 Hello!
    出力:Hlo
    
    入力:3 M3Eed!issj*(iZl.#Q
    出力:Meiji.