購入として成立しない入力を拒否する

C 言語Beginner
オンラインで実践に進む

はじめに

レシートに含めるべきものは承認された購入であり、数値変換関数が行頭で認識できた数値の接頭部分すべてではありません。Sunny Snacks には、入力全体の検証と、過去の購入を上書きしない容量チェックが必要になりました。

この独立した単元には、専用の商品カタログとレシートプログラムが用意されています。「レシートの購入行をすべて残す」で学んだ配列、関数、構造体、整数セント計算の知識が必要です。範囲を制限した十進数パーサーに必要な文字列とアドレスの詳細だけを学び、その後、買い物かごを変更せずに無効な入力を拒否します。Workspace ターミナルとコンパイラーは準備済みです。架空データを扱うテキストレシートプログラムであり、決済システムではありません。所要時間は著者の見積もりです。

十進数テキスト全体を変換する

このステップでは、有効な完全な数値と、先頭の数値部分だけを区別し、パーサーの成功と失敗の境界を実装します。

Workspace タブから始めます。準備されたファイルは前の実験から独立しています。cd で作業ディレクトリに移動し、ls で名前を確認し、cat でテキストを表示します。

cd /home/labex/project/receipts
ls
cat parse-demo.c
cat parse.h
cat read-line.h

parse-demo.c は parse_number を呼び出す小さなプログラムです。数量を読み、受け付けた整数または拒否を表示して終了します。次のステップでは、till.c が同じ parse.h を使います。現在のパーサーは常に 0 を返すため、有効な入力も拒否します。catalog.h、driver.h、read-line.h、parse-demo.c は変更せず、parse.h と、その後で till.c を編集してください。

C のテキストは、値がゼロのNUL 終端文字(\0)で終わる文字配列です。表示される数字 '0' とは異なります。たとえば次のようになります。

array index:    0     1     2
text:         '2'   '0'  '\0'

提供された read_line は 64 バイト、つまり最大 63 バイトの入力と終端文字の領域を確保します。getchar() は一文字を読み、int 型の戻り値は EOF も表せます。ループは最大 63 文字を保存し、長すぎる行でも改行まで読み続けるので、残りが次の回答になることはありません。完全な行では 1、消費済みの長すぎる行では -1、EOF では 0 を返します。EOF は未完了の行も破棄します。サンプルの条件 read_line(text) != 1 は、変換する前に後二つのケースを拒否します。

パーサーの公開インターフェースは次のとおりです。

int parse_number(const char text[], int minimum, int maximum, int *result)

準備済みのサンプルとドライバーが使うため、この名前と引数の順序を保ってください。成功時は 1、失敗時は 0 を返します。成功したら、変換した整数を呼び出し元の結果変数に保存します。失敗したら、その変数を変更しません。minimum と maximum は両端を含みます。購入数量の最小値は 1 でも、最小値が 0 の呼び出しでは数値 0 は有効です。

この呼び出しには、アドレスの考え方が少し必要です。通常の整数引数は値を渡します。&quantity は呼び出し元の quantity の格納場所のアドレスです。引数 int *result はそのアドレスを受け取り、*result = value は呼び出し元の格納場所に値を書き込みます。宣言の星印はアドレスを保存する変数を表し、式の中の星印はそのアドレスの格納場所にアクセスします。ここでは、この具体的な出力引数を扱い、動的メモリー確保やポインター全般の演習には広げません。

stdlib.h で宣言される strtol は、テキストを long 整数に変換します。基数 10 を明示するため、先頭にゼロがあっても十進数です。第一引数は読み始める文字を指します。第二引数も出力用です。char *end は、数値の一部にならなかった最初の文字のアドレスを保持します。&end はこのアドレスを保持する変数自体のアドレスを渡し、strtol が更新できるようにします。別の文字列をコピーするわけではありません。

"2bars" -> converted long value 2; end points at 'b'
"20"    -> converted long value 20; end points at '\0'
"+"     -> no conversion; end equals the starting address

