C言語応用問題集

C言語練習問題のアイキャッチ

調べて見たらC言語の問題集サイトが意外と少なかったので作ってみました。

C言語の勉強・復習・理解度チェック等好きな用途で使用してください。

一通りC言語を学んだ型向けの問題が多く、基本的にちょっとつまづきやすいような問題や知っておくと役に立つ知識が身につくような応用問題にしています。

本の問題集と違ってコメントしていただければ分からない点をお答えすることができますのでぜひコメント欄も活用していただければと思います。

各問題は

  • 問題
  • 条件
  • 問題の狙い
  • ヒント
  • 回答例

の構成で成り立っています。

ヒントや解答は初期状態だと見えませんが、クリックすれば中身が見れるようにしています。

MEMO

問題はまだまだ少ないです。すみません・・・。

順次追加していきますのでお楽しみに!

C言語の基本・ソースコードの書き方

いろんなコメントアウトの仕方

下記プログラムで /* ここから不要 */ から /* ここまで不要 */ の部分が不要になったのでコメントアウトしたのですが、うまくコメントアウトができずコンパイルエラーになってしまいました。

#include <stdio.h>

int main(void){
  int i,  j;
  int data[100];

  /* ここから不要 */
  /*
  for(i = 0; i < 10; i++){
    for(j = 0; j < 10; j++){
      /* i * j を計算 */
      data[j * 10 + i] = i * j;
    }
  }
  */
  /* ここまで不要 */

  for(j = 0; j < 10; j++){
    for(i = 0; i < 10; i++){
      /* i * j を計算 */
      data[j * 10 + i] = i * j;
    }
  }

  return 0;
}

コンパイルエラーになる理由はなんでしょう?

どのようにすれば簡単にこの不要部分をコメントアウトできるでしょうか?

条件

「//」は使用せずにコメントアウトしてください。

問題の狙い

いろんなコメントアウトの方法があることを知る。

ヒント

様々な方法があると思いますが、プリプロセッサを利用すれば簡単にコメントアウトすることができます。

解答例

コメントは /* から最初の */ 部分となります。ですので、上のソースコードのようにコメントを入れ子にすると途中までしかコメントとして扱われません。

下記のようにディレクティブ #if を使用すれば「#if 0 〜 #endif」の部分を簡単にコメントアウトすることが可能です。

/* ここから不要 */
#if 0
for(i = 0; i < 10; i++){
    for(j = 0; j < 10; j++){
       /* i * j を計算 */
       data[j * 10 + i] = i * j;
    }
}
#endif
/* ここまで不要 */

ディレクティブとはプリプロセッサへの指示です。「#if 条件文」と書けば「条件文」が成立しない場合、「#endif」までの部分がコメントアウトされることになります。

#include なんかもディレクティブです。ディレクティブ・プリプロセッサについては下記で解説していますので、興味があれば読んでみてください。

プリプロセッサ解説ページアイキャッチ【C言語】プリプロセッサについて解説!#includeや#defineの意味が理解できる!

スポンサーリンク

変数

計算結果が毎回変わる…

1から10の整数の足し算を行うプログラムを作成しました。

実行してみたところ、計算結果が55になりません!

それどころか実行するたびに結果がバラバラになってしまいます。

どのようにプログラムを修正すれば良いでしょうか?

#include <stdio.h>
  
int main(void){
  int x;
  int i;

  for(i = 1; i <= 10; i++){
      x = x + i;
  }

  printf("%d\n", x);

  return 0;

}

条件

条件は特にありません。

問題の狙い

変数についての基礎知識を身につけることです。

ヒント

変数宣言で作成された変数に格納される値は?

解答例

問題点は変数 x の初期化が行われていないことです。

変数宣言して作成した変数には基本的に不定値が格納されます。そのためプログラム実行ごとに異なる値が格納され、異なる結果となっているというわけです。不定値というのは何が入るか分からない値です。ランダムな値みたいな感じで考えて良いです。

なので、不定値を自分の意図する値で初期化してやればこの問題は解決します。具体的には変数宣言部分を下記のように変更すれば良いです。

int x = 0;

初歩的な問題ですが、実際プログラミングしていると陥りやすい問題です。変数宣言時には必ず初期化を行うクセをつけると良いと思います(私もできてない…)。

関数呼び出し回数のカウント

関数が呼び出された回数を関数内でカウントして表示するプログラムを作成しました。

#include <stdio.h>
  
void call_count(void)
{
  int count = 0;
  count++;
  printf("%d 回目\n", count);
}

int main(void){
  int i;

  for(i = 0; i < 5; i++){
    call_count();
  }

  return 0;

}

しかし毎回「1 回目」が表示されてしまいます。

1 回目
1 回目
1 回目
1 回目
1 回目

どうやらカウントアップが上手くできていないようです。なぜ上手くカウントアップできていないのでしょうか?カウントアップするためにどのようにソースコードを変更すれば良いでしょう?

条件

関数への引数の追加と関数の戻り値の型の変更は禁止します。

問題の狙い

変数のスコープ、静的変数、自動変数について理解する

