C言語のきほん|関数とポインタのしくみ

以下、やわらかい口調で、そのまま記事に使いやすい形でまとめます。本文は、ご提示いただいた内容をもとに再構成しています。

関数とポインタがつながると、C言語の世界がぐっと深く見えてくる

C言語を学んでいると、関数とポインタはそれぞれ別の話のように見えることがあります。
でも実際には、この2つはとても深く結びついています。

関数は、処理をひとまとまりにして使いやすくするための仕組みです。
一方、ポインタは、変数や配列が置かれているメモリの場所を扱うための仕組みです。

そしてC言語では、関数に値を渡したり、関数から結果を受け取ったりする場面だけでなく、配列を扱うとき、文字列を処理するとき、入力を受け取るとき、メモリを直接操作するときにも、ポインタが自然に登場します。

特に標準ライブラリ関数を見ていくと、ポインタがごく普通に使われていることがよく分かります。
たとえば printf は文字列を表示するときにポインタを受け取り、scanf は入力した値を書き込むために変数のアドレスを受け取ります。
memcpy や memset のような関数では、メモリ領域そのものをポインタで指定して操作します。
さらに snprintf や sscanf では、文字列を入出力の対象として扱うことで、ポインタの便利さがよりはっきり見えてきます。

このあたりが理解できるようになると、C言語のプログラムが「ただ動いている」のではなく、どのメモリを、どの関数が、どのように扱っているのかを意識しながら読めるようになります。
それは、より安全で正確なプログラムを書くためにも、とても大切な力です。

今回は、標準ライブラリ関数を手がかりにしながら、関数とポインタがどのようにつながっているのかを、ひとつずつ丁寧に見ていきましょう。

printf と scanf から見るポインタ

まずは、いちばん身近な標準ライブラリ関数である printf と scanf から見ていきましょう。

これらの関数の宣言には、次のような形が使われています。

#include <stdio.h>
int printf(const char * restrict format, ...);

#include <stdio.h>
int scanf(const char * restrict format, ...);

ここで注目したいのは 2 つあります。
ひとつは const char * restrict format、もうひとつは ... です。

可変個引数とは

... は可変個引数を表します。
これは、引数の個数が固定ではなく、必要に応じて後ろにいくつでも引数を渡せる仕組みです。

たとえば printf では、最初の引数に書式文字列を書き、その内容に応じて 2 個目以降の引数を渡します。

printf("%d\n", num);
printf("%s %d\n", name, age);
printf("%f %c %s\n", x, ch, str);

このように、書式文字列の内容に応じて、後ろの引数の数や型が変わります。
scanf も同じ考え方で、書式文字列に応じて必要な個数のアドレスを渡します。

restrict を簡単に説明すると

restrict は少し難しく見えますが、ここでは
このポインタを通してアクセスする対象は、他のポインタとは重ならないものとして扱ってよい
という意味のキーワードだと考えれば十分です。

これにより、処理系は最適化しやすくなることがあります。
学習段階では、まずは「ポインタに付けられる修飾のひとつなんだな」と受け止めておけば大丈夫です。

restrict とは何か?

少し詳しく説明すると

「このポインタは他と重ならない(別の領域を指す)」とコンパイラに伝える約束

です。

restrict の核心

通常、コンパイラはこう考えています。

「このポインタ、他のポインタと同じ場所を指してるかも…」

だから安全のために最適化を控えます。

restrict を付けると

「このポインタは 唯一のアクセス手段 です!」

と宣言することになります。

restrictなし

void func(int *a, int *b)

a と b は同じ場所を指している可能性あり

restrictあり

void func(int * restrict a, int * restrict b)

a と b は 絶対に別の領域

何が嬉しいの?

コンパイラが安心して最適化できる

例えば:

  • メモリアクセスを減らす
  • ループを高速化
  • レジスタに保持できる

printf でポインタが使われる場面

printf は、整数や小数を表示するだけの関数ではありません。
%s を使って文字列を表示したり、%p を使ってアドレスそのものを表示したりできます。

このとき printf は、文字列を指すポインタアドレス値としてのポインタを受け取って処理しています。

サンプルプログラム例

ファイル名:14_1_1.c

#include <stdio.h>

int main(void)
{
    char message[] = "こんにちは";
    char *title = "C言語の学習";

    printf("messageの内容: %s\n", message);
    printf("titleの内容: %s\n", title);

    printf("messageの先頭アドレス: %p\n", (void *)message);
    printf("titleが指すアドレス: %p\n", (void *)title);

    return 0;
}

