Lecture 10 ポインタ(1)

ポインタの解説動画

昨年度の講義(昨年度は第9回として実施)の録画を,以下に載せました.聞き逃した人はこちらを確認してください.




ポインタは,コンピュータのアドレスを利用して様々なデータにアクセスする手段であり,プログラムに非常に大きな自由度をもたらす. この自由度の大きさが,C言語をプログラミング言語の中で最も利用されるものに至らしめたといっても良い.

もっとも,ポインタの概念はC言語独自のものでは無く,現在のデジタルコンピュータ全体に通用する普遍的な概念である. ポインタは,コンピュータの中の構造が「見える」機能であり,コンピュータの構造を理解して初めて利用が可能になる. 従って,コンピュータの構造がわからなければこの概念は理解できない. この為,C言語の学習において,初学者が最も躓きやすい,理解が難しい概念となっている (もちろんこれは,C言語が難しいからという理由よりも,デジタルコンピュータの構造が理解できないことに起因している).

逆に言えば,これを突破できれば,デジタルコンピュータそのものが理解でき, 今後どのようなハードウェアや,言語でも習得可能となる (車の制御,エンジンの制御,家電の制御など, およそすべての機械の制御には組込みの超小型コンピュータが使われており, これらを使いこなすにはデジタルコンピュータの知識が不可欠だからである).
正念場です.気合を入れて取り掛かろう!

ポインタの基礎

ポインタとは,文字通り「別のオブジェクトを指し示す変数」である. その実態は「(別のオブジェクトの)アドレスを保持する変数」である.

ポインタの宣言は以下の通り.
型 *変数名;
ここで,「型」は「ポインタが指し示すオブジェクトの型」である.変数名の前にアスタリスクがついていることに注意.

【ヒント】アスタリスク前後のスペースはどこに入っても良い.例えば,
int *p;  と  int* p;
はどちらでも同じであるが,前者は従来型のCで, 後者は最新のC++でそれぞれ好んで用いられる表記である. 後者は「intポンタ型」を表すという意味合いが込められているが, 実際には「intポインタ型」が存在するわけではない. そのため,
int* p1, p2;	// ダメ
という表記は思惑通りには行かず,p2は普通のint型になってしまう.つまり,
int *p1, p2;	// ダメ
ということである.従って,C++では,カンマで区切って変数を宣言することはせず, 以下の様に,ひとつひとつ初期化つきで変数宣言をする慣わしになっている.
int* p1 = NULL;	// 良い.常に初期化する
int* p2 = b;	// 良い.

アスタリスク(*)とアンパサンド(&)

int a = 0;	// これは普通のint型の変数.初期化を忘れないように
int* p = &a;	// これはaのアドレスを指し示すポインタ変数 p の宣言.
		// aのアドレスで初期化している.&は参照(アドレス)を示す.
既に解説している通り,Cには,アスタリスク(*)とアンパサンド(&)という2つのポインタ演算子がある.

  • &演算子は,その後に続く変数のアドレスを返す.
  • *演算子は,後に続く「アドレス(場所)に格納された値」を返す.

【ヒント】ポインタ演算子は,乗算の演算子と同じアスタリスクを使うが,意味が全く異なる.混同しないように.

ポインタの利用例:
#include <stdio.h>
int main(void){
    int a = 123;
    int* p = &a;           // ポインタの宣言と初期化(代入).
    p = &a;                // ポインタの代入.上の代入と同じ内容.
    printf("a = %d\n", *p);// ポインタが示す先に格納された「値」を表示
}

誤りの例1 メモリ破壊

    int a;
    double* p;
    p = &a;    // 誤り!
    *p = 3.14; // この行だけ見る限りは文法的には合っているが...
上の例は,*p に3.14を代入すること自体,文法的に誤りではないが,それが示す変数は整数型である. 従って,このプログラムは意味を成さない結果となる.しかも,intは4バイト,doubleは8バイトであるから, 4バイト分はみ出して,使用宣言されていないメモリ領域を破壊することになる.