ヒント

関数内で変数宣言された変数は、特に指定がない場合は自動変数として作成されます。自動変数ではなく静的変数で count 変数を作成するためには・・・?

解答例

ヒントで述べたように count は自動変数として作成されています。自動変数は下記のような特徴を持ちます。

  • 関数実行時に作成と初期化が行われる
  • 関数終了時点で削除される

ですので、自動変数を関数内でカウントアップしても、次の関数呼び出し時にはまた count は 0 に初期化されてしまいます。

つまり、カウントアップが行われない理由は count 変数が自動変数であるためです。

解決方法は count 変数を自動変数ではなく、静的変数として宣言する事です。下記のように count の型に static をつけて変数宣言すれば静的変数として作成できます。

void call_count(void)
{
  static int count = 0;
  count++;
  printf("%d 回目\n", count);
}

静的変数は下記のような特徴を持ちます。

  • プログラム起動時に作成され初期化される
    (関数実行時に作成と初期化は行われない)
  • プログラム終了時に削除される
    (関数終了後も残る)

作成・初期化・削除されるのはプログラム実行からプログラム終了までで一度だけであり、関数実行時に初期化は行われなくなります。

そのため意図した通りに関数呼び出し回数をカウントアップすることができます。

スポンサーリンク

変数宣言に失敗する…

下記のようなプログラムを作成して実行したところ、全くうまく動作してくれません…。

#include <stdio.h>

#define SIZE (5 * 1024 * 1024)

int main(void){
    double data[SIZE];
    int i;

    printf("START!!!\n");

    for (i = 0; i < SIZE; i++) {
        data[i] = (double)i / (double)SIZE; 
    }
    
    printf("FINISH!!!\n");

    return 0;

}

実行すると、下記のようなメッセージが表示されて即座にプログラムが終了します(環境によって表示されるメッセージは異なる可能性があります)。

segmentation fault

うまく動いていたら FINISH!!! と表示されるはずなのに…。そもそも START!!! が表示されていないので変数宣言時点でプログラムが終了している…?

ここで問題です。上記ソースコードにおいて、プログラムが正常に動作しない理由はなんでしょうか?どうすれば正常に動作するでしょうか?

条件

SIZE の定義値の変更は禁止します

問題の狙い

変数の種類による違いを理解する

ヒント

仮にですが、SIZE の定義値を小さくすれば正常に動作します。

ここから推測できることは、変数 data を配置(変数宣言)するためのメモリが足りていないことです。

また、1つ前の問題でも解説したように、関数内で変数宣言された変数は、特に指定がない場合は自動変数として作成されます。この自動変数は基本的にはスタックメモリというメモリ領域に配置されます。

どこに配置されるかは OS の仕様にも寄るようですが、おそらく Mac や Linux、Windows ではスタックメモリに配置されると思います。

つまり、スタックメモリが data を配置するには小さすぎるのでプログラムが異常終了していると考えられます。

であれば、スタックメモリ以外のもっと大きなメモリ領域に data を配置するようにすれば上手く動くはず。

解答例

変数 data を宣言する際に、下記のように static 指定して静的変数として変数を作成するようにすれば、正常に動作するようになるはずです。

static double data[SIZE];

まず前提としては、メモリはいくつかの領域に分類され、それぞれを別の領域として扱って動作します。例えば下記のような領域が存在します。

  • スタックメモリ(自動変数の配置先など)
  • 静的メモリ(静的変数の配置先)
  • ヒープメモリ(malloc 関数で確保される)

ポイントになるのは、それぞれのメモリのサイズです。ちょっと静的メモリとヒープメモリのサイズの関係までは把握できていないのですが、基本的には上記の中でスタックメモリがダントツに小さいです。

私の環境ではスタックメモリのサイズは 8 MB です。Mac だとこの値がデフォルトなのかもしれません。

MEMO

Mac や Linux を使用しているのであれば、スタックメモリのサイズはターミナルから下記の ulimit コマンドを実行することで調べることが可能です

$ ulimit -s

表示される値の単位は KB です

私の環境で上記コマンドを実行すれば 8192 と表示されるので、スタックメモリのサイズは 8192 KB、つまり 8 MB ということになります

すみませんが、Windows での調べ方は分かりませんでした…

さらに、ヒントでも説明したように、(OS の仕様にもよりますが)基本的には自動変数はスタックメモリに配置されます。さらに、data を配置するためには、double 型のサイズ * (5 * 1024 * 1024) なので、40 MB は必要ということになります(double 型のサイズを 8 バイトとして計算)。

つまり、スタックメモリのサイズ < data を配置するのに必要なサイズ となるので、data を変数宣言すると、スタックメモリを溢れてしまい、即座にプログラムが異常終了することになります。

では、なぜ static 指定をすればプログラムが正常に動作するようになるかというと、これは static 指定して宣言した変数(静的変数)は、基本的に静的メモリに配置されるからです(これも OS の仕様によっては異なるかもですが…)。