実行結果例

messageの内容: こんにちは
titleの内容: C言語の学習
messageの先頭アドレス: 0x....
titleが指すアドレス: 0x....

この例で見ておきたいこと

message は文字型配列です。
配列名 message は、式の中で使われると、多くの場合先頭要素へのポインタとして扱われます。

一方で title は、文字列リテラルを指すポインタ変数です。
つまり、

  • message は配列
  • title はポインタ変数

という違いがあります。

けれども printf の %s から見ると、どちらも「先頭文字へのポインタ」として受け取れるため、文字列として表示できます。
また %p を使うと、そのポインタの値、つまりアドレスを確認できます。

ここでひとつ大切なのは、配列とポインタは同じではないということです。
ただし、配列名が式の中で使われると、先頭要素へのポインタのように振る舞うため、見た目が似る場面が多いのです。
この感覚を持っておくと、今後の配列や文字列の理解がかなり楽になります。

(void *) とは何か?

「型を持たないポインタ」への変換(キャスト)です。

基本イメージ

書き方意味
int *pint型のデータを指すポインタ
char *pchar型のデータを指すポインタ
void *p型が決まっていないポインタ

scanf でポインタが必要になる理由

scanf は、キーボードから入力を受け取る関数です。
でも、scanf はただ値を読むだけではありません。
読み取った値を、どこかの変数に書き込む必要があります。

そのため scanf には、書き込み先の場所、つまり変数のアドレスを渡さなければなりません。

この仕組みを分かりやすくするため、サンプルを別の簡単な例に変更すると、次のようになります。

サンプルプログラム例

ファイル名:14_1_2.c

#include <stdio.h>

int main(void)
{
    int score;
    char grade;
    double average;
    char name[50];

    printf("名前を入力してください: ");
    scanf("%49s", name);

    printf("点数を入力してください: ");
    scanf("%d", &score);

    printf("評価を入力してください: ");
    scanf(" %c", &grade);

    printf("平均点を入力してください: ");
    scanf("%lf", &average);

    printf("\n入力結果を確認します。\n");
    printf("名前: %s\n", name);
    printf("点数: %d\n", score);
    printf("評価: %c\n", grade);
    printf("平均点: %.2f\n", average);

    return 0;
}

実行結果例

名前を入力してください: Sato
点数を入力してください: 88
評価を入力してください: A
平均点を入力してください: 82.5

入力結果を確認します。
名前: Sato
点数: 88
評価: A
平均点: 82.50

なぜ & が付くものと付かないものがあるのか

この点は、初学者がとても混乱しやすいところです。
表で整理すると分かりやすいです。

入力先scanf に渡すもの理由
int score&scorescore に値を書き込むため、変数のアドレスが必要
char grade&gradegrade に文字を書き込むため、変数のアドレスが必要
double average&averageaverage に実数を書き込むため、変数のアドレスが必要
char name[50]name配列名 name は先頭要素へのポインタとして扱われるため

つまり、普通の変数には & を付けてアドレスを渡し、配列は配列名そのものが先頭アドレスとして使える、という違いです。

また、%c の前に空白を書いているのは、前回の入力で残った改行などの空白文字を読み飛ばすためです。
この書き方は、文字入力でとてもよく使います。

メモリを直接操作する関数とポインタ

ここからは、ポインタの役割がさらに分かりやすくなる関数を見ていきます。
memcpy と memset は、変数の値ではなく、メモリ領域そのものを対象に処理を行う関数です。

memcpy のしくみ

memcpy は、ある場所にあるデータを、別の場所へ指定バイト数だけそのままコピーする関数です。

宣言は次のようになっています。

#include <string.h>
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
  • s1 はコピー先
  • s2 はコピー元
  • n はコピーするバイト数

です。
どちらも void * になっているため、特定の型に限定されず、いろいろなデータを扱えます。

サンプルプログラム例

ファイル名:14_1_3.c

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

int main(void)
{
    int original[] = {5, 10, 15, 20};
    int copy[4];

    memcpy(copy, original, sizeof(original));

    printf("コピー後の配列: ");
    for (int i = 0; i < 4; i++) {
        printf("%d ", copy[i]);
    }
    printf("\n");

    return 0;
}

実行結果例

コピー後の配列: 5 10 15 20

この例では、original という配列の内容を copy にまとめてコピーしています。
1 要素ずつ代入しなくても、配列全体のバイト列を一括で複写できるのが memcpy の便利なところです。

