プログラミングに関するヒント

プログラムをコンパイル,実行するにはどうすればいいですか?

C言語のプログラムソースを作ったあと,「コンパイラ」と呼ばれるプログラムを用いて,プログラムソースを実行可能なプログラムへ変換する必要があります.

コンパイラには gcc, clang などがあります.以下の説明では gcc を使っています.

プログラムソースのファイル名が SRC.c の場合,以下のようにして gcc コマンドを実行すると,a.out という名前のファイルとしてプログラムが作成さ れます.

$ gcc SRC.c

ここで a.out というファイル名は,出力ファイル名が指定されない場合の既定値であり,-o オプションを用いてコンパイル時に変更できます.例えば, myprog という名前のプログラムとするなら,以下のように端末へ入力します.

$ gcc -o myprog SRC.c

コンパイルして作成したプログラムは,そのプログラムのファイルが存在するディレクトリにおいて以下のように端末へ入力することで実行できます (ファイル名が a.out の場合).

$ ./a.out

プログラムのファイル名が myprog の場合は,端末へ以下のように入力します.

$ ./myprog

コンパイラにはさまざまなオプションを指定できます.以下に一部を抜粋します.

オプション 効果
-g, -g2, -g3 コンパイルして作られるファイルにデバッグ用データを埋め込みます.-g3 により,もっとも詳細なデータが埋め込まれます.gdb などでデバッグする際にお勧めです
-W, -Wall 警告メッセージを表示します.「C言語の文法としては間違っていないけれど,バグじゃありませんか?」という箇所を教えてくれます. つねに両方 (-W -Wall) を指定しておくことを推奨します
-lLIB 関数をとってくるライブラリファイルを追加します.C言語の標準ライブラリに存在しない関数 (pow, sqrt など) を使う場合,そのような関数を含んでいるライブラリファイル (libm.a, libm.so など) から関数を引っ張ってくる際に指定します.-lLIB のあいだにスペースを入れない点に注意しましょう
-O, -O2, -O3 プログラムを高速化します.-O3 を指定した場合の結果が指定しない場合の結果と異なることが多いため,本実験では -O3 は使わないようにしましょう

pow, sqrt などの数学関数を使う場合,プログラムソース内に #include <math.h> と記述することに加えて,コンパイル時に -lm というオプションの指定が必要です.

多数のプログラムソースから構成されるそこそこ大きなプログラムを作る場合は,make, cmake といったプログラムを使うと作業効率が向上します.詳細は各自で調べてみましょう.

プログラムの実行を強制終了したい

ときにプログラムが無限ループに陥ったり,データが多すぎて処理に時間がかかり過ぎる場合があります.このような場合,端末上で [Ctrl] キーを押しながら [c] キー を押す (いわゆる Ctrl + c ) により,プログラムの実行を強制終了できます.

Ctrl + z でもプログラムの実行を停止できますが,こちらはプログラムをサスペンド (一時停止) するだけでプロセスは残っています.fg コマンドにより 実行を再開できますし,bg コマンドによりプログラムをバックグラウンドで走らせることも可能です.

よくトラブルになるキーとして Ctrl + s があります.これを押下すると,端末への表示がロックされ,プログラムが終了したように見えますが,同時に端末 へのキー入力を受け付けなくなります.この場合,Ctrl + q を押下するとロックが解除されます.

関数って何ですか? どうやって作るんですか?

既存の関数

C言語の「関数」とは,プログラムにおけるひとかたまりの機能を実現するための部品といえます.

これまであまり意識していなかったかもしれませんが,printfscanf は立派な関数です. また,main 関数も,プログラム実行後,最初に実行される特別な関数です.

  • printf: データやメッセージを端末 (正確には「標準出力」) へ出力するという機能を実現します.
  • scanf: 端末からキーボード等で入力した (正確には「標準入力」から与えられる) データを変数へ読み取るという機能を実現します.

