구매가 될 수 없는 입력 거부하기

CBeginner
지금 연습하기

소개

영수증에는 숫자 변환 함수가 행 앞부분에서 알아본 모든 접두부가 아니라 승인된 구매가 들어가야 합니다. 이제 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에서 텍스트는 값이 0인 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으로 전달하므로 앞에 0이 있어도 십진수입니다. 첫 번째 인수는 읽기 시작할 문자를 가리킵니다. 두 번째 인수도 출력입니다. 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 공백 또는 탭, 선택적 부호, 앞자리 0을 포함한 십진 숫자를 허용합니다. 내부 공백, 소수점, 16진수 접두부, 다른 공백 문자, 뒤에 붙는 글자는 유효하지 않습니다. 부호만 있거나 텍스트가 비어 있거나 공백뿐인 경우도 유효하지 않습니다. 정확한 소문자 명령 done은 프로그램의 Product ID 프롬프트 전용이며 숫자 텍스트가 아닙니다.

배열 인덱스 begin으로 허용된 앞쪽 공백과 탭만 건너뛰세요. &text[begin]은 남은 첫 문자의 주소입니다. strtol 호출 전에 이 문자가 숫자나 부호인지 검사하면, 함수가 더 넓게 허용하는 공백 처리 때문에 문법이 조용히 확장되지 않습니다. 변환 후 end == &text[begin]은 한 자리도 소비하지 않았다는 뜻입니다. *end는 멈춘 주소의 문자를 읽고 end++는 주소를 한 문자만큼 전진시킵니다. 뒤쪽 공백이나 탭만 건너뛴 다음 종료 문자를 요구하세요.

또 다른 경계도 있습니다. 텍스트가 long에도 들어가지 않는 큰 값을 나타낼 수 있습니다. errno.h의 errno는 라이브러리 오류 지표입니다. 호출 전에 0으로 설정하며, 호출 후 ERANGE는 범위 초과를 나타냅니다. 이 오류와 업무상의 최솟값 및 최댓값을 검사한 뒤에만 long을 int로 변환하세요. 반환된 숫자 0 자체는 오류 신호가 아닙니다.

완전한 파서로 parse.h를 교체하세요. 헤더의 함수는 static이므로 이를 포함하는 각 프로그램에 속합니다. cat >는 파일을 교체하고 따옴표로 묶인 here-document는 다음 C 코드를 마지막 EOF 행까지 그대로 복사합니다.

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이며 셸 프롬프트가 돌아옵니다. 부호와 앞자리 0이 있지만 일반 정수 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로 표시되지만 서로 다른 가격을 유지합니다. 가격 0은 수량 0이 아닙니다. 정수 합계는 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 취소는 이제 명확히 다른 결과를 가집니다.