キャストによる強制型変換

上記のプログラムは,コンパイラによってはコンパイルエラーとなる可能性が高い. その場合は,p = &a;の行を,キャスト演算子を使って,doubleを示すポインタ型に強制変換:
    p = (double*)&a;
してコンパイルし,実行してみなさい.

誤りの例2 メモリ破壊

int main(void){
    int* p;    // 初期化しないと....
    *p = 100;  // どこに格納される?
}
上の例では,ポインタはどこも指示していない.指示しないポインタは,実際には何らかのゴミデータが入るため, 偶然に指示されたどこかのアドレスに値100を書き込むことになる. その結果は,当然,メモリの破壊につながり,プログラムは強制終了させられるか,または 単に計算結果がおかしくなる.

ポインタ変数同士の算術演算

ポインタが指し示す変数同士の演算ももちろん可能である.ただし,アスタリスクがつくため,少々見にくい.
    float* a = ...;
    float* b = ...;
    float x = *a * *b;  // floatの値同士の掛け算.星,星,星・・・!
かなり見苦しいので,カッコでくくって,
    float x = (*a) * (*b);  // floatの値同士の掛け算.
などとすると良い.

C++だけの新機能「参照型引数」おすすめ!

前章で用いた「参照型引数」は,実はC++(ファイル拡張子名が.cpp)だけに拡張された新機能であり,C(拡張子.c)では使えない.
例えば,引数に用いた&
    double func(double& a, double& b)
は,double型の変数への参照を表し,利用側でも
    double x=2, y=3;
    double z = func(x,y);
と,ポインタを一切用いなくても参照渡しが可能になった.これにより,ポインタのアスタリスクによって 算術演算が見にくくなる問題を回避することができる.
    void func(double& a, double& b){
        double y = a*b;  // double同士の掛け算.みやすい!
        ....
    }

ポインタのアドレス計算

ポインタが優れていることは,ポインタのアドレス計算が,型の大きさに応じて自動的になされることである.
    short int si[100];
    short int* sip = si;
    *sip++;	// この結果は,先頭より2バイト先,si[1]を示す.
これによって,以下のようなことも可能である.
    char c[sizeof(long)*100];	// long 100個分の領域を確保
    long* x = (long*)c;	// char 配列の先頭にlongポインタをセット
    for(int i=0; i<100; i++)
        *x++ = i;	// 0から99まで値を代入.格納されるのはchar配列の中
この時,値が実際に格納されるのは,はじめに宣言した char配列の中だが, long型のポインタでアクセスしているので,アドレスは4バイト毎にインクリメントされ, long型としての整合性を保ち続ける.

ポインタ式の制限

ポインタの値そのものはアドレスである.ポインタには加減算を施すことができる.この場合,変更されるのは「アドレス」である.
    char s[] = "Meiji University";
    char* p = s; // 文字列の先頭アドレスを示すように初期化
    p += 3;      // この演算の後,*p は文字'j'を示す

【ヒント】[ ]の無い配列名は,配列の先頭アドレスを示す.&s[0]と同じ.
【ヒント】ポインタの演算は,整数の加減算以外は実行できない.

では,ポインタが示すアドレスに格納された値を変更するにはどうすればいいだろうか?
    char s[] = "Meiji University";
    char* p = s;    // 文字列の先頭アドレスに設定
    (*p) += 3;      // 文字'M'が'P'に変更される.
    *p += 3;        // *p は文字'j'を示す!!!
数学で,加減算よりも乗除算が先に実行されるように,C言語の演算子にも演算の順序がある. ポインタ演算子は,加減算よりも後に実行されるため,上記の最後の行のような結果になることに注意.

配列とポインタ,文字列定数とポインタ

既に,上記の文字配列をポインタで示した通り,配列のポインタとは親和性が高く, 配列の要素にポインタでアクセスすることができる.
その一方で,ポインタに配列演算子をつけて,配列と同じに使うこともできる.
    char* p = "Meiji University";
    p[3];    // ポインタに配列の添字をつけることができる.