こういった基本的かつ重要な機能のための関数は,C言語の仕様としてあらかじめ用意されており,プログラマがそのような関数を個別に作成する必要はありません.

どのような関数が用意されているかは「C言語 標準ライブラリ関数」といったキーワードを用いて検索し調べてみましょう.

標準ライブラリ関数が提供する機能以外にも,数値演算を行うプログラムでは「べき乗」や「平方根」を算出する機能が必要になるかもしれません.このような 機能は,標準ライブラリとは異なる数学ライブラリというファイル内に,それぞれ pow, sqrt という関数として用意されています.このような関数を利用 するためには,プログラムソースに #include <math.h> と記述することにくわえて,コンパイル時に -lm というオプションを追加する必要があります.

ほかにも,例えば任意精度の数値演算を可能とする GMP というライブラリが存在するのですが,このライブラリを使うためには #include <gmp.h> と記述し,かつコンパイルオプションとして -lgmp を追加します.なお,このような指定が正しくても,コンパイルしている環境 (パソ コン) 内にそのライブラリパッケージがインストールされていないと,コンパイルに失敗します.

自作の関数

実際にC言語でプログラムを作っていく場合,機能ごとに 自作の関数 を作成/使用するというのが一般的です.

これまでは main 関数内にすべての処理を書いていたかもしれませんが,それでどうにかなるのはせいぜい 200 行程度のプログラムソースでしょう.長くな るとプログラムソースを読むだけでも大変であり,間違い探し (デバッグ) はさらに難しくなります.

研究や仕事でプログラムを作る段になると,ソースが数百行のプログラムというのは非常に小さい部類に入り,実際には少なくとも数千行のプログラムを作るこ とになると思います.このとき関数を使えないと,手も足も出ないでしょう.

一連の処理を関数として切り出せば,

  1. プログラムの本流 (main関数の処理) は,関数を順に呼び出すだけであり,
  2. 各関数も数百行程度のコードで記述でき,

プログラムの全体像を把握することが容易になります.

例えば,5,000行のプログラムソース全体を一時に把握するのは大変ですが,10個程度の関数に分割すれば,一時に把握する必要があるのは500行程度になります. デバッグなどの際は,5,000行全体を追う必要はなくて,そのとき処理中である関数のソースである500行程度を相手にすれば十分となります.

関数への分割は面倒だと思うかもしれませんが,これができないと卒業研究や開発現場では話になりません.また,関数へうまく分割できない場合,プログラム を理解できていない可能性が高いです.このような場合,苦労して関数へ分割することを通してプログラムの理解が進むでしょう.

関数の作り方は,慣れないうちは段階的に行うのがよいと思います.

  1. main関数から,関数としてまとめる一連のコードを選びます.
  2. そのコードで用いている変数を大域変数とします.このとき,それらの変数の宣言/定義を main 関数から,その外側へ移すことになります.
  3. 関数化するものとして選んだコードを含むvoid型の関数を作り,main関数内のもとの箇所ではその関数をたんに呼び出すようにします.
  4. 大域変数を使わないよう,関数を以下のように改良します.
    1. 関数が必要とするデータを,大域変数としてではなくて関数の引数として与えます.
    2. 関数の処理結果を,大域変数へ代入するのではなく戻り値 (返り値) として返します.
    3. main関数における関数の呼び出しを,実引数を与えて戻り値を使用するよう,変更します.

例えば以下のプログラムソースを考えます.

#include <stdio.h>

int main(void)
{
  int a, b, c:

  a = 2;
  b = 3;
  c = a + b;

  printf("a=%d b=%d c=%d\n", a, b, c);
  return 0;
}

ここで,関数化する必要のないコードですが,足し算をしている c = a + b という処理を add という関数で行うこととします.

そのため,a, b, c という変数を大域変数とし,大域変数を処理する void add(void) という関数を定義し,もとのコードをこの関数で置き換えます.

#include <stdio.h>

