はじめに
本テキストは大学4年生を対象とした、C言語プログラミング学習教材です。構文の解説に加え、コンピュータサイエンスの根本原理とその実装方法について深い理解を目指します。
概念は理論的背景からしっかり説明し、実践的なコーディング例を通じて概念の定着を図ります。
第1回演習問題の解答
演習問題1: C言語プログラムのコンパイルプロセスの各段階について詳しく説明
解答:
- プリプロセッシング:
- ソースコードの前処理段階
#include
ディレクティブによりヘッダファイルが挿入される- マクロ展開:
#define
で定義されたマクロを置換 - 条件付きコンパイル:
#ifdef
,#ifndef
,#endif
などの処理 - 行番号の追跡: コンパイルエラーメッセージ用に元のソースコード行を管理
- 出力: 拡張されたソースコード(
.i
ファイル)
- コンパイル:
- 前処理済みのコードを解析して中間表現に変換
- 構文チェック: コードが言語の構文規則に従っているかを検証
- 意味解析: 型チェック、変数宣言の確認など
- 最適化: コードパフォーマンス向上のための変換(最適化レベルによる)
- 出力: アセンブリコード(
.s
ファイル)
- アセンブル:
- アセンブリコードを機械語(オブジェクトコード)に変換
- 命令とデータをバイナリ形式にエンコード
- 出力: オブジェクトファイル(
.o
や.obj
ファイル)
- リンク:
- 複数のオブジェクトファイルを一つの実行ファイルに結合
- 外部ライブラリとの結合: 標準ライブラリなど
- アドレス解決: 関数や変数の参照先を確定
- シンボル解決: 未定義シンボルの解決
- 出力: 実行可能ファイル(
.exe
や拡張子なしのバイナリ)
演習問題2: 整数型と浮動小数点型の違いとそれぞれの使用シナリオ
解答: 違い:
- 表現方法: 整数型は離散的な値を表現し、浮動小数点型は実数を近似的に表現
- 精度と範囲: 整数型は値を正確に表現するが範囲が限られ、浮動小数点型は広い範囲の値を表現できるが精度に制限がある
- 内部表現: 整数型は二進数表現、浮動小数点型はIEEE 754などの規格に基づく仮数部と指数部による表現
- 計算精度: 整数型は精密な算術演算が可能、浮動小数点型は丸め誤差が発生することがある
使用シナリオ:
- 整数型:
- カウンタや配列のインデックス
- メモリアドレス計算(ポインタ演算)
- ビット操作が必要な場合(フラグ、マスク)
- 正確な値が必要な計算(金融取引の整数表現など)
- 列挙型や論理値の表現
- 浮動小数点型:
- 科学計算や工学計算(物理シミュレーションなど)
- グラフィック処理(3D座標、変換行列)
- 非整数値が必要な計算(平均値、割合など)
- 指数関数的な値の範囲を扱う場合
- 高精度が必要ない近似計算
演習問題3: 要素が見つからなかった場合に挿入すべき位置を返す二分探索アルゴリズム
解答
#include <stdio.h>
// 拡張二分探索: 要素が見つからない場合は挿入位置を返す
int binary_search_insert_pos(int arr[], int left, int right, int x) {
int original_left = left;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == x)
return mid; // 要素が見つかった場合、そのインデックスを返す
if (arr[mid] < x)
left = mid + 1;
else
right = mid - 1;
}
// 要素が見つからない場合、挿入すべき位置を返す
// left は最初に x より大きくなる要素の位置
return left;
}
int main() {
int arr[] = {2, 3, 4, 10, 40, 50, 70, 90};
int n = sizeof(arr) / sizeof(arr[0]);
int x = 30; // 探す値
int result = binary_search_insert_pos(arr, 0, n-1, x);
// 要素が存在するかどうかを確認
if (result < n && arr[result] == x)
printf("要素は配列内のインデックス %d に存在します\n", result);
else
printf("要素は配列内に存在しません。挿入すべき位置はインデックス %d です\n", result);
return 0;
}
演習問題4: ビット演算子を使用して整数の特定のビットを設定・クリア・反転するプログラム
解答
#include <stdio.h>
// ビットを1に設定する関数
unsigned int setBit(unsigned int num, int position) {
return num | (1u << position);
}
// ビットを0にクリアする関数
unsigned int clearBit(unsigned int num, int position) {
return num & ~(1u << position);
}
// ビットを反転する関数
unsigned int toggleBit(unsigned int num, int position) {
return num ^ (1u << position);
}
// 特定のビットを取得する関数
int getBit(unsigned int num, int position) {
return (num >> position) & 1u;
}
// 数値のビット表現を表示する関数
void printBits(unsigned int num) {
unsigned int mask = 1u << 31; // 32ビット整数の最上位ビット
for (int i = 0; i < 32; i++) {
printf("%d", (num & mask) ? 1 : 0);
mask >>= 1;
// 見やすさのため、4ビットごとに空白を入れる
if ((i + 1) % 4 == 0) printf(" ");
}
printf("\n");
}
int main() {
unsigned int num = 42; // 00000000 00000000 00000000 00101010
int position = 3; // 0-indexed、右から4番目のビット
printf("元の数値 (%u): ", num);
printBits(num);
// ビットを設定
unsigned int after_set = setBit(num, position);
printf("ビット%dを設定後 (%u): ", position, after_set);
printBits(after_set);
// ビットをクリア
unsigned int after_clear = clearBit(num, position);
printf("ビット%dをクリア後 (%u): ", position, after_clear);
printBits(after_clear);
// ビットを反転
unsigned int after_toggle = toggleBit(num, position);
printf("ビット%dを反転後 (%u): ", position, after_toggle);
printBits(after_toggle);
// ビットを取得
int bit_value = getBit(num, position);
printf("ビット%dの値: %d\n", position, bit_value);
return 0;
}
演習問題5: C言語の型変換と潜在的な問題点
解答 C言語の型変換には暗黙的(自動)変換と明示的(キャスト)変換があります。
暗黙的型変換
- 整数拡張: 小さい整数型から大きい整数型への変換(例:
char
→int
) - 整数昇格: 演算前に
char
やshort
がint
に変換される - 算術変換: 二項演算子の被演算子を共通の型に変換(例:
int
+float
→float
+float
)
明示的型変換
float f = 3.14;
int i = (int)f; // float から int への明示的キャスト
潜在的な問題点:
1. 精度の損失:
double d = 123456789.123456789;
float f = d; // 精度が失われる
printf("%.9f\n", f); // 123456792.000000000 と表示される可能性
2. 符号なし整数へのキャストによるオーバーフロー:
int negative = -1;
unsigned int positive = (unsigned int)negative; // オーバーフロー
// positive は 4294967295 になる (UINT_MAX)
3. 切り捨てによるデータ損失:
float f = 3.99;
int i = (int)f; // i は 3 になる (小数部分は切り捨て)
4. ポインタ型間の危険なキャスト:
int *p_int = malloc(sizeof(int));
char *p_char = (char *)p_int; // 異なる型へのキャスト
// アクセス時にアライメント違反の可能性
5. 符号付き/符号なし整数の混合使用による比較問題:
int signed_value = -1;
unsigned int unsigned_value = 1;
if (signed_value < unsigned_value) {
printf("これは実行されない\n"); // -1 は符号なしに変換されると大きな値になる
}
6. 整数オーバーフローとアンダーフロー:
max = max + 1; // オーバーフロー: 結果は INT_MIN になる
第2回: 関数、配列、ポインタ
関数
C言語においてプログラムは関数の集合として構成されます。関数はコードの再利用性と構造化を促進する基本的な単位です。
関数の構文
戻り値の型 関数名(パラメータリスト) {
// 関数本体
return 戻り値; // 値を返す場合
}
例:
int add(int a, int b) {
return a + b;
}
関数プロトタイプ
関数を使用する前に、コンパイラにその存在と形式を伝えるプロトタイプ宣言が必要な場合があります。
int add(int a, int b); // 関数プロトタイプ
int main() {
int result = add(5, 3); // 関数呼び出し
return 0;
}
int add(int a, int b) { // 実際の関数定義
return a + b;
}
プロトタイプ宣言の利点は以下の通りです。「コンパイル時の型チェックを可能にする」、「関数の実装順序に制約されない」、「モジュール化された設計を促進」などがあります。
関数の引数渡し
C言語では、すべての引数が値渡しで行われます。つまり、関数に渡された値は新しいメモリ領域にコピーされ、関数内での変更は呼び出し元に影響しません。
void modify(int x) {
x = 100; // ローカルコピーの変更
printf("modify内: x = %d\n", x);
}
int main() {
int value = 10;
modify(value);
printf("main内: value = %d\n", value); // 10のまま
return 0;
}
ポインタを使用して参照渡しと同様の効果を得ることができます(後で詳しく説明します)。
再帰関数
関数は自分自身を呼び出すことができ、これを再帰と呼びます。再帰は多くのアルゴリズムで重要な概念です。
int factorial(int n) {
if (n <= 1) {
return 1; // 基底条件
}
return n * factorial(n - 1); // 再帰呼び出し
}
再帰関数設計において重要なポイントは以下の通りです。
- 必ず基底条件(終了条件)を持つこと
- 各再帰呼び出しで問題のサイズが小さくなること
- スタックオーバーフローのリスクを考慮すること
関数ポインタ
C言語では関数のアドレスを格納し、後で呼び出すことができます。これにより高度なコールバックパターンが実現可能です。
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int main() {
// 関数ポインタの宣言
int (*operation)(int, int);
// 関数ポインタに関数のアドレスを代入
operation = add;
printf("加算: %d\n", operation(5, 3)); // 8
operation = subtract;
printf("減算: %d\n", operation(5, 3)); // 2
return 0;
}
関数ポインタは動的にアルゴリズムを選択したり、イベント駆動型プログラミングのコールバックに使用されます。
配列
配列は同じデータ型の要素を連続したメモリブロックに格納する構造です。
配列の宣言と初期化
// 宣言
データ型 配列名[サイズ];
// 例
int numbers[5]; // 5つの整数を格納する配列
char message[100]; // 100文字を格納する配列
初期化:
// 宣言と同時に初期化
int numbers[5] = {1, 2, 3, 4, 5};
char vowels[] = {'a', 'e', 'i', 'o', 'u'}; // サイズは自動的に5になる
配列へのアクセス
配列の要素には、0から始まるインデックスを使ってアクセスします。
int scores
= {75, 80, 95};
printf("1番目の点数: %d\n", scores[0]); // 75
printf("2番目の点数: %d\n", scores
); // 80
printf("3番目の点数: %d\n", scores
); // 95
C言語は境界チェックを行わないため、配列の範囲外へのアクセスは未定義の動作を引き起こします。つまりこんな記述はダメです。
scores[10] = 100; // 危険: バッファオーバーフロー
多次元配列
C言語は多次元配列をサポートしています。
// 2次元配列の宣言と初期化
int matrix
= {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
// 要素へのアクセス
int element = matrix
; // 2行目、3列目の要素: 7
多次元配列はメモリ内で行優先順に格納されます(最後のインデックスが最も速く変化)。
配列と関数
配列が関数に渡されるとき、実際には配列の最初の要素へのポインタが渡されます。
void printArray(int arr[], int size) {
for (int i = 0; i < size; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int numbers[] = {1, 2, 3, 4, 5};
printArray(numbers, 5);
return 0;
}
関数内で配列の要素を変更すると、元の配列も変更されます。
void modifyArray(int arr[], int size) {
for (int i = 0; i < size; i++) {
arr[i] *= 2; // 各要素を2倍
}
}
int main() {
int numbers[] = {1, 2, 3, 4, 5};
modifyArray(numbers, 5);
// numbers は {2, 4, 6, 8, 10} になる
return 0;
}
ポインタ
ポインタはC言語の最も強力かつ複雑な機能の一つで、メモリアドレスを格納する変数です。
ポインタの基本概念
- アドレス演算子 &: 変数のメモリアドレスを取得
- **間接参照演算子 ***: ポインタが指すメモリの内容にアクセス
int x = 10;
int *p; // int型ポインタの宣言
p = &x; // ポインタpにxのアドレスを代入
printf("x の値: %d\n", x); // 10
printf("x のアドレス: %p\n", &x); // メモリアドレス(例: 0x7ffeef7dcb68)
printf("p の値: %p\n", p); // xのアドレス(例: 0x7ffeef7dcb68)
printf("p が指す値: %d\n", *p); // 10
ポインタと関数
ポインタを使用すると、関数で引数の値を変更することができます(参照渡しのシミュレーション)。
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 5, y = 10;
printf("交換前: x = %d, y = %d\n", x, y);
swap(&x, &y); // アドレスを渡す
printf("交換後: x = %d, y = %d\n", x, y); // x=10, y=5
return 0;
}
ポインタの演算
ポインタに対する加減算は、ポインタが指す型のサイズに基づいています。
int numbers[] = {10, 20, 30, 40, 50};
int *p = numbers; // 配列の最初の要素のアドレス
printf("%d\n", *p); // 10 (numbers[0])
printf("%d\n", *(p+1)); // 20 (numbers
)
printf("%d\n", *(p+2)); // 30 (numbers
)
ポインタのインクリメント/デクリメントは型サイズに応じた移動になります。
int *p = numbers;
p++; // sizeof(int)バイト分だけアドレスが増加
printf("%d\n", *p); // 20 (numbers
)
配列とポインタの関係
C言語では、配列名は最初の要素のアドレスとして扱われます。
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // arrは&arr[0]と同じ
// 配列要素へのアクセス方法
printf("%d %d\n", arr
, *(arr+2)); // 30 30
printf("%d %d\n", p
, *(p+2)); // 30 30
ただし、配列とポインタには重要な違いがあります:
- 配列名は定数ポインタ – 再代入不可
- sizeof(配列名)は配列全体のサイズを返す
- sizeof(ポインタ)はポインタ自体のサイズを返す
ポインタと文字列
C言語の文字列は、NULL文字('\0'
)で終わる文字の配列です。
char str[] = "Hello"; // {'H', 'e', 'l', 'l', 'o', '\0'}
char *ptr = "World"; // 文字列リテラルへのポインタ
printf("%s\n", str); // Hello
printf("%s\n", ptr); // World
文字列操作関数も多くはポインタを活用しています:
#include <string.h>
char str1[20] = "Hello";
char str2[] = " World";
strcat(str1, str2); // str1に str2を連結
printf("%s\n", str1); // "Hello World"
int len = strlen(str1); // 文字列の長さを取得
printf("長さ: %d\n", len); // 11
ポインタのポインタ
C言語では、ポインタを指すポインタを使用することもできます。
int x = 10;
int *p = &x; // xを指すポインタ
int **pp = &p; // pを指すポインタ
printf("x の値: %d\n", x); // 10
printf("*p の値: %d\n", *p); // 10
printf("**pp の値: %d\n", **pp); // 10
**pp = 20; // xの値を変更
printf("x の新しい値: %d\n", x); // 20
多次元配列の動的割り当てやコマンドライン引数(char *argv[])など高度なシナリオで活用されます。
実践的なコード例: スタック実装
ポインタと配列、関数の概念を組み合わせてスタックデータ構造を実装してみましょう。
重要な項目は以下の通りです。
- 構造体へのポインタの使用
- 動的メモリ割り当てと解放
- ポインタによる間接的なデータ操作
- 関数を通じたデータ構造の制御
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
// スタックの構造体定義
typedef struct {
int *array; // データを格納する動的配列
int capacity; // スタックの容量
int top; // スタックのトップ位置
} Stack;
// スタックの初期化
void initialize(Stack *stack, int capacity) {
stack->capacity = capacity;
stack->array = (int *)malloc(capacity * sizeof(int));
stack->top = -1; // 空のスタック
}
// スタックが空かどうかチェック
bool isEmpty(Stack *stack) {
return stack->top == -1;
}
// スタックが満杯かどうかチェック
bool isFull(Stack *stack) {
return stack->top == stack->capacity - 1;
}
// スタックにプッシュ
void push(Stack *stack, int item) {
if (isFull(stack)) {
printf("エラー: スタックが満杯です\n");
return;
}
stack->array[++(stack->top)] = item;
printf("%d がプッシュされました\n", item);
}
// スタックからポップ
int pop(Stack *stack) {
if (isEmpty(stack)) {
printf("エラー: スタックが空です\n");
return -1;
}
return stack->array[(stack->top)--];
}
// スタックのトップ要素を確認
int peek(Stack *stack) {
if (isEmpty(stack)) {
printf("エラー: スタックが空です\n");
return -1;
}
return stack->array[stack->top];
}
// スタックの解放
void freeStack(Stack *stack) {
free(stack->array);
stack->array = NULL;
stack->top = -1;
stack->capacity = 0;
}
int main() {
Stack stack;
initialize(&stack, 5);
push(&stack, 10);
push(&stack, 20);
push(&stack, 30);
printf("スタックのトップ要素: %d\n", peek(&stack));
printf("ポップされた要素: %d\n", pop(&stack));
printf("ポップ後のトップ要素: %d\n", peek(&stack));
push(&stack, 40);
push(&stack, 50);
push(&stack, 60); // エラーメッセージが表示される
// スタックの解放
freeStack(&stack);
return 0;
}
まとめと次回の予告
この第2回では、関数、配列、ポインタという重要な概念を詳しく探求しました。これらの概念は、C言語プログラミングの基礎であり、より複雑なプログラムを構築するための土台となります。
次回は、構造体、共用体、列挙型などのユーザー定義データ型、およびC言語でのメモリ管理(動的メモリ割り当てと解放)について詳しく学んでいきます。
演習問題
- 第n項までのフィボナッチ数列を計算する関数を、再帰と非再帰(反復)の両方の方法で実装し、それぞれのパフォーマンスを比較してください。
- ポインタを使用して、整数配列の最大値、最小値、平均値を求める関数をそれぞれ実装してください。
- 3×3行列の加算、減算、乗算を行う関数を実装してください。行列はポインタの配列として表現します。
- 単方向連結リストを実装し、要素の追加、削除、検索、表示を行うプログラムを作成してください。
- C言語でダングリングポインタやメモリリークが発生する状況を説明し、それを回避するためのベストプラクティスを提案してください。