さらに、静的メモリのサイズはスタックメモリよりも大きいので、自動変数ではなく静的変数として変数宣言するようにすれば、大きなサイズの配列等も宣言できるようになることが多いです。

ちなみに、グローバス変数も静的メモリに配置されますので、グローバル変数として data を宣言しても正常にプログラムを動作させることが可能です。

大きなサイズの配列を変数宣言するだけで発生する問題なので、この現象を経験したことがある人は多いのではないかと思います。

大きなサイズの配列を変数宣言している&プログラムが異常終了するような場合は、静的変数としてその配列を宣言するようにすることで解決することができる可能性があることは覚えておくと良いと思います(もちろん静的メモリの上限を超えるような大きなサイズの変数は宣言できません…)。

static については下記ページでも詳しく説明していますので、詳細を知りたい方は是非読んでみてください!

staticローカル変数の解説ページアイキャッチ【C言語】staticローカル変数の使い方・メリットを解説

型と演算

計算結果がおかしい…

下記の計算プログラムを作成しました。

#include <stdio.h>
  
int main(void){

  int x = 20000;
  int y = 214755;
  long long ans;

  ans = x * y;

  printf("ans = %lld\n", ans);

  return 0;
}

計算結果の出力は下記のようになりました。

ans = 132704

ありえないくらいに結果が小さい値になってしまいます。どう考えてもおかしい…。この計算で結果がおかしくなる原因はなんでしょう?どのようにソースコードを変更すればうまく計算できるようになるでしょうか?

条件

変更箇所は計算部分のみにしてください

問題の狙い

型とキャストについて理解していただくことが狙いです。

ヒント

int 型変数 * int 型変数の計算結果は int 型になります。

解答例

計算部分を下記のように変更すれば正しく計算できるようになります。

ans = (long long)x * (long long)y;

計算がうまくいかなかった理由は桁あふれです。

int 型変数 * int 型変数の計算結果は int 型になります。int 型の最大値は 2147483647 ですので、この値を超えた場合は桁あふれが発生して 2147483648 引いた数が計算結果となってしまいます。

解決方法は計算結果が int 型よりも大きい値が扱える型である long long 型になるように、計算に使用する変数をlong long 型にキャストすることです。long long 型の変数 * long log 型の変数の計算結果は long long 型ですので、int 型の最大値を超えたとしても計算結果を正しく出力することが可能です。

C言語だけでなくプログラミングにおいてキャストは非常に重要です。下のページでキャストについて解説していますので、この問題が分からなかった型やもっとキャストについて知りたい方は是非読んでみてください。

キャスト解説ページのアイキャッチC言語のキャストについて解説!「符号あり」と「符号なし」の比較・計算は特に危険!

スポンサーリンク

判定がうまくできない…

配列に格納されている値が正の値か負の値かを出力するプログラムを作成しました。

#include <stdio.h>
  
int main(void){
  int i;
  int x[5] = {-5, 2, -3, -4, 1};
  unsigned int threshold = 0;

  for(i = 0; i < 5; i++){
    if(x[i] >= threshold)
    {
      printf("%d は正の値です\n", x[i]);
    } else {
      printf("%d は負の値です\n", x[i]);
    }
 }

  return 0;
}

計算結果の出力は下記のようになりました。

-5 は正の値です
2 は正の値です
-3 は正の値です
-4 は正の値です
1 は正の値です

負の値まで正の値と判定されているようです。この理由はなんでしょう?うまく負の値を判定するためにはどのようにすれば変更すれば良いでしょうか?

条件

変更は条件分岐の判定文のみにしてください

問題の狙い

こちらも型とキャストについて理解していただくことが狙いです。

ヒント

int 型変数と unsigned int 変数の比較は、両方 unsigned int 型にキャストされた後に比較されます。

解答例

判定部分を下記のように変更すれば正しく計算できるようになります。

if(x[i] >= (int)threshold)

判定がうまくいかなかった理由は暗黙の型変換が発生したためです。

int 型変数と unsigned int 型変数で比較を行った場合、int 型変数は unsigned int 型に変換された後に比較が行われます。つまり int 型変数は全て正の値に変換されるのです。ですので問題文の条件分岐の判定は必ず成立するため、必ず正の値として判断されてしまいます。

これを防ぐためには unsigned int 型の変数に対して明示的な型変換(キャスト)を行えば良いです。int 型同士の比較であれば暗黙の型変換が行われないため、意図した通りの比較を行うことが可能です。

先ほどの問題同様にこちらもキャストが大きく関わる問題です。プログラミングにおいてキャストは非常に重要な概念になります。キャストに関しては下のページで解説していますのでしっかり理解しておきましょう!

キャスト解説ページのアイキャッチC言語のキャストについて解説!「符号あり」と「符号なし」の比較・計算は特に危険!

切り捨て切り上げ四捨五入

入力された2つの整数 x , y の x / y の計算結果を小数点以下で切り捨て・切り上げ・四捨五入するプログラムを作成してください

条件

切り捨て切り上げ四捨五入する関数は存在します。ですが、今回はその関数は使用せずに自力で計算してください。

問題の狙い