int a, b, c; /* main 関数内から変数の宣言・定義を移動 */
void add(void)
{
  c = a + b; /* 処理結果を大域変数へ代入 */
}
int main(void)
{
  a = 2; /* 変数 a, b, c の宣言・定義がなくなっている */
  b = 3;
  add(); /* この呼び出し後,大域変数 c の値が変更されている */

  printf("a=%d b=%d c=%d\n", a, b, c);
  return 0;
}

大域変数を使わないように add 関数を改良し,かつ呼び出し方を変更します.

#include <stdio.h>

int add(int a, int b) /* 必要なデータを引数として与える */
{
  int c;
  c = a + b;
  return c; /* 処理結果を返り値とする */
}
int main(void)
{
  int a, b, c: /* 変数の宣言・定義が復活 */

  a = 2;
  b = 3;
  c = add(a, b); /* どの変数を用いてどの変数の中身を設定するのか,わかりやすくなった */

  printf("a=%d b=%d c=%d\n", a, b, c);
  return 0;
}

配列とポインタの関係がよく分かりません.2つは同じなんですか?

同じように使える場合が多いですが,同じではありません.

  • ポインタは,プログラムが実行されているメモリ空間における番地 (アドレス) を保持する変数です.
  • 配列は,メモリ空間内に連続して置かれている,同じ型の変数の集まりです.
  • 配列名は,配列が存在するメモリ領域の先頭アドレスの別名です.例えば array という名前の配列が存在すれば,その配列の先頭アドレスを保持する array という読取り専用のポインタが存在するかのように扱われます.

ポインタに対しては,*演算子や[]演算子によるメモリの中身へのアクセスが可能です.配列名も「読出し専用ポインタ」のように扱える場合,それらの演 算子を用いた配列の中身へのアクセスに使用できます.

int main(void)
{
  int array[] = {1, 2};
  int* int_p = array;		/* 配列 array の先頭アドレスを int_p へ代入 */
  printf("%d %d\n", array[0], array[1]);
  printf("%d %d\n", *array, *(array+1));
  printf("%d %d\n", int_p[0], int_p[1]);
  printf("%d %d\n", *int_p, *(int_p+1));
  return 0;
}

また,関数の仮引数として配列を記述すると,実際にはポインタに変換して処理されます.例えば,以下の関数 func_1 を考えます.

void func_1(int an_array[])
{
  printf("%d\n", an_array[0]);
}

この関数は,結局のところ以下の関数として処理されます.

void func_1(int *an_array)
{
  printf("%d\n", an_array[0]);
}

しかし配列はポインタとは異なります.以下のプログラムを見てみましょう.

int main(void)
{
  int array[100];
  int* int_p = array;		/* 配列 array の先頭アドレスを int_p へ代入 */
  printf("%lu %lu\n", sizeof(array), sizeof(int_p));
  return 0;
}

このプログラムの,手元の環境での実行結果はつぎのようになりました.

400 8

この結果は,メモリ領域に占めるデータサイズが,配列では400バイト (int型変数1つで4バイト x 100個) であることに対し,ポインタでは8バイトであること を表します.

ポインタが占めるデータサイズは,プログラムが扱えるアドレス空間のサイズに依存します.8ビット = 1バイトですので,手元の環境 (計算機) で64ビットCPU を使っていれば,64ビット/8ビット = 8バイト,となります.32ビットCPUなら4バイトです.

strtok って何ですか?

文字列 (char型の配列) をトークンへ分割するための,標準ライブラリ関数です.トークンを区切るデリミタを任意に (スペースやタブ以外に) 指定できるため, strtok 関数により文字列の柔軟な処理が可能になります.