先頭部分を変換するだけでは不十分です。この課題の構文は、任意の先頭・末尾の ASCII スペースまたはタブ、任意の符号、先頭ゼロも含む十進数字を許可します。内部の空白、小数点、十六進接頭辞、その他の空白文字、末尾の文字は無効です。符号だけ、空のテキスト、空白だけのテキストも無効です。小文字の完全一致コマンド done はプログラムの Product ID プロンプト専用で、数値テキストではありません。

配列インデックス begin を使い、許可された先頭のスペースとタブだけを飛ばします。&text[begin] は残った最初の文字のアドレスです。strtol を呼ぶ前に、その文字が数字か符号であることを検査します。これにより、同関数のより広い空白処理が、この課題の構文を暗黙に広げるのを防ぎます。変換後に end == &text[begin] なら、一桁も消費されていません。*end は停止位置の文字を読み、end++ はそのアドレスを一文字分進めます。末尾のスペースかタブだけを飛ばし、その後は終端文字であることを要求します。

もう一つ境界があります。テキストが表す値は、long にさえ収まらない可能性があります。errno.h の errno はライブラリーのエラー指標です。呼び出し前にゼロにし、呼び出し後の ERANGE で範囲オーバーフローを検出します。そのエラーと業務上の最小値・最大値の両方を検査してから、long を int に変換してください。戻された数値ゼロだけでは、エラーを意味しません。

完成したパーサーで parse.h を置き換えます。ヘッダー内の関数は static なので、取り込んだ各プログラムに属します。cat > はファイルを置き換え、引用符付きのヒアドキュメントは、最後の EOF 行まで以下の C をそのままコピーします。

cat > parse.h <<'EOF'
#include <errno.h>
#include <stdlib.h>

static int parse_number(const char text[], int minimum, int maximum, int *result) {
    int begin = 0;
    while (text[begin] == ' ' || text[begin] == '\t') begin++;
    char first = text[begin];
    if (first != '+' && first != '-' && (first < '0' || first > '9')) return 0;
    char *end;
    errno = 0;
    long value = strtol(&text[begin], &end, 10);
    if (end == &text[begin] || errno == ERANGE) return 0;
    while (*end == ' ' || *end == '\t') end++;
    if (*end != '\0' || value < minimum || value > maximum) return 0;
    *result = (int)value;
    return 1;
}
EOF

最後の文 *result = (int)value は、すべての検査を通過した後でのみ呼び出し元の整数を書き込みます。そのため、無効な入力は既存の結果を変更しません。明示的なキャスト (int) は、安全性を確認済みの変換を行います。

小さなサンプルをコンパイルします。-std=c11 は C11 を選び、-Wall -Wextra は警告を有効にし、-o は実行ファイル名を指定します。

gcc -std=c11 -Wall -Wextra parse-demo.c -o parse-demo
./parse-demo

Quantity (1..20): で +0002 を入力し、Enter を押します。Accepted quantity: 2 と表示され、シェルプロンプトに戻ります。符号と先頭ゼロがあっても、通常の整数 2 を表しています。

再度サンプルを実行します。

./parse-demo

数量のプロンプトで 2bars を入力します。期待される結果は Rejected quantity. です。これは意図した失敗です。先頭部分の 2 を変換しても、行全体を検証したことにはなりません。プログラムの終了ステータスは呼び出し元に渡す小さな整数で、ここでは 0 が通常の成功、2 がサンプルによる入力拒否を意味します。直ちに echo $? を実行し、直前のステータスを表示してください。間に別のコマンドを挟むと置き換わります。

echo $?

期待される出力は 2 です。この拒否はターミナルやコンパイラーの故障ではありません。三回目のサンプル実行を行います。

./parse-demo

9999999999999999999999999999999999999999 を入力して Enter を押します。これも拒否されます。行は 63 バイト未満ですが、十進数の値が変換範囲を超えています。一方、21 は long に収まりますが、最大数量 20 を超えます。同じ安全な拒否でも、理由は異なります。

コンパイル時にファイル不足と出たら、cd と ls で復帰します。構文エラーなら、修正したヘッダーを保存して再コンパイルしてください。parse-demo はコンパイル時にヘッダーを取り込みます。古い実行ファイルの実行では、新しい編集を検証できません。