ただし注意点もあります。
memcpy は、重なり合う領域同士のコピーには使えません。
もしコピー元とコピー先が重なるなら、未定義動作になる可能性があります。
この点は実務でもとても大切です。

memset のしくみ

memset は、指定したメモリ領域を同じ値で埋める関数です。

#include <string.h>
void *memset(void *s, int c, size_t n);
  • s は対象の先頭アドレス
  • c は設定する値
  • n は対象バイト数

です。

サンプルプログラム例

ファイル名:14_1_4.c

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

int main(void)
{
    char line[11];

    memset(line, '-', sizeof(line) - 1);
    line[10] = '\0';

    printf("lineの内容: %s\n", line);

    return 0;
}

実行結果例

lineの内容: ----------

char 型の配列では、このように同じ文字で埋める使い方がよくあります。
一方で int 配列に対して 0 以外の値を入れたいときは注意が必要です。

たとえば

int data[5];
memset(data, 1, sizeof(data));

のように書くと、各 int に 1 が入るわけではありません。
memset は1バイト単位で値を埋めるので、int が 4 バイト環境なら 0x01010101 という値になってしまいます。
これは 10 進数では 16843009 になります。

このため、memset は

  • 0 で初期化する
  • char 配列を特定の文字で埋める

といった用途に向いています。
int 配列に 1 や 100 を入れたいときは、for 文で代入するほうが安全です。

memcpy と memset を一緒に使う感覚

ご提示の内容に沿って、学習用にやさしく整えた例を示します。

ファイル名:14_1_5.c

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

int main(void)
{
    char mark[8];
    char text[] = "C言語";
    char save[20];

    memset(mark, '*', sizeof(mark) - 1);
    mark[sizeof(mark) - 1] = '\0';

    memcpy(save, text, strlen(text) + 1);

    printf("mark: %s\n", mark);
    printf("save: %s\n", save);

    return 0;
}

実行結果例

mark: *******
save: C言語

このプログラムでは、

  • memset で文字配列を * で埋める
  • memcpy で文字列を別の配列へコピーする

という 2 つの使い方を確認できます。
memcpy では strlen(text) + 1 として、最後のナル文字までコピーしている点も大切です。
ここを忘れると、コピー先が正しい文字列として扱えなくなることがあります。

文字列に対して入出力する関数

次に、文字列そのものを入出力の対象にする snprintf と sscanf を見ていきましょう。
これらもポインタと深く関係しています。

snprintf の役割

snprintf は、画面に表示する代わりに、文字列として整形した結果をバッファに書き込む関数です。

#include <stdio.h>
int snprintf(char * restrict s, size_t n,
             const char * restrict format, ...);
  • s は書き込み先の文字配列
  • n は書き込み可能なサイズ
  • format は書式文字列

です。
サイズを指定できるため、バッファオーバーフローを防ぎやすいのが大きな利点です。

サンプルプログラム例

ファイル名:14_1_6.c

#include <stdio.h>

int main(void)
{
    char message[100];
    char name[] = "田中";
    int level = 3;

    snprintf(message, sizeof(message),
             "%sさんの学習レベルは%dです。", name, level);

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

    return 0;
}

実行結果例

田中さんの学習レベルは3です。

このように、複数の値を組み合わせて 1 本の文字列を作りたいときにとても便利です。
strcat を何度も使ってつなげるより、ずっとすっきり書けます。

sscanf の役割

sscanf は、文字列の中から書式に従って値を読み取る関数です。

#include <stdio.h>
int sscanf(const char * restrict s, const char * restrict format, ...);

scanf がキーボード入力を対象にするのに対して、sscanf は文字列そのものを入力元にします。
読み取った値は、scanf と同じように、変数のアドレスへ書き込まれます。

サンプルプログラム例

ファイル名:14_1_7.c

#include <stdio.h>

int main(void)
{
    char data[] = "商品:ノート 価格:350";
    char item[20];
    int price;

    int ret = sscanf(data, "商品:%19s 価格:%d", item, &price);

    if (ret == 2) {
        printf("商品名は%sです。\n", item);
        printf("価格は%d円です。\n", price);
    } else {
        printf("文字列の読み取りに失敗しました。\n");
    }

    return 0;
}

実行結果例

商品名はノートです。
価格は350円です。

このように、決まった形式の文字列から必要な情報だけを取り出す処理で役立ちます。
設定ファイルの簡易解析や、整ったログ文字列の読み取りなどでも応用しやすい考え方です。

snprintf と sscanf を組み合わせてみる

この 2 つを続けて使うと、
「値を文字列にまとめる」
「その文字列から再び値を取り出す」
という流れを確認できます。