strtok 関数を使用する際の要点は以下になります.

  1. string.h ヘッダファイルをインクルードする.
  2. 関数の第1引数へ,最初は文字配列の先頭アドレスを指定し,2回目以降は NULL を指定する.
  3. 関数の第2引数へ,デリミタを含む文字列を指定する.
  4. 関数内で,文字列をどこまで処理したかというアドレスを保持している.2回目以降の呼び出しでは,そのアドレス以降を処理する.
  5. はじめに第1引数へ指定した文字列は中身が変更される (デリミタが \0 で上書きされる).
  6. 文字列がもうトークンを含まない場合,NULL を返す.
  7. 連続するデリミタは1つのデリミタとして扱われる.あるいは,トークンが長さ0の文字列となることはない.

strtok 関数の使用例として,例えば以下が考えられます.

#include <stdio.h>
#include <string.h>

int main(void)
{
  char str[] = "hello, I am fine. Thank you"; /* 処理対象の文字列 */
  char *str_head, *ptr;
  int str_size, i;

  str_size = strlen(str);
  str_head = str;
  while ((ptr = strtok(str_head, " ,."))) { /* 「 」「,」「.」をデリミタとする */
    printf("token=%s\n", ptr);
    str_head = NULL; /* 2回目以降は第1引数が NULL になる */
  }

  for (i=0; i < str_size; i++) {
    printf("str[%d]=[%c]\n", i, str[i]);
  }

  return 0;
}

手元の環境での,このプログラムの実行結果は以下になりました.

token=hello
token=I
token=am
token=fine
token=Thank
token=you
str[0]=[h]
str[1]=[e]
str[2]=[l]
str[3]=[l]
str[4]=[o]
str[5]=[]
str[6]=[ ]
str[7]=[I]
str[8]=[]
str[9]=[a]
str[10]=[m]
str[11]=[]
str[12]=[f]
str[13]=[i]
str[14]=[n]
str[15]=[e]
str[16]=[]
str[17]=[ ]
str[18]=[T]
str[19]=[h]
str[20]=[a]
str[21]=[n]
str[22]=[k]
str[23]=[]
str[24]=[y]
str[25]=[o]
str[26]=[u]

strtok 関数は,関数内部に情報を保持しており,この情報は strtok 関数を呼び出すたびに原則として変更されます.よって,ある文字列A を strtok関 数で処理している最中に,べつの文字列B を strtok関数で処理しようとすると,内部の情報が壊れて適切に動きません.この問題を回避できる,strtok_r という再帰呼出しの可能な関数が存在します.

strtok_r 関数を含め,標準ライブラリ関数の使用例や確実な情報を調べたい場合は,マニュアルを参照するようにしましょう.手元の Unix 環境にマニュア ルがインストールされていれば,man 3 strtok と端末へ入力することで,標準ライブラリ関数 strtok のマニュアルを読むことができます.

fgets って何ですか? scanf ではダメなんですか?

標準入力 (stdin) から整数や浮動小数点数,文字列を読み取ることを考えます. 標準入力からデータを読み取ることは,端的には端末上にキーボードから文字を入力することに相当します.

この際,scanf 関数を使うと,以下のような問題が発生することがあります.

  1. 文字列を読み取る場合,文字列を保持する配列よりも長い入力が与えられ,プログラムのメモリ領域の一部が不正に書き換えられてしまう. プログラムは,よくて異常終了,悪いとクラッカーに乗っ取られてしまう.
  2. scanf 関数で想定した様式に従った入力でなかった場合,標準入力の中身の一部だけが処理され,残りがバッファに残っているという状態になる. この状態にはさまざまなケースが考えられ,つぎにどう処理が進むかを予測することが難しい.
  3. 読み取りたい文字列がスペースやタブを含む場合,適切に読み取ることが難しい. (通常,scanf関数を用いると,データはスペースやタブ,改行で分割されてから処理されます.)

fgets 関数は,アドレス (S),文字数 (SIZE),ファイルポインタ (STREAM) を引数として受け取り,たかだか SIZE-1 だけの文字を STREAM から 読み取り,S へ読み取ったデータを格納する関数です.ここで,読み取った文字列の末尾に \0 を追加するため,SIZE よりも1つ少ない数の文字が読み取 られる点に注意しましょう.