ここも実はキャストが深く関わってきます。キャストを利用した計算を身につけましょう。

ヒント

double型の変数をint型に変換すると小数点以下は切り捨てされます。

解答例

下記により1つ目の式で切り捨て、2つ目の式で切り上げ、3つ目の式で切り上げが行われます。

#include <stdio.h>

int main(void){
    int x; /* 割られる数 */
    int y; /* 割る数 */
    int ans1; /* 切り捨て結果 */
    int ans2; /* 切り上げ結果 */
    int ans3; /* 四捨五入結果 */
    printf("割られる数 x は?");
    scanf("%d", &x);

    printf("割る数 y は?");
    scanf("%d", &y);

    /* 切り捨て */
    ans1 = x / y;
    /* 切り上げ */
    ans2 = (x + y - 1) / y;
    /* 四捨五入 */
    ans3 = (double)x / (double)y + 0.5;

    printf("x / y の計算結果\n");
    printf("切り捨て: %d\n", ans1);
    printf("切り上げ: %d\n", ans2);
    printf("四捨五入: %d\n", ans3);

    return 0;

}

なぜこれで切り捨て切り上げ四捨五入ができるかは下のページで解説していますので、問題が分からなかった方は是非読んでみてください。

切り捨て四捨五入切り上げの解説ページのアイキャッチC言語で割り算結果を「切り捨て」「四捨五入」「切り上げ」する方法

int型の最大値

char 型で扱える値の最大値は 127 です。これは覚えている人も多いでしょう。では int 型の最大値はいくつでしょう?計算するプログラムを作成してください。

条件

OSや開発環境によっては int 型の最大値は「limits.h」のファイルで「INT_MAX」として定義されています。が、今回はこれを使わずに自分で計算してみましょう。

問題の狙い

型のサイズ、サイズの取得の仕方、ビットシフト演算について理解していただくことが狙いです。

ヒント

符号付きの整数型では最上位ビットが符号部になっており、そのほかが値を表現するビットになっています。

整数型のビットの関係図

そして、符号部以外が全て 1 の場合に正の数の最大値となります。

最大値の時のビットの状態

また、型のサイズは下記のように型名を引数として sizeof 関数を実行することで取得することが可能です。ただし取得されるサイズの単位はバイトです。

size = sizeof(int);

解答例

下記のプログラムで計算することが可能です。

#include <stdio.h>

int main(void){
  int i;
  int bit;
  int intmax;
  int intbyte;

  intbyte = sizeof(int);
  bit = intbyte * 8  - 1;
  
  intmax = (1 << bit) - 1;

  printf("%d\n", intmax);

  return 0;
}

1 を符号部以外のビット数分だけ左にシフトすれば最上位ビットのみが 1 のデータが作成できます。

最上位ビットのみを1にする方法

これから 1 を引いてやれば、符号部以外のビットが全て 1 となり、これがその型の最大値となります。

符号付整数型の最大値のビットの状態

スポンサーリンク

二進数を計算してみよう

入力された数字を2進数に変換するプログラムを作成してください

条件

割り算の使用は禁止とします。「&」演算子を使って求めてみてください。

問題の狙い

繰り返し文、二進数、ビット演算子について理解する

ヒント

ある10進数Xのnビット目が1かどうかは下記で判断可能です。

X & (2 ^ n) > 0

解答例

解説が長くなるので別ページを用意しました。

「二進数を計算してみよう」の解答例「二進数を計算してみよう」の解答例

配列

二次元配列を回転して表示させよう

用意した二次元配列を時計回りに90度、180度、270度それぞれの角度で回転させた結果を出力してください

条件

行列の行数・列数はマクロを使って定義してください

問題の狙い

二次元配列、繰り返し文について理解する

ヒント

例えば90度回転して表示する場合は配列にアクセスする順序が下の矢印の順になります。

90度回転時の配列へのアクセス順序

解答例

解説が長くなるので別ページを用意しました。

2次元配列を回転して表示する方法の解説ページアイキャッチ【C言語】2次元配列を回転して表示させよう

スポンサーリンク

二次元配列を一次元配列で表すと?

下記のような行数3、列数6の二次元データに対し、指定された座標(x, y)の値を取得して表示するプログラムを作成することを考えます。

2次元データの例

二次元データを二次元配列に格納した場合は、プログラムのソースコードは下記のようになります。

#include <stdio.h>
  
int main(void){
  char array[3][6] =
  {
    {'A', 'B', 'C', 'D', 'E', 'F'},
    {'G', 'H', 'I', 'J', 'K', 'L'},
    {'M', 'N', 'O', 'P', 'Q', 'R'}
  };
  int x, y;

  printf("x = ");
  scanf("%d", &x);
  printf("y = ");
  scanf("%d", &y);

  printf("(x, y) : %c\n", array[y][x]);

  return 0;
}

さてここからが問題です。

二次元データを下記のように一次元配列に格納した場合に、同様に指定された座標(x, y)の値を表示するプログラムを作成してください。

