第6章: 防御的プログラミングとソフトウェアテスト理論 - ソフトウェア品質保証の計算機科学的基盤
6.1 防御的プログラミングの理論
防御的プログラミングとは
防御的プログラミング(Defensive Programming)は、ソフトウェア工学における設計哲学で、プログラムが予期せぬ入力や条件下でも正しく動作することを目指します。
防御的プログラミングの原則(Steve McConnell, 2004):
1. 入力を信頼しない(Trust No Input)
├─ すべての外部入力を検証
└─ 境界値のチェック
2. フェイルセーフ設計(Fail-Safe Design)
├─ エラー発生時は安全な状態に
└─ 継続不能なエラーは明示的に停止
3. アサーションの使用(Use Assertions)
├─ 前提条件の明示化
└─ 内部整合性の検証
4. エラー処理の明示化(Explicit Error Handling)
├─ すべての戻り値をチェック
└─ エラーは呼び出し元に通知
フォールトトレラントシステム
フォールトトレラント設計の階層(Avizienis, 2004):
┌────────────────────────────────────────────────────────────┐
│ Fault(障害) │
│ ↓ (activation) │
│ Error(誤り) │
│ ↓ (propagation) │
│ Failure(故障) │
└────────────────────────────────────────────────────────────┘
対策の分類:
1. Fault Prevention(障害予防): 開発時のレビュー、テスト
2. Fault Tolerance(障害許容): 冗長性、リカバリ
3. Fault Removal(障害除去): デバッグ、修正
4. Fault Forecasting(障害予測): 信頼性評価
6.2 POSIXエラー処理の意味論
errnoの設計思想
UNIXシステムは、errnoというグローバル変数を使用してエラー情報を伝達します。これは1970年代の設計ですが、今日でも広く使用されています。
errnoの仕組み:
┌─────────────────────────────────────────────────────────┐
│ システムコール │
│ │
│ 成功時: │
│ ├─ 戻り値: >= 0(FDなど有効な値) │
│ └─ errno: 変更されない(未定義) │
│ │
│ 失敗時: │
│ ├─ 戻り値: -1 │
│ └─ errno: エラーコードに設定 │
└─────────────────────────────────────────────────────────┘
重要な注意点:
1. errnoは成功時に0にリセットされない
2. errnoのチェックは戻り値が-1の時のみ意味がある
3. スレッドセーフ(各スレッドに独自のerrno)
主要なPOSIXエラーコード
システムプログラミングで頻出するerrno値:
ファイル関連:
┌────────────┬────────────────────────────────────────┐
│ ENOENT │ No such file or directory │
│ EACCES │ Permission denied │
│ EISDIR │ Is a directory │
│ ENOTDIR │ Not a directory │
│ EEXIST │ File exists │
│ ENOTEMPTY │ Directory not empty │
└────────────┴────────────────────────────────────────┘
リソース関連:
┌────────────┬────────────────────────────────────────┐
│ ENOMEM │ Cannot allocate memory │
│ EMFILE │ Too many open files (per process) │
│ ENFILE │ File table overflow (system-wide) │
│ EAGAIN │ Resource temporarily unavailable │
└────────────┴────────────────────────────────────────┘
I/O関連:
┌────────────┬────────────────────────────────────────┐
│ EBADF │ Bad file descriptor │
│ EINVAL │ Invalid argument │
│ EINTR │ Interrupted system call │
│ EPIPE │ Broken pipe │
│ EIO │ I/O error │
└────────────┴────────────────────────────────────────┘
プロセス関連:
┌────────────┬────────────────────────────────────────┐
│ ECHILD │ No child processes │
│ ESRCH │ No such process │
└────────────┴────────────────────────────────────────┘
perror()とstrerror()
#include <stdio.h>
#include <string.h>
#include <errno.h>
/* perror(): エラーメッセージを標準エラーに出力 */
void example_perror(void)
{
int fd = open("/nonexistent", O_RDONLY);
if (fd == -1)
{
perror("open"); /* 出力: "open: No such file or directory" */
}
}
/* strerror(): エラーコードに対応する文字列を取得 */
void example_strerror(void)
{
int fd = open("/nonexistent", O_RDONLY);
if (fd == -1)
{
fprintf(stderr, "Error: %s (errno=%d)\n",
strerror(errno), errno);
/* 出力: "Error: No such file or directory (errno=2)" */
}
}
6.3 終了ステータスの理論
POSIX終了ステータス規約
POSIX.1-2017による終了コードの定義:
┌─────────────┬─────────────────────────────────────────────┐
│ 終了コード │ 意味 │
├─────────────┼─────────────────────────────────────────────┤
│ 0 │ 成功 │
│ 1-125 │ コマンド固有のエラー │
│ │ (多くのプログラムは1を一般エラーに使用) │
│ 126 │ コマンドは見つかったが実行できない │
│ │ (実行権限なし、または非実行ファイル) │
│ 127 │ コマンドが見つからない │
│ 128 │ 不正な終了引数 │
│ 128+n │ シグナルnで終了 │
│ │ 例: 128+9=137 (SIGKILL) │
│ │ 128+11=139 (SIGSEGV) │
│ │ 128+15=143 (SIGTERM) │
│ 255 │ 終了コードが範囲外(256以上 mod 256) │
└─────────────┴─────────────────────────────────────────────┘
wait()マクロファミリー
#include <sys/wait.h>
/*
* waitpid()から返されるstatusの解析マクロ:
*
* WIFEXITED(status):
* 子プロセスが正常終了した場合にtrue
*
* WEXITSTATUS(status):
* exit()に渡された終了コードを取得(WIFEXITEDがtrueの時のみ有効)
*
* WIFSIGNALED(status):
* 子プロセスがシグナルで終了した場合にtrue
*
* WTERMSIG(status):
* 終了を引き起こしたシグナル番号(WIFSIGNALEDがtrueの時のみ有効)
*
* WCOREDUMP(status):
* コアダンプが生成された場合にtrue(非POSIX拡張)
*/
int get_exit_status(int status)
{
if (WIFEXITED(status))
return (WEXITSTATUS(status));
else if (WIFSIGNALED(status))
return (128 + WTERMSIG(status));
else
return (1); /* 不明な終了理由 */
}
6.4 ソフトウェアテスト理論
テストの分類
ソフトウェアテストは、様々なレベルと手法に分類されます:
テストレベルのV字モデル:
要件定義 ──────────────────────────→ 受け入れテスト
│ ↑
↓ │
基本設計 ──────────────────────────→ システムテスト
│ ↑
↓ │
詳細設計 ──────────────────────────→ 統合テスト
│ ↑
↓ │
実装 ───────────────────────────→ 単体テスト
各レベルの目的:
┌──────────────┬─────────────────────────────────────────┐
│ 単体テスト │ 個々の関数・モジュールの正確性検証 │
│ 統合テスト │ モジュール間のインターフェース検証 │
│ システムテスト │ システム全体の機能・非機能要件検証 │
│ 受け入れテスト │ ユーザー要件の満足度検証 │
└──────────────┴─────────────────────────────────────────┘
テスト技法
ブラックボックステスト(機能テスト):
┌─────────────────────────────────────────────────────────┐
│ 入力 → [プログラム(内部構造は無視)] → 出力 │
│ │
│ 技法: │
│ ・同値分割:入力を等価クラスに分類 │
│ ・境界値分析:境界付近の値をテスト │
│ ・決定表:条件の組み合わせを網羅 │
└─────────────────────────────────────────────────────────┘
ホワイトボックステスト(構造テスト):
┌─────────────────────────────────────────────────────────┐
│ 内部構造に基づいてテストケースを設計 │
│ │
│ カバレッジ基準: │
│ ・ステートメントカバレッジ:全文を実行 │
│ ・ブランチカバレッジ:全分岐を通過 │
│ ・条件カバレッジ:全条件のtrue/false │
│ ・パスカバレッジ:全経路を通過(現実的に困難) │
└─────────────────────────────────────────────────────────┘
Pipexにおけるテスト戦略
Pipexテスト戦略:
1. 単体テストレベル:
├─ find_command(): パス検索の正確性
├─ ft_split(): コマンドライン解析
└─ 各ヘルパー関数の動作確認
2. 統合テストレベル:
├─ パイプ接続の正確性
├─ ファイルリダイレクションの動作
└─ プロセス間の通信
3. システムテストレベル:
├─ シェルとの出力比較
├─ エッジケースの処理
└─ エラー状況での動作
4. 回帰テスト:
└─ 修正後も既存機能が動作することを確認
6.5 エラー処理の実装パターン
ファイル操作のエラー処理
#include <errno.h>
#include <fcntl.h>
int open_infile(char *filename)
{
int fd;
fd = open(filename, O_RDONLY);
if (fd == -1)
{
/* 詳細なエラーメッセージを提供 */
ft_putstr_fd("pipex: ", STDERR_FILENO);
ft_putstr_fd(filename, STDERR_FILENO);
ft_putstr_fd(": ", STDERR_FILENO);
if (errno == ENOENT)
ft_putendl_fd("No such file or directory", STDERR_FILENO);
else if (errno == EACCES)
ft_putendl_fd("Permission denied", STDERR_FILENO);
else if (errno == EISDIR)
ft_putendl_fd("Is a directory", STDERR_FILENO);
else
ft_putendl_fd(strerror(errno), STDERR_FILENO);
return (-1);
}
return (fd);
}
int open_outfile(char *filename, int append_mode)
{
int fd;
int flags;
if (append_mode)
flags = O_WRONLY | O_CREAT | O_APPEND;
else
flags = O_WRONLY | O_CREAT | O_TRUNC;
fd = open(filename, flags, 0644);
if (fd == -1)
{
ft_putstr_fd("pipex: ", STDERR_FILENO);
ft_putstr_fd(filename, STDERR_FILENO);
ft_putstr_fd(": ", STDERR_FILENO);
ft_putendl_fd(strerror(errno), STDERR_FILENO);
return (-1);
}
return (fd);
}
システムコールのエラー処理
/* fork()の安全なラッパー */
pid_t safe_fork(void)
{
pid_t pid;
pid = fork();
if (pid == -1)
{
if (errno == EAGAIN)
ft_putendl_fd("Error: Process limit reached", STDERR_FILENO);
else if (errno == ENOMEM)
ft_putendl_fd("Error: Insufficient memory", STDERR_FILENO);
else
perror("fork");
exit(EXIT_FAILURE);
}
return (pid);
}
/* pipe()の安全なラッパー */
int safe_pipe(int pipefd[2])
{
if (pipe(pipefd) == -1)
{
if (errno == EMFILE)
ft_putendl_fd("Error: Too many open files", STDERR_FILENO);
else if (errno == ENFILE)
ft_putendl_fd("Error: System file table overflow", STDERR_FILENO);
else
perror("pipe");
return (-1);
}
return (0);
}
/* dup2()の安全なラッパー */
int safe_dup2(int oldfd, int newfd)
{
if (dup2(oldfd, newfd) == -1)
{
if (errno == EBADF)
ft_putendl_fd("Error: Bad file descriptor", STDERR_FILENO);
else if (errno == EINTR)
ft_putendl_fd("Error: Interrupted by signal", STDERR_FILENO);
else
perror("dup2");
return (-1);
}
return (0);
}
コマンド実行のエラー処理
void execute_cmd_safe(char *cmd, char **envp)
{
char **args;
char *cmd_path;
/* コマンドをパース */
args = ft_split(cmd, ' ');
if (!args || !args[0])
{
ft_putendl_fd("Error: Empty command", STDERR_FILENO);
exit(127);
}
/* コマンドパスを検索 */
cmd_path = find_command_path(args[0], envp);
if (!cmd_path)
{
free_array(args);
exit(127); /* command not found */
}
/* execve()の実行 */
if (execve(cmd_path, args, envp) == -1)
{
free(cmd_path);
free_array(args);
if (errno == ENOENT)
{
ft_putendl_fd(": No such file or directory", STDERR_FILENO);
exit(127);
}
else if (errno == EACCES)
{
ft_putendl_fd(": Permission denied", STDERR_FILENO);
exit(126);
}
else
{
perror("execve");
exit(126);
}
}
}
6.6 メモリ安全性
リソースリーク防止
リソースリークの種類:
1. メモリリーク
├─ malloc()後のfree()忘れ
└─ 例外パスでのメモリ解放漏れ
2. ファイルディスクリプタリーク
├─ open()後のclose()忘れ
└─ fork()後の不要なFD放置
3. プロセスリーク
└─ 子プロセスのwait()忘れ(ゾンビプロセス)
リソース管理パターン
/* RAII風パターン(獲得時に初期化、終了時に解放) */
typedef struct s_resources
{
int *fds;
int fd_count;
char **allocations;
int alloc_count;
} t_resources;
void init_resources(t_resources *res)
{
res->fds = NULL;
res->fd_count = 0;
res->allocations = NULL;
res->alloc_count = 0;
}
void register_fd(t_resources *res, int fd)
{
int *new_fds;
new_fds = realloc(res->fds, sizeof(int) * (res->fd_count + 1));
if (new_fds)
{
res->fds = new_fds;
res->fds[res->fd_count++] = fd;
}
}
void cleanup_resources(t_resources *res)
{
int i;
/* すべてのFDを閉じる */
for (i = 0; i < res->fd_count; i++)
{
if (res->fds[i] >= 0)
close(res->fds[i]);
}
free(res->fds);
/* すべてのメモリを解放 */
for (i = 0; i < res->alloc_count; i++)
free(res->allocations[i]);
free(res->allocations);
}
6.7 テスト自動化
シェルスクリプトによるテスト
#!/bin/bash
# test_pipex.sh - 包括的なテストスイート
# 色定義
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
PIPEX="./pipex"
TEST_DIR="test_files"
PASSED=0
FAILED=0
TOTAL=0
# テスト環境のセットアップ
setup() {
mkdir -p "$TEST_DIR"
echo -e "line 1\nline 2\nline 3\nline 4\nline 5" > "$TEST_DIR/input.txt"
echo -e "apple\nbanana\napricot\navocado" > "$TEST_DIR/fruits.txt"
touch "$TEST_DIR/empty.txt"
}
# テスト環境のクリーンアップ
cleanup() {
rm -rf "$TEST_DIR"
}
# テストケースの実行
run_test() {
local name="$1"
local pipex_cmd="$2"
local shell_cmd="$3"
local pipex_out="$TEST_DIR/pipex_out"
local shell_out="$TEST_DIR/shell_out"
((TOTAL++))
echo -n "[$TOTAL] $name ... "
# 両方のコマンドを実行
eval "$pipex_cmd" > /dev/null 2>&1
local pipex_exit=$?
eval "$shell_cmd" > /dev/null 2>&1
local shell_exit=$?
# 出力を比較
if diff -q "$pipex_out" "$shell_out" > /dev/null 2>&1; then
echo -e "${GREEN}PASS${NC}"
((PASSED++))
else
echo -e "${RED}FAIL${NC}"
echo " Expected (shell):"
head -3 "$shell_out" 2>/dev/null | sed 's/^/ /'
echo " Got (pipex):"
head -3 "$pipex_out" 2>/dev/null | sed 's/^/ /'
((FAILED++))
fi
rm -f "$pipex_out" "$shell_out"
}
# エラーケースのテスト
run_error_test() {
local name="$1"
local cmd="$2"
local expected_exit="$3"
((TOTAL++))
echo -n "[$TOTAL] $name ... "
eval "$cmd" > /dev/null 2>&1
local actual_exit=$?
if [ "$actual_exit" -eq "$expected_exit" ]; then
echo -e "${GREEN}PASS${NC} (exit=$actual_exit)"
((PASSED++))
else
echo -e "${RED}FAIL${NC} (expected=$expected_exit, got=$actual_exit)"
((FAILED++))
fi
}
# テスト実行
echo "====== Pipex Test Suite ======"
echo
setup
# 基本機能テスト
echo "--- Basic Functionality ---"
run_test "cat | wc -l" \
"$PIPEX $TEST_DIR/input.txt 'cat' 'wc -l' $TEST_DIR/pipex_out" \
"< $TEST_DIR/input.txt cat | wc -l > $TEST_DIR/shell_out"
run_test "grep | wc -l" \
"$PIPEX $TEST_DIR/input.txt 'grep line' 'wc -l' $TEST_DIR/pipex_out" \
"< $TEST_DIR/input.txt grep line | wc -l > $TEST_DIR/shell_out"
run_test "head | tail" \
"$PIPEX $TEST_DIR/input.txt 'head -n 3' 'tail -n 1' $TEST_DIR/pipex_out" \
"< $TEST_DIR/input.txt head -n 3 | tail -n 1 > $TEST_DIR/shell_out"
# 境界値テスト
echo
echo "--- Edge Cases ---"
run_test "empty file" \
"$PIPEX $TEST_DIR/empty.txt 'cat' 'wc -l' $TEST_DIR/pipex_out" \
"< $TEST_DIR/empty.txt cat | wc -l > $TEST_DIR/shell_out"
# エラーケーステスト
echo
echo "--- Error Handling ---"
run_error_test "nonexistent input file" \
"$PIPEX $TEST_DIR/nonexistent.txt 'cat' 'wc -l' $TEST_DIR/out" 0
run_error_test "command not found" \
"$PIPEX $TEST_DIR/input.txt 'notacommand' 'wc -l' $TEST_DIR/out" 127
run_error_test "invalid command argument" \
"$PIPEX $TEST_DIR/input.txt 'ls --invalid-option' 'wc -l' $TEST_DIR/out" 0
cleanup
# 結果サマリー
echo
echo "====== Results ======"
echo -e "Passed: ${GREEN}$PASSED${NC}"
echo -e "Failed: ${RED}$FAILED${NC}"
echo "Total: $TOTAL"
if [ $FAILED -eq 0 ]; then
echo -e "\n${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "\n${RED}$FAILED test(s) failed.${NC}"
exit 1
fi
メモリリークテスト
#!/bin/bash
# test_leaks.sh - Valgrindによるメモリリーク検査
PIPEX="./pipex"
TEST_DIR="test_files"
setup() {
mkdir -p "$TEST_DIR"
echo "test content" > "$TEST_DIR/input.txt"
}
cleanup() {
rm -rf "$TEST_DIR"
}
echo "====== Memory Leak Test ======"
setup
# Valgrindを実行
valgrind \
--leak-check=full \
--show-leak-kinds=all \
--track-fds=yes \
--error-exitcode=1 \
--log-file="$TEST_DIR/valgrind.log" \
$PIPEX "$TEST_DIR/input.txt" "cat" "wc -l" "$TEST_DIR/output.txt"
EXIT_CODE=$?
# 結果表示
if [ $EXIT_CODE -eq 0 ]; then
echo "✓ No memory leaks detected"
else
echo "✗ Memory leaks or FD leaks detected"
echo
echo "Valgrind output:"
cat "$TEST_DIR/valgrind.log"
fi
cleanup
exit $EXIT_CODE
6.8 デバッグ技法
科学的デバッグ手法
Andreas Zeller(2009)による科学的デバッグ:
1. 仮説の形成
└─ 「このバグの原因は X ではないか」
2. 実験の設計
└─ 仮説を検証するテストケースを作成
3. 観察
└─ プログラムの動作を観察
4. 仮説の検証/棄却
└─ 観察結果に基づいて仮説を評価
5. 繰り返し
└─ 新たな仮説を立てて再検証
実践例:
問題: "パイプの出力が空"
仮説1: dup2()が失敗している
→ 実験: dup2()の戻り値をログ出力
→ 観察: 戻り値は0(成功)
→ 結論: 棄却
仮説2: 書き込み側が先に閉じられている
→ 実験: close()のタイミングをログ出力
→ 観察: 親がpipe[1]を閉じてから子がwrite
→ 結論: 採用 → 修正
printfデバッグ
/* 条件付きデバッグマクロ */
#ifdef DEBUG
# define DEBUG_PRINT(fmt, ...) \
fprintf(stderr, "[DEBUG] %s:%d: " fmt "\n", \
__FILE__, __LINE__, ##__VA_ARGS__)
# define DEBUG_FD(fd, msg) \
fprintf(stderr, "[DEBUG] %s: fd=%d\n", msg, fd)
#else
# define DEBUG_PRINT(fmt, ...) ((void)0)
# define DEBUG_FD(fd, msg) ((void)0)
#endif
/* 使用例 */
int main(int argc, char **argv)
{
DEBUG_PRINT("argc = %d", argc);
DEBUG_PRINT("argv[1] = %s", argv[1]);
int fd = open(argv[1], O_RDONLY);
DEBUG_FD(fd, "open infile");
/* ... */
}
コンパイル方法:
# デバッグビルド
gcc -DDEBUG -g -Wall -Wextra pipex.c -o pipex
# リリースビルド
gcc -O2 -Wall -Wextra pipex.c -o pipex
FDトレース
/* プロセスの開いているFDを表示 */
void debug_print_fds(const char *label)
{
#ifdef DEBUG
char path[64];
char link[256];
int i;
fprintf(stderr, "[DEBUG] === FD Table (%s) ===\n", label);
for (i = 0; i < 20; i++)
{
snprintf(path, sizeof(path), "/proc/self/fd/%d", i);
ssize_t len = readlink(path, link, sizeof(link) - 1);
if (len != -1)
{
link[len] = '\0';
fprintf(stderr, " FD %2d: %s\n", i, link);
}
}
fprintf(stderr, "=========================\n");
#else
(void)label;
#endif
}
GDBによるデバッグ
# デバッグシンボル付きでコンパイル
gcc -g -O0 -Wall -Wextra pipex.c -o pipex
# GDBの起動
gdb ./pipex
# 基本コマンド
(gdb) break main # ブレークポイント設定
(gdb) break fork # fork()にブレークポイント
(gdb) run in "cat" "wc" out # 引数を指定して実行
(gdb) next # 次の行を実行
(gdb) step # 関数の中に入る
(gdb) print pid # 変数の値を表示
(gdb) info locals # ローカル変数を表示
(gdb) backtrace # コールスタック表示
(gdb) continue # 次のブレークポイントまで実行
# fork後の子プロセスを追跡
(gdb) set follow-fork-mode child
(gdb) set detach-on-fork off
6.9 Pipex完成チェックリスト
機能要件
必須機能(Mandatory):
□ ./pipex infile cmd1 cmd2 outfile が動作する
□ シェルの "< infile cmd1 | cmd2 > outfile" と同等の出力
□ PATH環境変数からのコマンド検索
□ 絶対パス/相対パスのコマンド実行
□ 適切なエラーメッセージ
ボーナス機能:
□ here_doc LIMITER cmd1 cmd2 outfile
□ 複数パイプ: cmd1 | cmd2 | cmd3 | ... | cmdN
□ 追記モード(>>)の実装
品質要件
コード品質:
□ Norminette準拠
□ メモリリークなし(Valgrind確認)
□ FDリークなし
□ ゾンビプロセスなし
□ 適切なエラー処理
テスト:
□ 基本機能のテスト
□ エッジケースのテスト
□ エラーケースのテスト
□ シェルとの出力比較
6.10 まとめ
学習した理論
- 防御的プログラミング
- POSIXエラー意味論
- ソフトウェアテスト理論
- デバッグ技法
実践のポイント
- すべてのシステムコールの戻り値をチェック
- 詳細なエラーメッセージを提供
- リソースを確実に解放
- 自動テストでリグレッションを防止
---
おめでとうございます! Pipexプロジェクトの全章を完了しました。ここで学んだプロセス管理、パイプ通信、エラー処理の知識は、minishellやその他のシステムプログラミングプロジェクトで活用できます。