標準入力からのデータを,fgets 関数を用いて文字数の上限を決めて取り出し,その配列に対して strtok 関数や sscanf 関数などを用いて処理すること で,上述の問題を回避できます.

fgets 関数と似た関数として gets 関数が存在しますが,この関数は読み取る文字数を指定できないという致命的欠陥を有するため,使ってはいけませ ん.

fgets 関数の使用例として,つぎのプログラムを考えてみます.

#include <stdio.h>

int main(void)
{
  char str[10];
  fgets(str, sizeof(str), stdin);
  printf("str=%s\n", str);
  return 0;
}

このプログラムの実行例を以下に示します.

$ ./a.out
123456789abcd
str=123456789

この実行例から,10文字目 (a) 以降が読み取られていないことを確認できます.

scanf 関数の使用が問題になる例として,つぎのプログラムを考えてみます.

#include <stdio.h>

int main(void)
{
  int i;
  char whole[1024];
  char alpha[4] = "";
  char beta[4] = "";
  char gamma[4] = "";

  while (fgets(whole, sizeof(whole), stdin)) {
    i = -1;
    *beta = '\0';
    if (2 != sscanf(whole, "%d %s", &i, beta)) {
      break;
    }
    printf("i=%d beta=%s\n", i, beta);
  }

  printf("alpha=%s beta=%s gamma=%s\n", alpha, beta, gamma);

  return 0;
}

手元の環境で,「1 dd」と入力してから,Enter, Ctrl-d と押下した場合の実行結果は以下になりました.

$ ./a.out
1 dd
i=1 beta=dd
alpha= beta=dd gamma=

この場合は適切に動いています.

「1 abcdefg」と入力してから,Enter, Ctrl-d と押下した場合の実行結果は以下になりました.

$ ./a.out
1 abcdefg
i=1 beta=abcdefg
alpha= beta=abcdefg gamma=efg

文字配列 beta が,本来許される3文字を超えて「abcdefg」という文字を有していることになっています. また,本来空であるべき文字配列 gamma に「efg」という文字列が入っていることを確認できます.

beta の中身の出力にあたっては,beta の先頭アドレスから読みはじめて,その配列内に \0 が存在せず beta を超えて,beta の後ろにあるgamma のメモリ領域へ入り,結局 gamma[3] にある \0 をもってして beta の文字列が終端した,と処理されています.

なお,scanf 関数において文字を読み取る際,読み取る (\0 を除いた) 最大文字数が M として所与であれば,「%s」ではなく「%Ms」とフォーマット を指定することで,上の例のような問題を回避できます.

上の例では文字配列のサイズが4 (最大文字数が3) であることから,「%s」を「%3s」とした以下のプログラムを考えます.

#include <stdio.h>

int main(void)
{
  int i;
  char whole[1024];
  char alpha[4] = "";
  char beta[4] = "";
  char gamma[4] = "";

  while (fgets(whole, sizeof(whole), stdin)) {
    i = -1;
    *beta = '\0';
    if (2 != sscanf(whole, "%d %3s", &i, beta)) {
      break;
    }
    printf("i=%d beta=%s\n", i, beta);
  }

  printf("alpha=%s beta=%s gamma=%s\n", alpha, beta, gamma);

  return 0;
}

このプログラムを実行し,「1 abcdefg」と入力してから,Enter, Ctrl-d と押下した場合の実行結果は以下になりました.

$ ./a.out
1 abcdefg
i=1 beta=abc
alpha= beta=abc gamma=

この結果から,beta, gamma の中身が適切であることを確認できます.

関数の再帰呼出しって何ですか?

関数の再帰呼出し (recursive call) とは,ある関数 f の中で再び同じ f を呼び出すことをいいます.

例えば,「N の階乗 (N !)」を求める関数について考えましょう.階乗の計算式は「N ! = N * (N-1) !」であり,「N-1 の階乗」を求めておいてそれに N をか ければ「N の階乗」を求めることができます.つまり,「階乗を求める処理」のなかに再び「階乗を求める処理」 が登場します.