char array[18] =
{
  'A', 'B', 'C', 'D', 'E', 'F',
  'G', 'H', 'I', 'J', 'K', 'L',
  'M', 'N', 'O', 'P', 'Q', 'R'
};

条件

二次元配列は使わないこと

問題の狙い

二次元データと一次元配列の関係を理解する

ヒント

二次元データの列数がYであるとき、二次元座標の(x, y)を一次元座標で考えると(y * M + x)にマッピングできます

解答例

問題文で示した一次元の配列を変数宣言した場合、printf 関数部分を下記のように書き換えれば2次元配列の時と同様の動きをさせることが可能です。

printf("(x, y) : %c\n", array[y * 6 + x]);

このように1次元配列でも2次元データを扱うことが可能です。ですので2次元データを扱うからといって必ず2次元配列を使用しなければいけないということもありません。個人的には2次元配列嫌いなので、このやり方で2次元データを1次元配列で扱うことが多いです。

文字・文字列

文字列の表示結果がおかしい

変数宣言時に格納した文字列を表示するプログラムを作成してみました。

#include <stdio.h>

int main(void){
  char str[11] = "daeudaeucom";

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

  return 0;

}

が、結果は下のようになりました。

daeudaeucom?

なんか変な文字が余計についてる…。もう一回実行してみると

daeudaeucomF

また変な文字がついてる…。

この余計な文字がつかないようにするためにはどのように変更すれば良いでしょうか?

条件

条件は特にありません

問題の狙い

文字列の扱いに慣れる事が狙いです。

ヒント

文字列の最後に必ず必要なのは…?

解答例

ソースコードの問題点は下記の部分です。

char str[11] = "daeudaeucom";

基本的にC言語で文字列を扱う関数は、文字列の最後にヌル文字( ‘\0’ )が格納されていることを期待しています。ヌル文字までを有効な文字列と考えて処理します。例えば printf 関数だと、ヌル文字までの文字列を出力する作りになっています。

また文字列を作成したり変更するような関数(例えば strcpy や sprintf などなど)は文字列の最後にヌル文字を格納するようになっています。

これは上記の文字列を格納する変数の初期化においても同じで、文字列の最後にヌル文字が格納されます。

ですが、上記のコードだと11文字分の配列に対して11文字を格納する初期化を行なっているため、ヌル文字を格納する事ができません。

ヌル文字がないと、文字列を扱う関数(上記のソースコードの場合は printf 関数)が文字列の終端が分からないため、余分に文字列を出力してしまう可能性があります。

ですので、文字列を格納する配列を作成する場合は、配列の大きさを「文字列の長さ+1文字分」の大きさにしてやる必要があります。

下記のように変数宣言を書き換えれば、必ず初期化した文字列分のみが出力されるようになります。

char str[12] = "daeudaeucom";

スポンサーリンク

日本語って難しい!マルチバイト文字をC言語で扱ってみよう

入力された日本語文字列から「特定の日本語の文字」が含まれているかを判断するプログラムを作成してください

条件

特になし

問題の狙い

文字列の扱い、配列の扱い、繰り返し文、条件分岐について理解する

ヒント

日本語はマルチバイト文字です

char型に格納できるのは1バイト分のデータのみです

文字列の長さはstrlen()により取得可能です

ただしstrlen()は1文字1バイトであることを前提に文字列の長さを計算します

解答例

「日本語って難しい!マルチバイト文字をC言語で扱ってみよう」の解答例「日本語って難しい!マルチバイト文字をC言語で扱ってみよう」の解答例

ポインタ

安全な free 関数

この問題の場合はちょっと長い前置きがあります。

malloc 関数によって動的に確保したメモリは、メモリリークを防ぐために使い終わった後は free 関数で解放することが必要です。

さらに二重 free や解放済みのメモリにアクセスしないためには、free で解放したアドレスを指していたポインタには、free 関数直後に NULL を指させること(NULLを代入すること)が重要です。

この辺りの話は下のページで解説していますので ポインタや free についてさらに詳しく知りたい方は読んでみてください。

NULLの解説ページアイキャッチ【C言語】「NULL」の意味とNULLを用いた「安全なポインタの使い方」

とりあえず言いたいのは free 関数直後に NULL 代入することが重要だと言うことです。

で、ここからが問題になります。

free 関数後に NULL 代入することが大事なのは理解したとして、この NULL 代入することをつい忘れてしまう人も多いと思います。

そこで新たな関数 safe_free を作成することにしました。

safe_free 関数は下記を行う関数です。

  • 指定されたアドレスのメモリを free 関数で解放する
  • 指定されたアドレスを指していたポインタに NULL を指させる

つまり free と NULL 代入を行う関数です。この safe_free 関数を使うことによりメモリ解放後に、そのアドレスを指していたポインタには必ず NULL が代入されます。素晴らしい!

早速下記のようにプログラムを作成し、safe_free 関数実行前後のポインタが指すアドレスを表示してみました。

#include <stdio.h>
#include <stdlib.h>

void safe_free(int *p)
{
  free(p);
  p = NULL;
}

