C 스타일 문자열
널 문자로 끝나는 char 배열의 저장 구조와 입출력·문자열 함수 사용법을 익히고 버퍼 한계를 이해합니다.
C++에서 텍스트를 소유하는 기본 도구는 std::string이지만, 운영체제 API·C 라이브러리·레거시 코드 경계에서는 여전히 const char*나 char*를 만납니다. 이 장에서는 그 포인터가 가리켜야 하는 널 종료 바이트 문자열(null-terminated byte string)의 저장 계약을 배웁니다.
핵심은 문자 배열, 배열의 전체 용량, 첫 널 문자 전의 문자열 길이를 같은 값으로 취급하지 않는 것입니다.
문자 배열과 C 스타일 문자열
C 스타일 문자열은 char 원소의 연속 구간이며, 접근 가능한 범위 안의 첫 널 문자 \0이 끝을 표시합니다. 널 문자의 값은 0이고 문자열 길이에는 포함되지 않지만, 저장 공간은 한 칸 차지합니다.
C++ · NULL-TERMINATED BYTE STRING
C 스타일 문자열은 임의의 char 배열이 아니라, 접근 가능한 범위 안에 첫 \0이 있는 바이트열입니다. 배열 용량과 문자열 길이는 서로 다른 값입니다.
| 인덱스 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 저장 값 | 'O' |
's' |
'l' |
'o' |
'\0' |
| 역할 | 문자열 내용 · 길이 4 | 첫 널 문자 · 끝 표시 | |||
용량 5 · 길이 4
인덱스 0 1 2 3 4
값 O s l o \0
마지막 한 칸이 문자열 탐색을 멈추게 합니다.
배열 extent와 문자열 length를 구분한다
char city[5] = "Oslo";
sizeof city // 5
std::strlen(city) // 4
sizeof는 배열 표현식이 배열 타입을 유지하는 문맥에서 전체 저장 칸을 셉니다. strlen은 첫 널 문자 직전까지만 셉니다.
맞는 bound 안에서 끝 표시와 나머지 0을 만든다
char name[] = "Ada";
// {'A', 'd', 'a', '\0'}
char padded[8] = "Hi";
// {'H', 'i', '\0', 0, 0, 0, 0, 0}
문자열 리터럴의 끝 널 문자까지 배열에 들어가야 합니다. 필요한 원소 수보다 작은 bound로 초기화하면 프로그램은 ill-formed입니다.
유효한 배열이어도 C 문자열은 아닐 수 있다
char raw[4]{'S', 'e', 'o', 'u'};
// raw is not a null-terminated
// byte string
이 값을 strlen처럼 널 종료를 전제로 하는 함수에 넘기면 함수가 배열 밖을 읽으며 undefined behavior가 발생할 수 있습니다.
변환 뒤에는 bound가 포인터 값에 남지 않는다
void inspect(const char text[]);
// parameter type adjusts to:
void inspect(const char* text);
배열은 상수 포인터가 아닙니다. 많은 표현식에서 첫 원소 포인터로 변환될 뿐이며, 함수 매개변수의 대괄호 표기는 배열 bound를 전달하지 않습니다.
검사 순서는 접근 가능한 capacity → 첫 \0의 위치 → 그 전까지의 length입니다. 셋 중 하나라도 증명할 수 없으면 널 종료 문자열 함수의 전제도 증명되지 않습니다.
#include <cstring>
char city[5] = "Oslo";
static_assert(sizeof city == 5); // 배열 전체 원소 수(char는 1바이트)
// std::strlen(city)는 첫 '\0' 전까지 세므로 4sizeof city가 배열 크기를 알려 주는 것은 city가 배열 타입을 유지하는 문맥이기 때문입니다. 배열은 상수 포인터가 아닙니다. 다만 많은 표현식에서 첫 원소를 가리키는 포인터로 array-to-pointer conversion되며, 그 포인터 값에는 원래 배열 bound가 들어 있지 않습니다.
void inspect(const char text[20]);
// 위 선언의 매개변수 타입은 다음과 같습니다.
void inspect(const char* text);따라서 함수가 고정 버퍼의 용량까지 알아야 한다면 별도 길이를 받거나, 배열 참조·std::span처럼 범위를 보존하는 타입을 사용해야 합니다.
선언과 초기화
문자열 리터럴로 배열을 초기화하면 리터럴의 문자와 끝 널 문자가 차례로 배열 원소를 초기화합니다. bound를 생략하면 컴파일러가 끝 널까지 포함한 크기를 추론합니다.
char greeting[6] = "Hello";
// {'H', 'e', 'l', 'l', 'o', '\0'}
char name[] = "World";
static_assert(sizeof name == 6);
char padded[8] = "Hi";
// {'H', 'i', '\0', 0, 0, 0, 0, 0}배열이 리터럴의 끝 널 문자까지 담지 못하면 표준 C++ 프로그램은 ill-formed입니다. 단순한 실행 시 경고로 취급해서는 안 됩니다.
char shortArray[4] = "LongString"; // ill-formed문자를 직접 나열할 때는 끝 널을 직접 넣어야 C 문자열이 됩니다.
char valid[5]{'S', 'e', 'o', 'u', '\0'};
char raw[4]{'S', 'e', 'o', 'u'}; // 유효한 배열이지만 C 문자열은 아님
char buffer[100]; // automatic 객체: 원소 값과 끝 널이 정해지지 않음raw나 값을 쓰지 않은 buffer를 std::strlen, std::strcpy, C 문자열 출력처럼 널 종료를 전제로 하는 연산에 넘기면, 함수가 배열 밖에서 널 문자를 찾게 되어 undefined behavior가 발생할 수 있습니다.
배열 객체는 대입할 수 없으므로 buffer = "text";도 허용되지 않습니다. 이것은 배열이 상수 포인터여서가 아니라 built-in 배열에 배열 대입 연산이 정의되어 있지 않기 때문입니다.
입력 단위와 남은 입력
입력 함수는 원하는 단위가 공백 전 token인지 개행 전 한 줄인지에 따라 선택합니다. 일반 C++ 코드에서는 먼저 std::string으로 받아 길이를 관리하는 편이 단순합니다.
#include <iostream>
#include <string>
std::string token;
std::cin >> token; // 선행 공백을 건너뛰고 다음 공백에서 멈춤
std::string line;
std::getline(std::cin, line); // 다음 개행까지 읽음고정 배열이 필요한 경우 C++20 이후의 문자 배열 overload는 배열 bound를 사용해 최대 N - 1문자를 저장하고 끝 널을 둡니다. C++17 이하의 포인터 overload는 같은 bound 보호를 제공하지 않으므로, 이전 표준 모드에서는 std::string 또는 명시적인 입력 폭 계약이 필요합니다. 한도에서 멈춘 뒤 입력에 남은 문자가 있는지도 다음 읽기 전에 판단해야 합니다.
#include <iostream>
#include <iterator>
char token[20]{};
std::cin >> token;
char fullName[50]{};
std::cin.getline(fullName, std::size(fullName));istream::getline(array, n)은 최대 n - 1문자를 저장할 공간을 남기고 끝 널을 기록합니다. 구분자에 도달하기 전에 한도에 닿으면 failbit가 설정될 수 있으므로, 스트림 상태와 남은 입력을 함께 복구해야 합니다.
한도 도달이 실패 원인이고 badbit나 eofbit가 아니라는 점을 확인했다면, 남은 줄을 버리기 전에 먼저 failbit를 지워야 합니다. 실패 상태에서는 ignore도 입력을 추출하지 못합니다.
#include <iostream>
#include <limits>
if (std::cin.fail() && !std::cin.bad() && !std::cin.eof()) {
std::cin.clear();
std::cin.ignore(
std::numeric_limits<std::streamsize>::max(),
'\n'
);
}formatted input인 operator>>가 성공해 스트림 상태가 정상이고, 다음 prompt에서 새 줄을 받을 때 현재 줄의 나머지를 의도적으로 버리려면 다음처럼 최대 길이까지 개행을 제거할 수 있습니다.
#include <iostream>
#include <limits>
std::cin.ignore(
std::numeric_limits<std::streamsize>::max(),
'\n'
);인자 없는 std::cin.ignore()는 기본적으로 한 문자만 제거합니다. 이를 “입력 버퍼 전체를 비운다”고 설명하면 John Doe\n처럼 여러 문자가 남은 경우를 처리하지 못합니다. 반대로 남은 문자가 다음 데이터라면 버리지 말고 읽기 순서를 다시 설계해야 합니다.
문자열 함수와 용량 전제
<cstring> 함수는 호출자가 각 함수에 맞는 유효 포인터, 읽을 수 있는 source 범위, 필요한 널 종료, 목적지 용량과 비중첩 전제를 만족했다고 가정합니다. 길이 제한 함수라고 해서 모든 전제가 같지는 않습니다. C++ 헤더를 포함했다면 std:: 이름을 사용합니다.
| 함수 | 역할 | 호출자가 증명할 전제 |
|---|---|---|
std::strlen(src) | 첫 널 문자 전의 길이 | src가 접근 가능한 범위 안에서 널 종료됨 |
std::strcpy(dest, src) | 끝 널까지 복사 | source 널 종료, destination에 strlen(src) + 1칸 이상, 두 객체가 겹치지 않음 |
std::strcat(dest, src) | destination 끝에 source 추가 | 두 입력 모두 널 종료, 최종 길이와 끝 널이 destination에 들어감, 두 객체가 겹치지 않음 |
std::strcmp(a, b) | 두 문자열 비교 | 두 입력 모두 널 종료; 결과의 음수·0·양수만 판정 |
std::strcmp는 locale 사전 정렬이 아니라 unsigned character 값의 lexicographical 비교를 수행합니다. 구현이 반환하는 정확한 음수나 양수 값에는 의미를 부여하지 않습니다.
#include <cstring>
#include <iterator>
char destination[20]{};
const char source[] = "Hello";
if (std::strlen(source) + 1 <= std::size(destination)) {
std::strcpy(destination, source);
}std::strncpy와 std::strncat을 이름에 n이 있다는 이유만으로 일반적인 overflow 해결책으로 사용해서는 안 됩니다.
std::strncpy는 source의 첫n개 원소 안에서 널 문자를 만나지 않으면 결과를 널 종료하지 않습니다. 먼저 만나면 destination에 모두n개가 기록될 때까지 널 문자로 채웁니다.std::strncat의n은 source에서 추가할 최대 문자 수입니다. 그 뒤에 끝 널을 한 칸 더 기록하므로, 이미 널 종료된 destination의 현재 길이와 남은 용량을 따로 계산해야 합니다.
std::strcpy, std::strcat, std::strncpy, std::strncat에서 source와 destination 객체가 겹치면 동작은 undefined입니다.
새 C++ 코드에서는 std::string의 대입·append·비교를 우선하고, 고정 외부 버퍼 계약이 있을 때만 그 계약을 명시적으로 검사하는 편이 안전합니다.
C++ · TEXT BOUNDARY DECISIONS
새 C++ 텍스트는 std::string으로 소유하고, 고정 char 배열과 C 함수는 경계에서만 사용합니다. 어떤 API를 고르든 저장 용량과 널 종료 전제를 따로 증명해야 합니다.
| 목적 | 권장 형태 | 멈춤·안전 계약 |
|---|---|---|
| 공백 전 token | |
선행 공백을 건너뛰고 다음 공백에서 멈춥니다. C++20 이후 고정 배열 overload는 bound에서 끝 널 한 칸을 뺀 제한을 사용하지만, 이전 포인터 overload에는 같은 보호가 없습니다. |
| 공백 포함 한 줄 | |
구분자 개행까지 읽습니다. 앞선 formatted input의 나머지를 버릴 의도라면 먼저 최대 길이까지 ignore합니다. |
| 고정 배열 한 줄 | |
최대 배열 크기보다 한 글자 적게 저장하고 끝 널을 둡니다. 구분자 전에 한도에 닿으면 원인을 확인하고 failbit를 지운 뒤 남은 입력을 처리합니다. |
| 복사·이어 붙이기 | std::string의 대입과 append |
strcpy·strcat은 목적지의 남은 용량을 검사하지 않습니다. 호출 전 결과 전체와 끝 널 공간을 증명해야 합니다. |
| C API에 읽기 전달 | |
포인터를 수정하지 않습니다. 소유 문자열의 수명과 무효화 가능 연산을 가로질러 보관하지 않고 호출 경계에서 다시 얻습니다. |
token · 다음 공백까지
std::string token;
std::cin >> token;
고정 배열보다 동적 길이 문자열을 기본으로 둡니다. char[N]의 bound-aware overload는 C++20 이후 계약입니다.
line · 다음 개행까지
std::string line;
std::getline(std::cin, line);
formatted input의 남은 줄을 버릴 때만 최대 길이 ignore를 먼저 사용합니다.
fixed buffer · bound 전달
char line[50]{};
std::cin.getline(line,
std::size(line));
한도 도달 원인을 확인하고 failbit를 지운 뒤 남은 입력을 복구합니다.
C API · 짧은 포인터 수명
legacy(text.c_str());
읽기 전용으로 쓰고, 소유 문자열의 수명이나 무효화 가능 연산을 가로질러 보관하지 않습니다.
strcpy와 strcat
source가 널 종료됐고 destination에 최종 내용과 \0이 모두 들어가며 두 객체가 겹치지 않을 때만 호출합니다.
strncpy와 strncat
strncpy는 첫 n개 안에 널이 없으면 널 종료하지 않고, 짧으면 padding합니다. strncat은 최대 n문자 뒤에 끝 널 한 칸을 더 씁니다.
strcmp
locale 사전 정렬이 아니라 unsigned character 값에 따른 lexicographical 비교입니다. 음수·0·양수만 사용합니다.
한 문자 ignore()를 “버퍼 비우기”로 부르지 않는다
std::cin.ignore(
std::numeric_limits<
std::streamsize
>::max(),
'\n'
);
스트림이 정상일 때 현재 줄의 나머지를 정말 버리려는 경우에 사용합니다. failbit가 있다면 실패 원인을 확인하고 먼저 지워야 하며, 같은 입력을 이어 읽어야 한다면 남은 문자를 보존합니다.
선택 순서: 원하는 입력 단위 → 소유 타입 → capacity와 terminator → 실패 상태와 남은 입력 → C 경계의 포인터 수명.
C API와 std::string의 경계
읽기 전용 const char*를 요구하는 C API에는 std::string::c_str()를 사용할 수 있습니다.
#include <string>
void legacy_read_only(const char*);
void send_status() {
std::string text = "status";
legacy_read_only(text.c_str());
}반환 포인터로 문자열을 수정해서는 안 됩니다. 포인터는 소유한 std::string의 수명이 계속되고 그 포인터를 무효화할 수 있는 연산이 없을 때만 사용할 수 있습니다. 해당 문자열을 비-const 참조로 받는 표준 라이브러리 함수에 넘기거나, operator[], at, data, front, back, begin, rbegin, end, rend를 제외한 비-const 멤버를 호출하면 기존 포인터가 무효화될 수 있습니다. 따라서 장기간 저장하지 말고 호출 경계에서 다시 얻어 사용합니다. 쓰기 가능한 외부 버퍼를 요구하는 API는 필요한 크기, 실제 기록 길이, 끝 널 규칙을 그 API 계약에 맞춰 별도로 처리해야 합니다.
정리하면 C 스타일 문자열을 읽을 때는 다음 순서를 지킵니다.
- 접근 가능한 배열 또는 버퍼의 용량을 확인합니다.
- 그 범위 안에 첫
\0이 있는지 확인합니다. - 입력·복사·append 결과와 끝 널이 모두 들어가는지 계산합니다.
- 스트림 실패 상태와 남은 입력, C API 포인터의 수명을 함께 관리합니다.
다음 절에서는 저장 공간과 길이를 객체가 관리하는 std::string을 자세히 다룹니다.