この計算式を,1 の階乗は 1 (1 ! = 1) であることに注意して関数 f として実現すると,つぎのようになります.

int f(int n)
{
  int y;

  if (1 == n) {
    y = 1;
  } else {
    y = n * f(n - 1);
  }

  return y;
}

この関数 fmain関数から,つぎのようにして呼び出したとします.(このプログラムでは「3 !」を z に代入し,出力しています.)

#include <stdio.h>

int f(int); /* 関数 f のプロトタイプ宣言.関数 f の定義は省略 */

int main(void)
{
  int z;
  z = f(3);
  printf("z=%d\n", z);
  return 0;
}

この場合,つぎのように関数 f が呼び出されていきます.

関数fの呼出し過程

関数 f が 1 を実引数として呼び出されると,それ以上再帰呼出しは行われません.その後,つぎのように関数 f は,呼び出し元へ値を返してゆき,最終 的に main 関数まで戻ります.

関数fが値を返す過程

結局,上述のプログラムを実行すると以下のようになります.

$ ./a.out
z=6

定数と変数を 「==」や「!=」で比較する際,どうして左辺が定数,右辺が変数になっているんですか?

「==」や「!=」と間違えて「=」演算子を使ってしまった,という誤りを発見しやすくするためです.

左辺が変数ですと,定数を変数へ代入するものとしてコンパイルできてしまいます.一方,左辺が定数なら,「定数には代入できません」とコンパイラに怒られて誤りに気づくことができます.

malloccalloc って何ですか?

malloccalloc は,変数として使用するメモリ領域を確保するための関数です.

プログラムでは,おおまかにいって以下の3種類の変数が使われます.

  1. 自動変数: 関数を呼び出した際に,スタック領域上に自動的に用意される変数です.その関数が実行中は存在しますが,その関数が終了するとスタック領域 から消失します.
    • 自動変数のアドレスを返す関数を作るというバグがよく見られます.最近ではそのような場合,コンパイラが警告メッセージを出してくれるようです.
    • 多くの自動変数を用いる関数を深く再帰呼出しすると,スタック領域を使い尽くしてプログラムが異常終了したりします.
  2. 大域変数: プログラム起動時,データ領域上に用意される変数です.プログラムが終了するまで存在します.
  3. 動的変数: プログラム実行時,ヒープ領域上に用意される変数です.より詳細には,ヒープ領域における指定サイズのメモリ領域を「使用中」とし,そのメ モリ領域のアドレスをポインタへ代入し,ポインタを介して使用します.

ここで,「スタック領域」「データ領域」「ヒープ領域」というのは,プログラムが走るアドレス空間における用途別の領域名です.

malloc は,バイト数を引数にとり,そのサイズのメモリ領域をヒープ領域において「使用中」とし,そのメモリ領域の先頭アドレスを返します.例えば,

int *pa;
pa = malloc(sizeof(int) * 4);

と書けば,pa というポインタはあたかも長さ4のint型配列のように扱えます. ここで sizeof というのは,以下のいずれかを返す演算子です.

  • 引数として与えられたデータ型の変数1つが使用するバイト数.sizeof(int), sizeof(int*) などとして使用する場合の返り値です.
  • 引数として与えられたデータが使用するバイト数.int d, array[40] といった変数が存在するとき,sizeof(d), sizeof(array) などとして使用する場 合の返り値です.

もちろん,長さ4のint型配列ならば,

int pa[4];

と書けば使えます.しかし,このような記述では,pa の長さは4で固定であり,プログラム実行時のデータに基づいて変更できません. データ数が変更になれば再コンパイルが必要になり,例えば「1〜10 の平均値を計算する」「1〜100 の平均値を計算する」場合に,それぞれ異なるプログラム を作って使用するという無駄なことになります.

malloc で「使用中」とされたメモリ領域は,それ以降 malloccalloc を使って確保されることはありません.malloc を何度も呼び出していると, ヒープ領域を使い果たしてプログラムは異常終了します.