int main(void){
  int *pointer;
  pointer = malloc(sizeof(int) * 5);

  printf("before pointer: %p\n", pointer);

  safe_free(pointer);

  printf("after pointer : %p\n", pointer);

  return 0;

}

実行後のアドレスが 0x0 になっていれば成功なのですが、なぜか safe_free 関数実行前後でアドレスが変わっていません!!

before pointer: 0x7fb058400690
after pointer : 0x7fb058400690

実行前後で表示されるアドレスが変わらない理由はなんでしょうか?作ろうとしていた safe_free 関数を実現するためにはどうすれば良いでしょうか?

条件

戻り値の型は void のままにしてください。

問題の狙い

ポインタ・関数のスコープについて理解していただくことが狙いです。

ヒント 

関数に引数として渡される変数は、関数呼び出し元の変数とは別のものです。ただし変数に格納されている値はコピーされています。違う箱に同じ値が格納されるイメージです。つまりメイン関数の変数 pointer と safe_free 関数の変数 p は別の変数だけど同じアドレスを指す事になります。

変数がコピーされる様子

ですので、 p には free すべきアドレスがコピーされてきており、free 関数での解放処理は意図通りに行われています。

関数内でfreeする様子

しかし safe_free 関数に渡された p に NULL を代入しても、p は単なるコピーの変数ですので、関数呼び出し元の変数である pointer には何の影響もありません。

関数内でNULLセットする様子

注目すべき点は、関数に渡されたアドレスの先は変更することが可能ということです(この場合は渡されたアドレスの先を解放している)。

つまり、pointer に格納されている値(ポインタの指す先、アドレス)を変更するためには、pointer 変数が存在するアドレスを safe_free 関数に引数として渡せば良いということです。

変数が存在するアドレスは 「&演算子」を用いれば取得できますね!

pointer はポインタですので、pointer の存在するアドレスを引数として指定するためには…?

解答

下記のソースコードであれば前述した safe_free 関数を実現することが可能です。

#include <stdio.h>
#include <stdlib.h>

void safe_free(int **p)
{
  free(*p);
  *p = NULL;
}
int main(void){
  int *pointer;
  pointer = malloc(sizeof(int) * 5);

  printf("before pointer: %p\n", pointer);

  safe_free(&pointer);

  printf("after pointer : %p\n", pointer);

  return 0;

}

結果も safe_free 関数実行直後に変数 pointer の指すアドレスが 0x0 になっていることが確認できます。

before pointer: 0x7f8266c00690
after pointer : 0x0

ポイントはポインタ変数である pointer が存在するアドレスを safe_free 関数に指定して関数を実行することです。これにより pointer が存在するアドレス(仮にA番地としましょう)を safe_freeに渡すことができます。

ポインタのアドレスを引数として渡す様子

ポインタ変数のアドレスが渡されてくるので、safe_free の引数 p の型はダブルポインタ(ポインタのポインタ)にする必要があります。

void safe_free(int **p)

p はダブルポインタですので、ポインタ変数が存在するアドレスを格納することが可能です(まさにポインタのポインタ)。

このときの p と pointer の関係をまとめておきます。

p:pointer 変数を指している

pointer:malloc で確保した領域を指している

p*:pointer が指す先と同じところを指している

ダブルポインタがmain関数の変数を参照する様子

したがって *p を free 関数で解放してやれば、pointer の指すアドレスの領域を解放することができます。

ダブルポインタを使ったfree

さらに *p に NULL を代入すれば、*p の指すアドレス(A番地)にある変数 pointer に NULL が代入される事になります。

関数側から呼び出し元のポインタにNULLセットする様子

これにより、

  • 指定されたアドレスのメモリを free 関数で解放する
  • 指定されたアドレスを指していたポインタに NULL を指させる

を実現することが可能です。

注目して欲しいのはダブルポインタを使えば関数呼び出し元のポインタに格納される値を関数側で変更できる点ですね。関数内で呼び出し元のポインタを変更したい場合は、ダブルポインタを使うと便利です。

説明がよく分からなかった方は、下記でダブルポインタについて解説していますのでこちらも参考にしてみてください。

ポインタのポインタ解説ページアイキャッチ【C言語】ポインタのポインタ(ダブルポインタ)を解説【図解】

スポンサーリンク

リストの解放1

下記はリストを作成するプログラムになります。

#include <stdio.h>
#include <stdlib.h>

struct list {
  int number;
  struct list *next;
};

int main(void){
  struct list *head = NULL;
  struct list *add = NULL;
  struct list *node = NULL;
  int i;

  for(i = 0; i < 10; i++){
    add = (struct list*)malloc(sizeof(struct list));
    add->number = i;
    add->next = NULL;
    if(head == NULL){
      head = add;
    } else {
      node->next = add;
    }
    node = add;
  }

  node = head;
  while(node != NULL){
    printf("number:%d\n", node->number);
    node = node->next;
  }

  /* リスト削除処理 */

  return 0;
}

これにより下の図のように list 構造体がリストのように繋がります。

リストが繋がる様子