ただし,配列名をポインタの様にアドレス変更することは出来ない.
    char s[] = "Meiji University";
    s++;    // エラー!! sは配列の先頭ラベルで,アドレスを変更することは出来ない.
    char* p = s;
    p++;    // もちろん,これは可能.

ポインタなら,アドレスを変更することができるため,この機能を使って以下のようなプログラムを作ることができる.
    #include <stdio.h>
    int main(void){
        char* p = "Meiji University";
        while(*p)
            printf("%c", *p++);
    }
最初,pは文字列定数の先頭'M'の位置を示している.printfによって,一文字ずつ表示しながら,アドレスをインクリメントすることによって,文字列中の文字を次々に表示していく. 文字列定数の最後は必ず「ヌル文字」であるため,while(*p) は false となってループが終わる. このように,ポインタを用いれば,文字をスキャンする時にループカウンタは必要ない.

ポインタ配列

ポインタも他の変数と同様に配列を作ることができる.データベースなどを作るときに有効. 以下は名簿へのアクセス例.
    char* p[] = {
        "きかい はなこ",
        "めいじ たろう",
        "りこう いちろう",
        ""
    };
    
    void meibo_print(int shusseki_banngou){
    	printf("%s", p[shusseki_bangou]);
    }

仮引数としてのポインタ

文字列を引数として関数に渡すときは,文字列全体のコピーを作るのではなく, その先頭アドレスが渡される.これをポインタで受けると, アドレスのスキャン(順次変更していくこと)ができる.
以下のプログラムの実行結果はどうなるか,確かめよ.
//
// 映画「2001年宇宙の旅」より
// (劇中に登場する,反乱する人工知能HAL9000は誰が作ったのか?
//  の謎を解くプログラム)
//
void who_made(const char *s){  // 先頭アドレスをポインタで受ける
    while(*s)
        printf("%c",(*s++)+1);  // アドレスをインクリメント
    printf("\n");
}

int main(void){
    who_made("HAL");  // HALを作ったのは誰?
}