状態を変更する前に購入を承認する

このステップでは、パーサーと購入配列を組み合わせ、書き込む前に満杯のかごを拒否し、複数の入力拒否の後でレシートを保存します。

Workspace の /home/labex/project/receipts から始めます。準備済みの till.c には、前の実験のレコード型、カタログ検索、完全な出力処理が含まれています。ただし approve_purchase には未実装部分があります。

cd /home/labex/project/receipts
cat catalog.h
cat till.c
cat driver.h

ドライバーをプロンプトから内側へ読み進めてください。ID の完全な行を読み、完全一致の done を認識し、十進 ID を解析して、商品が存在するか確認します。その後、数量を読んで 1..20 の範囲でパーサーを呼び出します。!parse_number(...) はパーサーが偽を返したという意味です。読み取りや変換に失敗すると、承認処理を呼ばずに Product ID に戻ります。無効な数量は処理中の候補だけを破棄し、過去の購入は破棄しません。

承認処理は配列の境界も守ります。商品のインデックスを検索し、存在しない ID や 1..20 外の数量を拒否し、line_count >= MAX_LINES の場合も拒否します。|| は、どれか一つの条件でも拒否することを意味します。その後でのみ両フィールドを書き込み、line_count を増やして 1 を返します。basket[MAX_LINES] への書き込みは、一回だけでも六要素の配列外へのアクセスです。後から拒否しても、その不正な書き込みは取り消せません。使える位置は 0~5 です。

すべての計算は整数セントのままです。提供された四行では、ID は 1..999、価格は 0..9999 セントで、有効な無料商品も含まれます。数量が最大 20 の購入を六件に制限することで、合計は最大 1,199,880 セントになります。ID が繰り返されても、承認済み購入を一件ずつ順序どおりに残してください。この範囲の別の四行カタログを提供して再コンパイルしても、同じロジックが使える必要があります。構造体やローカル変数の名前は変えてもかまいませんが、準備済みの関数インターフェースはドライバーに合わせてください。

完成プログラムで till.c を置き換えます。独立した単元なので、レコードと出力の基本部分を再掲しており、前の VM のファイルは必要ありません。parse.h は、この VM の前のステップで完成させたパーサーをそのまま使います。

cat > till.c <<'EOF'
#include <stdio.h>
#include "catalog.h"
#include "parse.h"

typedef struct {
    int id;
    char name[32];
    int cents;
} Product;

typedef struct {
    int product_index;
    int quantity;
} Purchase;

static const Product products[] = CATALOG_ROWS;
static const int product_count = sizeof products / sizeof products[0];
static Purchase basket[MAX_LINES];
static int line_count;

static int find_product(int id) {
    for (int i = 0; i < product_count; i++)
        if (products[i].id == id) return i;
    return -1;
}

static int approve_purchase(int id, int quantity) {
    int index = find_product(id);
    if (index < 0 || quantity < 1 || quantity > MAX_QUANTITY || line_count >= MAX_LINES)
        return 0;
    basket[line_count].product_index = index;
    basket[line_count].quantity = quantity;
    line_count++;
    return 1;
}

static void print_receipt(void) {
    int total = 0;
    puts(SHOP_TITLE);
    puts("ID | ITEM | QTY | UNIT | SUBTOTAL");
    for (int i = 0; i < line_count; i++) {
        Product product = products[basket[i].product_index];
        int quantity = basket[i].quantity;
        int subtotal = product.cents * quantity;
        total += subtotal;
        printf("%d | %s | %d | %d.%02d | %d.%02d\n",
               product.id, product.name, quantity,
               product.cents / 100, product.cents % 100,
               subtotal / 100, subtotal % 100);
    }
    printf("TOTAL | %d.%02d\n", total / 100, total % 100);
}

#include "driver.h"
EOF

新しいコードをコンパイルします。

gcc -std=c11 -Wall -Wextra till.c -o till