ポインタ head がリストの先頭を指し、list 構造体の next メンバが次のリスト構造体を指すことで各データが繋がっています。

ではここからが問題です。上のソースコードではリストの削除(malloc でメモリ確保したものの解放)が行われていません。リストを削除するソースコードを追加してください。

条件

先頭のデータから順に削除してください。また削除されたことが確認できるように、free関数で解放する前に、解放しようとしているデータの number メンバの値を表示するようにしてください。

問題の狙い

ポインタの指す先のイメージを掴む。

ヒント

head はリストの先頭のデータを指しています。head に次のデータを指すためには下記を実行すれば良いです。

head = head->next;

なので、これを利用すればリストを先頭から最後まで辿っていくことができます。

while(head != NULL){
  head = head->next;
}

ここまで聞くとリストを先頭から辿りながらデータを解放していくコードで最初に思い浮かぶのは下記ではないでしょうか。

while(head != NULL){
  free(head);
  head = head->next;
}

しかし、これをやると先頭のデータは解放できますが、他のデータが解放できません。というか4行目でNULLアクセスをして落ちる可能性も高いです。

結論的には、この問題の場合、データを指すポインタは head の1つだけではリストのデータ全てを解放することは無理です。もう一つポインタを用いて上手く解放させる方法を考えてみましょう。

解答例

リストを削除する部分のソースコードは下のようになります。

while(head != NULL){
  /* nodeに削除するデータを指させる */
  node = head;
  /* リストの先頭を次のデータに移動 */
  head = head->next;

  printf("%dを削除\n", node->number);

  /* データを削除 */
  free(node);
}

ポイントは下記の手順を踏んでデータの解放を行うところです。

  • node にリストの先頭(head)を指させる
  • head に次のデータを指させる(先頭の更新)
  • node の指すアドレスを解放する

リストデータの削除手順

node に削除するデータのアドレスを退避するところがポイントです。これにより head に次のデータを指させても、node が削除するデータを指していますので、削除すべきデータのアドレスを見失わずに解放することが可能になります。

この問題ではリストの削除のみに関するものになりますが、リストの全体像やプログラムについては下記のページで解説しています。詳しく知りたい方は是非読んでみてください。

リスト構造の解説ページアイキャッチ【C言語】リスト構造について分かりやすく解説【図解】

再帰呼び出し

ループを再帰呼び出しで置き換え

1から10の整数の和を求めるプログラムを作成してください。

条件

ループは使わず再帰呼び出しを用いて作成してください。

問題の狙い

再帰呼び出しの基本を理解することです。

ヒント

1から10の整数の和は下記のように捉えることができます。

1から9の整数の和 + 10

1から9の整数の和は下記のように捉えることができます。

1から8の整数の和 + 9

1から8の整数の和は・・・。

解答例

解答例は下記のようになります。

#include <stdio.h>

int calc_sum(int);

int calc_sum(int n){

  if(n == 1){
    return 1;
  }

  return calc_sum(n - 1) + n;
}

int main(void){
  int sum;
  sum = calc_sum(10);

  printf("sum = %d\n", sum);
  return 0;
}

calc_sum 関数は、1 から n までの整数の和を「1 から n – 1 までの整数の和 + n」の計算により求める関数です。1 から n – 1 までの整数の和は calc_sum 関数により求めますので再帰呼び出しになっています。

引数に 10 を指定して calc_sum 関数が実行されると、次に引数 9 を指定して calc_sum 関数が実行されます。さらにその中で引数 8 を指定して calc_sum 関数が実行され、これが引数 1 になるまで繰り返し行われることになります。ここまで計算処理は行われません。

引数 1 で実行した場合は、1 から 1 までの整数の和を求めるので、単純に 1 をリターンします。そうすると、引数 2 で実行した calc_sum 関数に戻り、そこでそれに対して 2 を足した結果をリターンし(つまり 1 から 2 までの整数の和)、引数 3 で実行した calc_sum に戻ります。この calc_num では calc_num(2) の戻り値に 3 を足した結果(つまり 1 から 3 までの整数の和)をリターンします。

これを最後まで繰り返すことにより 1 から 10 までの整数の和を求めることができます。

ポイントは 再帰呼び出しをストップするタイミングですね。この問題であれば引数 n が 1 の時に、それ以上再帰呼び出しをする必要がないということで再帰呼び出しは行わずに関数を終了しています。

スポンサーリンク

再帰呼び出しの折り返し

下記のような表示を行うプログラムを作成してください。

+
++
+++
++++
+++++
+++++
++++
+++
++
+

条件

再帰呼び出しを用いてください。

問題の狙い

再帰呼び出しの基本を理解することです。

ヒント

再帰呼び出しを行う関数は以下の3つ処理に分けられます。再帰再帰呼び出しなので、呼び出す関数は自分自身の関数ですね。

  • 関数呼び出し前の処理
  • 関数呼び出し前
  • 関数呼び出し前後の処理

それぞれが処理される順序を図示すると下のようになります。

再帰処理の実行順序