Quiz 10

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

    1. 【ポインタ引数と参照型引数】入力したint型整数の値に2を加える関数plus2()を作成しなさい. main文は以下のものを使用するが,一部正しくなるように修正せよ. 関数plus2()の引数にはint型整数を示すポインタを渡さなければならないことに注意せよ. また,参照型引数を持つ plus2_ref() も作成してみよ.
      void plus2(int* x)
      {
          // ... ?
      }
      
      void plus2_ref(...?)
      {
          // ... ?
      }
      
      int main(void)
      {
          printf("整数を入力して下さい: ");
          int c = 0;
          scanf("%d",&c);
         
          int *p = &c; // ポインタpの宣言と初期化
                       //(cのアドレスをpに格納)
         
          printf("cの値は%dです\n",c);
          printf("*pの値は%dです\n",*p);
      
          plus2(&c);  // 関数の中でcに2を加えたい!
          printf("関数代入後のcの値は%dです\n",c);
          printf("関数代入後の*pの値は%dです\n",*p);
         
          plus2(p);  // 関数の中でcに2を加えたい!
          printf("関数代入後のcの値は%dです\n",c);
          printf("関数代入後の*pの値は%dです\n",*p);
         
          plus2_ref( ...? ); //...どう使いますか?
          printf("関数代入後のcの値は%dです\n",c);
          printf("関数代入後の*pの値は%dです\n",*p);
      
          return 0;
      } 
      

      解答例 Q10-1




    2. 【ポインタによる文字位置のスキャン】 入力した文字列を反転して表示するプログラムをポインタを用いて実現せよ.
      void inverted_print(char* s)
      {
          // ....
      }
      
      int main(void)
      {
          const int SIZE = 128;
          char words[SIZE+1];
          printf("Type words: ");
          scanf("%[^\n]", words);
          inverted_print(words);
      }
      

      【実行例】
      > q10-2
      Type words: Hello, world 2014!
      -> !4102 dlrow ,olleH
      

      解答例 Q10-2




    3. 【配列引数】以下は,0から99までの100個のint型データを作成して配列に格納し (create関数.但しこの関数は何個でも対応できるものとして作ること), その結果を表示する(print関数)プログラムである.それぞれの関数の中身を作成して, プログラムを完成せよ.main()関数や,それぞれの関数の引数を変更してはならない.
      void create(...)
      {
          //...
      }
      
      void print(...)
      {
          //...
      }
      
      int main(void)
      {
          const int SIZE = 100;
          int data[SIZE];
          create(data,SIZE);
          print(data,SIZE);
      }
      

      【実行例】
      > 10-3
      data[00]=00
      data[01]=01
      data[02]=02
      ....
      

      解答例 Q10-3




    4. 【データ型のサイズとポインタ】要素数5個のchar型配列,short型配列,int型配列,double型配列をそれぞれ用意し, それぞれの配列の要素一つ一つのアドレスを表示するプログラムを作成せよ. なお,アドレスは,printf文の%pで表示させることができる.
      実際に表示されるアドレスは環境により異なり,下の例のようにはならないことに注意せよ.
    5. //【プログラム例】
      int main(void)
      {
          const int SIZE = 5;
          char   c_data[SIZE];
          short  s_data[SIZE];
          int    i_data[SIZE];
          double d_data[SIZE];
      
          show_addr_c(c_data, "char", SIZE);
          ....
      }
      

      //【実行例】
      > 10-4
      char型配列
      &c_data[0]=0x00000000, アドレス差
      &c_data[1]=0x00000001, 1
      &c_data[2]=0x00000002, 1
      &c_data[3]=0x00000003, 1
      &c_data[4]=0x00000004, 1
      
      short型配列
      &s_data[0]=0x00001000, アドレス差
      &s_data[1]=0x00001002, 2
      &s_data[2]=0x00001004, 2
      &s_data[3]=0x00001006, 2
      &s_data[4]=0x00001008, 2
      
      int型配列
      .....
      

      解答例 Q10-4



      【ヒント】C++のテンプレート機能を使って,すべてのデータ型に対応する関数を一つだけ作ってみました. テンプレートを使わない場合は,各データ型用の関数をそれぞれ作る必要があります.

    6. 2つの文字列を連結して一つの文字列にする関数
      char* my_strcat(char* s1, const char* s2)
      を作成し,キーボードから入力した任意の2つの文字列を連結して表示するプログラムを作成せよ. 但し,当該関数は,文字列s1の最後に文字列s2を連結し, その結果の文字列へのアドレスを返すこととする.
      必要に応じて,strlen()関数も作成してみよ.
      また,以下のmain関数を利用せよ.
      int main(void)
      {
          printf("Type string #1: ");
          char s1[100];
          scanf("%[^\n]%*c",s1);	// 改行コード以外をs1へ,
                                	// 最後の改行コードは読み飛ばす
      
          printf("Type string #2: ");
          char s2[100];
          scanf("%[^\n]",s2);		// 改行コード以外をs2へ.
      
          printf("result: %s\n", my_strcat(s1,s2));
      }
      

      解答例 Q10-5


      【ヒント】文字列の最後尾に別の文字列を追加するために,文字列の長さを返す関数 strlen() を使っていますが, ここでは strlen() も自作してみました.



Assignment(課題)10

課題の提出は Online Judge にて行います.

  • 提出期限があります.できるだけ早めにやりましょう.
  • Online Judge は普通の課題提出方法とは異なり,良い点が取れるまで何度でも挑戦できます.良い点が取れるまで頑張ってやろう.
  • わからないことは,早めに Teams で聞こう.問題は自分で解決すること.
  • それでもどうしてもわからないときは,毎週月曜日の2時限目にZoomによるオフィスアワーを用意しています. ZoomのリンクはOh-o! meijiで確認してください.
  • Online Judge のページはこちら