サンプルプログラム例

ファイル名:14_1_8.c

#include <stdio.h>

int main(void)
{
    char buffer[100];
    int month = 4;
    int day = 15;
    double temperature = 22.5;

    snprintf(buffer, sizeof(buffer),
             "month=%d day=%d temp=%.1f",
             month, day, temperature);

    printf("作成した文字列: %s\n", buffer);

    int month2, day2;
    double temperature2;

    int ret = sscanf(buffer,
                     "month=%d day=%d temp=%lf",
                     &month2, &day2, &temperature2);

    if (ret == 3) {
        printf("読み取った値: %d月 %d日 %.1f度\n",
               month2, day2, temperature2);
    } else {
        printf("読み取りに失敗しました。\n");
    }

    return 0;
}

実行結果例

作成した文字列: month=4 day=15 temp=22.5
読み取った値: 4月 15日 22.5度

この流れを見ると、

  • snprintf は文字列へ書き込む
  • sscanf は文字列から読み取る

という関係がよく分かります。
どちらも文字列バッファの先頭アドレスや、値を書き込む変数のアドレスを使っており、ここでもポインタが中心的な役割を果たしています。

関数とポインタが結びつくと何が見えるのか

ここまで見てきた関数には共通点があります。
それは、関数がメモリ上の場所を受け取り、その場所に対して読み書きしているということです。

たとえば、

  • printf の %s は、文字列の先頭アドレスから文字を読む
  • scanf は、変数のアドレスへ入力値を書き込む
  • memcpy は、コピー元とコピー先のアドレスを使ってデータを移す
  • memset は、指定したアドレスから一定バイト数を埋める
  • snprintf は、書き込み先バッファのアドレスへ文字列を作る
  • sscanf は、入力文字列を読みながら変数のアドレスへ値を書き込む

という動作になっています。

つまり、関数とポインタの関係を理解するというのは、
関数がどの場所にアクセスし、何を読み、何を書いているのかを理解すること
でもあるのです。

この感覚が身につくと、なぜ scanf に & が必要なのか、なぜ配列名だけで渡せる場面があるのか、なぜ memcpy や memset が高速に見えるのか、といった点がすべてつながって見えてきます。

printf と scanf のポインタの流れ

  • printf は文字列の先頭アドレスを受け取って表示する
  • scanf は変数のアドレスを受け取って値を書き込む
  • 配列名は先頭要素へのポインタとして扱われることがある

この図では、printf と scanf がどちらもポインタを使っていることを視覚的に示します。
ただし役割は逆で、printf はメモリの内容を読む側、scanf はメモリへ書く側です。
この違いが理解できると、アドレスを渡す意味がかなりはっきりします。

memcpy と memset の違い

  • memcpy は別のメモリ領域へ内容をコピーする
  • memset は同じ値でメモリ領域を埋める
  • どちらも先頭アドレスとサイズを使って操作する

この図は、memcpy と memset の働きの違いをひと目で整理するためのものです。
memcpy は「別の場所へ写す」、memset は「同じ値で埋める」という違いがあります。
特に memset はバイト単位で処理するので、int 配列の初期化では 0 以外に注意が必要、という点も図に入れておくと理解しやすいです。

snprintf と sscanf の往復

  • snprintf は値から文字列を作る
  • sscanf は文字列から値を取り出す
  • 文字列バッファが中継地点になる

この図は、数値や文字列が一度 buffer にまとめられ、そこから再び別の変数へ読み取られる流れを示します。
関数がただ値を処理するだけでなく、文字列という形に変換したり、そこから情報を抜き出したりしていることが直感的に分かります。

学習のポイント

最後に、この記事で特に意識しておきたい点を整理しておきます。

ポイント内容
関数はメモリ上の場所を扱うことがある値そのものではなく、アドレスを受け取って処理する
配列名は先頭要素へのポインタとして扱われることがあるそのため scanf や printf で配列名をそのまま渡せる場面がある
scanf は書き込み先のアドレスが必要どこへ入力値を保存するかを関数に知らせるため
memcpy はメモリのコピー型よりもバイト列として扱う
memset はメモリを同じ値で埋める0 初期化では便利だが、0 以外は注意
snprintf と sscanf は文字列を介した入出力バッファのサイズ指定やアドレス渡しが重要

関数とポインタは、C言語の中でも特に「仕組みを理解すると一気に視界が開ける」組み合わせです。
このあと関数ポインタや配列とポインタのより深い話へ進むときにも、今回の内容がしっかり土台になってくれます。