ポイントは先に実行されれば「関数呼び出し前の処理」は早く実行されるが「関数呼び出し後の処理」は遅く実行されるところですね。関数呼び出し前の処理は再帰呼び出しの折り返し前に行われますが、関数呼び出し後の処理は再帰呼び出しの折り返し後に実行される感じです。

問題の表示結果は「+」を1つ表示する処理が最初と最後に行われているので….。

解答例

解答例は下記のようになります。

#include <stdio.h>

void print_recursive(int, int, char);

void print_recursive(int n, int max, char moji){
  int i;
  for(i = 0; i < n; i++){
    printf("%c", moji);
  }
  printf("\n");

  if(n < max){
    print_recursive(n + 1, max, moji);
  }

  for(i = 0; i < n; i++){
    printf("%c", moji);
  }
  printf("\n");
}

int main(void){
  print_recursive(1, 5, '+');
  return 0;
}

ポイントは print_recursive 関数の中で、print_recursive 関数呼び出し前と print_recursive 関数呼び出し後両方で同じ出力を行なっている点です。同じ出力を同じ関数の中で行なっていますが、表示が行われるタイミングは再帰呼び出しの折り返し前と折り返し後で異なるため、かなり離れて出力されていることが確認できると思います。

また、再帰呼び出しはどこで再帰呼び出しをストップするか(自身の関数の呼び出しを行わないようにするか)も重要なポイントです。このソースコードの場合は下記で再帰呼び出しを続けるかどうかの判断を行なっています。

if(n < max){
  print_recursive(n + 1, max, moji);
}

リストの解放2

リストの解放の問題をリストの解放1で出題しましたが、その問題ではリストの先頭からデータの解放を行うことを条件としていました。

今回はその逆で、リストの最後尾からデータの解放を行うプログラムを作成してください。

条件

最後尾のデータから順に削除してください。また削除されたことが確認できるように、free 関数で解放する前に、解放しようとしているデータの number メンバの値を表示するようにしてください。

問題の狙い

再帰呼び出しのいろいろな使い方を学びましょう。

ヒント

まずは再帰呼び出しを用いてリストの最後尾まで辿りましょう。

解答例

下記の関数を引数にリストの先頭へのポインタを指定して呼び出すことでリストを最後尾から順に削除することができます。

void deleteList(struct list *node){

  if(node->next != NULL){
    deleteList(node->next);
  }
  printf("%dを削除\n", node->number);
  free(node);
}

deleteList 関数の中で deleteList 関数に node->next を指定して実行することで再帰呼び出しを行なっており、これによりリストの最後尾(node->next == NULL となる node)までたどることができます。

そこで再帰呼び出しがストップするので、そこからは最後尾から順にデータを free して関数終了する処理がリストの先頭まで実行されます。

オススメの問題集(PR)

今回は、私が思い付いたものをてきとうに見繕って問題として紹介しましたが、もっと基礎的な内容をもっと網羅的に、問題形式で解く形でC言語を学習したいという方には、下記の 新・解きながら学ぶC言語 第2版 がオススメです!

C言語の入門書で学ぶことを網羅的に問題形式で出題し、それを解くことでC言語の復習をすることができる書籍になります。

このページで出題したような、プログラム作成問題も184問収録されています(穴埋め問題も含めると全部で1436問!)。さらに、プログラム作成問題に関しては1つ1つの問題に対して解説も充実しています。

問題形式でC言語の復習をしたいという方には最適の本だと思います。どんな感じの本かは上記リンク先から試し読みできると思いますので、興味のある方は是非見てみてください!

オススメの参考書(PR)

C言語学習中だけど分からないことが多くて挫折しそう...という方には、下記の「スッキリわかるC言語入門」がオススメです!

まず学習を進める上で、参考書は2冊持っておくことをオススメします。この理由は下記の2つです。

  • 参考書によって、解説の仕方は異なる
  • 読み手によって、理解しやすい解説の仕方は異なる

ある人の説明聞いても理解できなかったけど、他の人からちょっと違った観点での説明を聞いて「あー、そういうことね!」って簡単に理解できた経験をお持ちの方も多いのではないでしょうか?

それと同じで、1冊の参考書を読んで理解できない事も、他の参考書とは異なる内容の解説を読むことで理解できる可能性があります。

なので、参考書は2冊持っておいた方が学習時に挫折しにくいというのが私の考えです。

特に上記の「スッキリわかるC言語入門」は、他の参考書とは違った切り口での解説が豊富で、他の参考書で理解できなかった内容に対して違った観点での解説を読むことができ、オススメです。題名の通り「なぜそうなるのか?」がスッキリ理解できるような解説内容にもなっており、C言語入門書としてもかなり分かりやすい参考書だと思います。

もちろんネット等でも色んな観点からの解説を読むことが出来ますので、分からない点は別の人・別の参考書の解説を読んで解決していきましょう!もちろん私のサイトも参考にしていただけると嬉しいです!

入門用のオススメ参考書は下記ページでも紹介していますので、こちらも是非参考にしていただければと思います。

https://daeudaeu.com/c_reference_book/

同じカテゴリのページ一覧を表示

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です