見出し画像

C言語教室 解答編 第12回 と オーバーフローのはなし

今回は中間テストということにしたので、設問も多くしました。簡単なものからはじめて、徐々に解決すべき内容が増えていくようにしたつもりです。また、最初の方の課題を後で使うまたは参考にできるような順序にもしました。

C言語教室 第12回(続) - 課題と演習

さて、今回も答案を頂きまして感謝の限りです。答案へのコメントは解答例の後に書きます。

ということで、まだテストを受けていない方は、この後は読まずに挑戦していただければと思います。答案を書いたら、こっそり!?コメントなどでお知らせくださいね。

恒例の「うっかり」解答を見ないための閑話です。


AyumiKatayama さんの答案にもあったのですが、C言語の演算においてはオーバーフローのチェックがありません。厳密に言えばオーバーフロー時の結果は「未定義」であるということになっています。大部分の実装ではオーバーフローした部分は捨てられ、その型に収まる値のビット列だけの値になるようです。その結果、数学的にはありえないプラス✕プラスがマイナスの値になったりすることがあるわけです。

整数のオーバーフローで忘れがちなケース

オーバーフローをどう処理するかは言語によってかなり違っていて、古のBASICでは必ずオーバーフローのチェックが入っていました(最近は内部処理がC#と同じ.NETなので外すことができます)。UCSD-PASCALではプラグマでチェックをするかどうかを決められたような記憶があります。FORTRANも処理系によって異なるような気がします。アセンブラレベルではオーバーフローのチェックはフラグが立つハードウェアが殆なので、それをチェックするコードを入れるだけでよいのですが、それでもチェックのコストがかかるのは確かなので、嫌われるようです。

最近の型が弱い言語では、整数同士であってもオーバーフローすると浮動小数点が返ってくるような仕様も見受けられます(PHPなど)。python はメモリの許す限り、どんどん大きな整数にしてくれます。

Pythonの整数型はどのように実装されているのか

ところが浮動小数点だからといってオーバーフローが無くなるわけではありません。もっとも滅多なことで起こらなくなるのは確かですが。こちらの扱いはより面倒で、どのような値になるのかは実装によります。またアンダーフローというのもあり、割り算でこれが起これば、結果は整数の0除算と同じような話です。ただいずれも何らかの値は返し、エラーになるわけではないようです。

【C言語】算術オーバーフローと回避方法

Inf(Infinity)詳細解説【発生条件、定数定義 - MaryCore

最近話題の Rust では、この問題にも取り組んでいて、コードで明示することでチェックを行う方法が提供されるようです。

Rustでの整数オーバーフローまとめ

C言語においても gcc の独自拡張であったり、最新の仕様では何らかの対処ができる手段が出てきているみたいです。

C言語での整数のオーバーフロー検査


閑話が無くなってしまいましたが、課題に進みます。

課題1
長方形の短辺と長辺の長さを引き数として、長方形の面積を求める関数を書きなさい。なお長さは整数でよい。

#include <stdio.h>

int area_of_rectangle(int length, int width) {
  return length * width;
}

void main() {
  int x = 5;
  int y = 9;
  int area;

  area = area_of_rectangle(x, y);
  printf("%d*%d=%d\n", x, y, area);
}

実行すれば

5*9=45

と表示されるはずです。特にコメントするような点は無さそうです。応用として double 型の引き数を渡したり、結果を double 型で返すコードも書いてみてください。C言語では関数名の多重定義(同じ名前で引き数が異なるような関数)が許されないので、名前に戻り値の型名を含めるのが一般的です(整数であれば int_area_of_rectangle()という名前にする)。


課題2
課題1で書いた関数を引き数付きマクロを使って書き直しなさい。

#include <stdio.h>

#define AREA(x, y) ((x)*(y))

void main() {
  int x = 5;
  int y = 9;
  int area;

  area = AREA(x, y);
  printf("%d*%d=%d\n", x, y, area);
}

結果は課題1とまったく同じです。マクロは文字列であって型が無いので、double にするときも同じマクロが使えます。

#include <stdio.h>

#define AREA(x, y) ((x)*(y))

void main() {
  double x = 5.;
  double y = 9.;
  double area;

  area = AREA(x, y);
  printf("%f*%f=%f\n", x, y, area);
}


5.000000*9.000000=45.000000

課題3
円の半径を引き数として、円の面積を求める関数を書きなさい。なお半径は整数で良いが面積は小数とする。円周率はマクロを使って定義しなさい。

#include <stdio.h>

#define PI (3.14)

double area_of_circle(int radius) {
  return radius * radius * PI;
}

void main() {
  int r = 3;
  double area;

  area = area_of_circle(r);
  printf("radius:%d,area of circle:%f\n", r, area);
}

実行結果は

radius:3,area of circle:28.260000

となります。微妙な話なのですが、面積を求める時に左から処理されるので、整数✕整数を行ってから、整数✕小数の計算が行われるため、オーバーフローの可能性はあります。気になるのであれば、最初に (double)radius とキャスト(型変換)するか、PI を最初に持ってくるとすべて double で計算してくれます。


課題4
整数の配列と要素の数を引き数で渡し、配列のそれぞれの要素にゼロを代入する関数を書きなさい。

#include <stdio.h>

#define ARRAY_SIZE (10)

void clear_array(int *p, int size) {
  int i;

  for (i = 0; i < size; i++)
    *p++ = 0;
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;

  clear_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
}

実行してみましょう。

index:0,value=0
index:1,value=0
index:2,value=0
index:3,value=0
index:4,value=0
index:5,value=0
index:6,value=0
index:7,value=0
index:8,value=0
index:9,value=0

素直に書きました。もちろん領域を一気に埋める memset を使うこともできます(コードは割愛します)。


課題5
整数の配列と要素の数を引き数で渡し、配列のそれぞれの要素にランダムな値を代入する関数を書きなさい。ランダムな値を得るには stdlib.h で定義されている rand() を使うこと。

まずは素直に書いてみます。

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

#define ARRAY_SIZE (10)

void random_array(int *p, int size) {
  int i;

  for (i = 0; i < size; i++)
    *p++ = rand();
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;

  random_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
}

ブラウザ環境 では、何度も実行すると毎回異なる値の結果が得られるようです。

index:0,value=1570585491
index:1,value=7967052
index:2,value=1755579204
index:3,value=1526056698
index:4,value=2097724634
index:5,value=1299356952
index:6,value=1042335466
index:7,value=280517786
index:8,value=1994007630
index:9,value=1697943069

ところが gcc で実行すると、これとは異なる値かもしれませんが、何度も実行しても全く同じ結果になります。殆どの C言語環境では、このようになると思います。

index:0,value=41
index:1,value=18467
index:2,value=6334
index:3,value=26500
index:4,value=19169
index:5,value=15724
index:6,value=11478
index:7,value=29358
index:8,value=26962
index:9,value=24464

これは乱数系列を求めるのに、その初期値となる種(seed)を指定していないためです。そこで seed を指定してみましょう。

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

#define ARRAY_SIZE (10)
#define RANDOM_SEEED (97)

void random_array(int *p, int size) {
  int i;

  srand(RANDOM_SEED);

  for (i = 0; i < size; i++)
    *p++ = rand();
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;

  random_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
}

これで RANDOM_SEED で指定した値を変えれば、異なる結果が得られるようになりますが、seed を変えなければ、やはり毎回、同じ結果になります。

そこで実行するたびに異なる結果が得られるようにするには、現在の時刻を使って seed を作るという手法をとります。なお、ブラウザ環境では時刻に関する処理が動作しないようなので、ここから先は gcc で動かしています。

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

#define ARRAY_SIZE (10)

unsigned int random_seed() {
  return (unsigned int)time(NULL);
}

void random_array(int *p, int size) {
  int i;

  srand(random_seed());

  for (i = 0; i < size; i++)
    *p++ = rand();
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;

  random_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
}

まず time.h を include して、time 関数を呼び出します。この関数は time_t という型のPOSIX時間またはUNIX時間と呼ばれる1970年1月1日午前0時0分0秒からの秒で表された経過時間を返します。time_t はシステムにより4バイトだったり8バイトだったりして、int に収まるのか long でなければいけないかは、使っているOSやコンパイラに依存するのですが、結局は整数です(2038年までは細かいことは忘れても構いません)。これで同じ秒の間に実行しなければ、異なる seed を得ることができます。

さて、次の課題でも使いやすいように、ひと工夫しておきましょう。

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

#define ARRAY_SIZE (100)

#define RANDOM_SEEED (97)
#define MIN_VALUE (-5)
#define MAX_VALUE (5)

void random_array(int *p, int size) {
  int i;

  srand(RANDOM_SEEED);

  for (i = 0; i < size; i++)
    *p++ = rand() % (MAX_VALUE - MIN_VALUE + 1) + MIN_VALUE;
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;

  random_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
  
}

乱数として生成する値の範囲を MIN_VALUE から MAX_VALUE に絞れるようにしました。この例では -5 から 5 までの 11種類の値がランダムに代入されるようになります。なお、rand 関数は 0~RAND_MAXの範囲の値を返します(負の値は返りません)。最大値がいくつなのか気になったら、RAND_MAXを printf で出してみましょう。


課題6
課題5で作った配列を引き数で渡して、この配列に含まれる値の最小値を返す関数を書きなさい。

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

#define ARRAY_SIZE (10U)

#define MIN_VALUE (-100)
#define MAX_VALUE (100)

void random_array(int *p, int size) {
  int i;

  for (i = 0; i < size; i++)
    *p++ = rand() % (MAX_VALUE - MIN_VALUE + 1) + MIN_VALUE;
}

int min_array(int *p, unsigned int size) {
  int min = *p;
  int i;

  // assume size > 0
  p++;
  for (i = 1; i < size; i++) {
    min = (*p < min) ? *p : min;
    p++;
  }
  return min;
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;
  int min;

  random_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
  
  min = min_array(iarray, ARRAY_SIZE);
  printf("min value=%d\n", min);
}

課題5で作ったコードをもとに、最小値を求める関数を追加しました(環境によって適切な srand 呼び出しは加えてください )。最初の値を仮の最小値として2番め以降の値を比べて最も小さな値を探しています。size が 1 の時はループに入らないので正しく動きますが、0 の時は考慮していません(gcc の場合、大きさ0の配列はエラーにならないのですが)。

index:0,value=-95
index:1,value=76
index:2,value=21
index:3,value=24
index:4,value=38
index:5,value=-10
index:6,value=17
index:7,value=-1
index:8,value=69
index:9,value=28
min value=-95

課題7
課題6で作った関数を参考にして、配列に含まれる値の最小値と、最小値であった要素がいくつあったのかを返す関数を書きなさい。

さて、今度は最小値の要素がいくつあるのかも数えます。

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

#define ARRAY_SIZE (100)

#define MIN_VALUE (-10)
#define MAX_VALUE (10)

void random_array(int *p, int size) {
  int i;

  for (i = 0; i < size; i++)
    *p++ = rand() % (MAX_VALUE - MIN_VALUE + 1) + MIN_VALUE;
}

int min_array(int *p, unsigned int size, int *c) {
  int min = *p;
  int i;

  // assume size > 0, pointer != NULL
  *c = 1;
  p++;
  for (i = 1; i < size; i++) {
    if (*p == min)
      (*c)++;
    else {
      if (*p < min) {
        min = *p;
        *c = 1; 
      }
    }
    p++;
  }
  return min;
}

void main() {
  int iarray[ARRAY_SIZE];
  int i;
  int min;
  int count = 0;

  random_array(iarray, ARRAY_SIZE);
  
  for (i = 0; i < ARRAY_SIZE; i++)
    printf("index:%d,value=%d\n", i, iarray[i]);
  
  min = min_array(iarray, ARRAY_SIZE, &count);
  printf("min value=%d(%d)\n", min, count);
}

乱数を生成しているので、結果は実行の都度、変わるはずですが、例えば以下のように表示されます(途中は省略)。

index:0,value=9
index:1,value=4
<中略>
index:98,value=0
index:99,value=-2
min value=-10(5)

要素の値が最小値と同じであれば、個数を増やし、最小値が更新されれば個数をリセット(1にする)します。書いていて危なかったのが、最小値の個数を増やす行で *c++ ではアドレスが増えてしまうので、(*c)++ と書かなければいけません。演算子の優先順位に注意が必要なところです。

さて、かなり長くなってきたので、今回はここまでにして、残りの解答例は次回にします。ということで頂いた解答も、ここまでの範囲でコメントさせていただき、残った部分は次回までお待ち下さい。


最初は Akio van der Meer さんの答案からです。

(答案提出) C言語教室 第12回(続) - 課題と演習 - 2023.01.29更新

課題1、課題2は大丈夫ですね。結果をいったん変数に代入しておかないと、printf の引き数にはどんな型でも入ってしまうので、稀にびっくりする結果が出ることがあります。課題3に書いてある「自乗」ですが、C言語には BASIC でいうところの ^ 演算子はありません。べき乗がしたければ pow関数(math.hが必要)を使います。なお pow関数はべき乗(2番目の引き数)の型が double なんですよね。

【C言語入門】pow関数でべき乗計算(累乗、二乗、ルート、平方根)

ええと課題4ですが、0 の代わりに NULL を使うのは好ましくありません。確かに NULL は 0 なんですが、x の型は intへのポインタで、*x の型は int です。ポインタ型以外に NULL はダメです。

課題5も NULL の話は同じです。乱数を0からRANGEに絞るのは良いアイデアですが、せっかく符号あり整数なので、負の値も代入できるとベターです(次の課題で役に立ちます)。

課題6では、最小値を求める時に急にポインタではなく配列として処理していますが、結果は何も変わらないので良しとします。ポインタで書くとインクリメントをどこでやるかで間違うかもしれません(ブラウザ環境では ","カンマ演算子が使えませんし)。

課題7では、いったん値ごとの数をすべて数えてから、最小値となった値の数を出していますが、これは課題8の準備ということだと理解します。数自身は値ごとに数えなくても最小値に対してのみ数えれば間に合いはします。

いやいや、お疲れさまでした。課題8以降はもう少しお待ち下さい。


次は AyumiKatayama さんです。以下に答案をいただきました。

C言語教室 第12回(続) - 回答提出

はい。const は忘れることにしています。引き数のチェックもサボっています。またオーバーフローの件は、先に閑話で触れさせていただきました。最近のコンパイラは、コンパイル時に結果がわかる時は最適化してしまうこともあり、オーバーフローがわかる場合もあるようです。

課題3ですが、floatの有効桁数は約7桁なので、doubleにしておいた方が電卓の結果に近くなると思います。円周率を定義する数値リテラルなんですが、小数点があるとdoubleであると解釈され、floatにしたいときだけ 3.14F と F を付けるんですよね。ややこしい(なお long double にしたい時は 小数点+L)。リテラルに対する型の話はどこかでやらないとなぁ。

課題5は、seedを得るのに clock まで使われるあたりコダワリを感じます。max に +1 が必要ですよね。ウンウン。まあ今回は seed は本質的ではないので、その辺りはまたの機会に追求しましょう。

課題7は、素直に else を使えば contine しなくても大丈夫だと思います。このあたりのロジックの組み方って、後から行を追加した時にどう影響するかだとは思うのですよね。個人的には break は乱用しますが、continue は避ける傾向があります。いずれにせよ結果に影響はありません。

ということで課題8以降は次回ということで、よろしくお願いします。


いやぁ、長くなってしまいました。次の教室もはやくやらないと。

ヘッダ画像は、いらすとや さんより、
https://www.irasutoya.com/2020/04/blog-post_243.html




この記事が気に入ったらサポートをしてみませんか?