malloccalloc で確保したメモリ領域が不要になった場合,free という関数を用いて「使用中」というラベルを取り除きます.適宜 free を呼び出 さないと,もう二度と使わないデータを保持し続けて使えるメモリ領域が減っていくというメモリリークが発生します.

なお,malloc はメモリ領域を確保するだけであり,そのメモリ領域に何らかのデータが書き込まれていても何もしません.

例えば,

int *pa;
pa = malloc(sizeof(int));
*pa += 1;

などと書いた場合,*pa の中身が「1」であることを期待するかもしれませんが,その中身は不定です.「1」かもしれませせんし,「-1」かもしれません.

malloc で確保した領域は,多くの場合明示的な初期化が必要です. 上の例でいえば,

int *pa;
pa = malloc(sizeof(int));
*pa = 0;
*pa += 1;

と書けば,*pa の中身は必ず「1」となります.

このような初期化を自動的に行ってくれるのが calloc です.calloc は,

  • calloc(nmemb, size) というように2つの引数をとり,
  • 1つの要素のバイト数が size である要素が nmemb 個連なるメモリ領域を確保し (全体のバイト数は nmemb ✕ size),
  • そのメモリ領域をゼロクリアしたあとで,
  • そのメモリ領域の先頭アドレスを返します.

動的に長さの変化する配列を用意し,かつその要素をすべて 0 (ポインタ配列なら NULL) にするといった処理を短く記述できます.

関数が引数として構造体をとる場合,どうしてポインタとして指定されていることが多いんですか?

引数をとる関数は,呼び出されたときに,

  1. その仮引数である変数をスタック領域に用意し,
  2. 実引数の中身を対応する仮引数へコピーしてから,

処理を開始します.

引数がint型やchar型なら,それぞれ4バイト,1バイトのメモリ領域を確保・設定するだけなので,実引数を仮引数へコピーする処理コストは小さいです.

しかし,引数が構造体なら,その処理コストが大きくなることがあります.例えば,長さ 1000 の int型配列をメンバとしてもつ構造体を引数とすると,関数呼 出しの際に毎回 4000バイトのメモリ処理が生じます.

このように処理コストが大きくなることを避けるためにポインタを用います.ポインタなら,構造体のメンバがいくら大きかろうと,ポインタ1つ分 (たかだか8 バイト) のメモリ処理で済みます.

なお,関数への引数をポインタとして 与えない 場合,実引数と仮引数は (中身が同じ) 異なる変数であるため,関数内で仮引数をどう変更しても実引数に 影響はありません.一方,関数への引数をポインタとして 与える 場合,実引数と仮引数は異なる変数なのですが,中身のアドレスは同じであり,関数内で 仮引数を経由して行ったデータの変更は実引数にも現れる点に注意しましょう.

構造体ポインタから,その構造体データのメンバへのアクセスを記述するのが手間なんですが…

構造体ポインタは多用されるため,構造体ポインタからそのメンバへのアクセスを短く記述するための -> 演算子が用意されています.

p という構造体ポインタが存在し,かつその値が適切に設定されていれば,p は何らかの構造体データの先頭アドレスを保持しているはずです.このとき, 構造体データ自体は * という演算子を用いて *p として使用でき,その構造体データのメンバには . という演算子を用いてアクセスできます.

例えば,以下のように構造体 foo が定義されているとします.

struct foo {
    int elem_A;
    char elem_B;
};

また,以下のように構造体データおよびポインタの値が設定されているとします.

struct foo data_1 = { 1, 'a' };
struct foo *foo_p = &data_1;

このとき,data_1.elem_A(*foo_p).elem_A は同じであり,同様に data_1.elem_B(*foo_p).elem_B も同じです.

-> 演算子を用いれば,(*foo_p).elem_Afoo_p->elem_A と短く記述できます.elem_B の場合,foo_p->elem_B となります.