ドライバーはプロンプトとメッセージを標準エラー出力に送ります。完全なレシートだけを標準出力に送ります。新しいファイルへリダイレクトし、表示されるプロンプトに入力を続けてください。

./till > receipt.txt

次の取引を順序どおり入力します。ID と数量のそれぞれで Enter を押してください。不明な ID には数量を尋ねません。拒否後は Product ID に戻るので、前の数量プロンプトへの回答を続けようとしないでください。

Product ID への入力 Quantity への入力 期待される観察
101 2 Added line 1
102 2bars 数量を拒否し、行 1 を保持
999 数量の入力なし 不明な ID、行 1 を保持
103 20 Added line 2、有効な数量の上限
104 1 Added line 3、有効な無料商品
102 1 Added line 4
101 1 Added line 5、行 1 とは別の購入
104 2 Added line 6
103 1 かごが満杯で拒否し、六行すべてを保持
done 数量の入力なし レシートを確定し、シェルに戻る

シェルプロンプトが戻ってから、保存された結果を読んでください。

cat receipt.txt

期待される内容は次のとおりです。

SUNNY SNACKS
ID | ITEM | QTY | UNIT | SUBTOTAL
101 | Oat bar | 2 | 1.25 | 2.50
103 | Water | 20 | 0.75 | 15.00
104 | Water | 1 | 0.00 | 0.00
102 | Fruit cup | 1 | 2.30 | 2.30
101 | Oat bar | 1 | 1.25 | 1.25
104 | Water | 2 | 0.00 | 0.00
TOTAL | 21.05

拒否された 2bars、ID 999、七件目の購入は、行を作らず、過去の行も変更していません。ID 103 と 104 はどちらも Water と表示しますが、異なる価格を保っています。価格ゼロは数量ゼロではありません。整数の合計は 250 + 1500 + 0 + 230 + 125 + 0 = 2105 セントです。

完全なレシートには、売り場のタイトル、指定された縦棒区切りの五列、順序どおりの全購入、最後に厳密に一つの TOTAL が含まれます。空行やフィールド周辺の空白は変えられますが、列見出しの欠落、認識できない余分なレコード、合計後の行があれば不完全な出力です。receipt.txt、till.c、parse.h、実行ファイル till、すべての提供ファイルを保持します。コースのチェックでは、一時ディレクトリでも未変更のコードのコピーを、実行時の境界検出付きで実行します。与えた入力で起きる誤りは検出できますが、完全なメモリー安全性の証明ではなく、ファイルの修復も行いません。

キャンセルと確定の違いを観察するため、別の出力名で新しい取引を開始します。

./till > cancelled.txt

ID 101 を入力して Enter を押し、空の数量プロンプトで Ctrl+D を押します。EOF は Cancelled: no receipt. と表示してキャンセルし、完全なレシートを公開しません。未完了の入力行も破棄します。直ちに本来の終了ステータスを確認します。

echo $?

期待される出力は 2 です。以前の receipt.txt が残るのは、このコマンドが別のファイル名を使うためです。同じ名前なら、> はプログラムを実行する前に既存ファイルを切り詰めます。一方、Product ID の done はステータス 0 で確定し、空のかごでも合計 0.00 のレシートを作ります。done は数量コマンドにはなりません。

有効な購入が拒否される場合は、パーサーがテキスト全体を検証し、範囲の両端を含んでいるか、その後で承認条件を確認してください。過去の行が消える場合は、エラーパスがかごやカウンターの変更前に戻っているか確認します。保存、再コンパイルを行い、レシートのテキストを編集せず、空のかごで始まる新しい実行で必要な取引を繰り返してください。正しく保存されたレシートと、再利用できるプログラムは一致しなければなりません。

まとめ

長さを制限した文字列の行、変換終了位置のポインター、オーバーフロー検査、両端を含む業務上の範囲を使って、購入を承認する前に検証しました。無効な入力や満杯のかごによる拒否の後も、それまでの行をすべて残し、無料商品や境界値の数量も受け付けるプログラムになりました。購入の確定と EOF によるキャンセルの結果も、明確に区別できるようになりました。