std::string 클래스
std::string의 값·길이·용량 계약과 연산자·멤버 함수·C API 경계를 익힙니다.
앞 글의 C 스타일 문자열은 배열의 용량, 끝의 널 문자, 복사할 곳의 남은 공간을 코드를 쓰는 사람이 직접 맞춰야 했습니다. 표준 라이브러리의 std::string은 이 일을 객체가 맡아, 글자를 덧붙이면 필요한 만큼 저장 공간을 늘리고 길이도 스스로 기억합니다.
이 글은 std::string이 관리하는 길이와 용량, 만드는 방법, 대입과 연결과 비교, 인덱스 접근, 검색과 편집, 한 줄 입력을 받는 std::getline, C API에 넘길 때의 주의점을 봅니다. 사용하려면 <string> 헤더를 포함합니다.
C++ · STRING OBJECT CONTRACT
문자열의 값·길이·용량과 관찰자 수명은 별개다
std::string은 문자 시퀀스를 값으로 소유하고 길이와 저장 공간을 관리합니다. 현재 문자 수, 재할당 여유, 끝 널 문자는 서로 다른 계약입니다.
문자 시퀀스를 객체가 소유한다
복사하면 별도의 문자열 값을 얻습니다. C++11 이후 문자들은 연속 저장되지만 내부 표현과 작은 문자열 최적화는 구현 세부사항입니다.
현재 문자 개수를 센다
size()와 length()는 같은 값을 반환합니다. 끝 널 문자는 이 개수에 포함되지 않습니다.
재할당 전 저장 여유를 나타낸다
항상 현재 길이 이상이지만 길이와 같을 필요는 없습니다. 객체가 짧아져도 자동 축소를 보장하지 않습니다.
연속 범위 뒤에 끝 널 문자가 있다
std::string name = "Ada";
name.size() // 3
name.capacity() // 3 이상
name.data()[3] // '\0'
일반 문자 인덱스는 0부터 2까지입니다. 현행 draft에서 3은 끝 널 위치이며, 그 값을 char() 이외로 바꾸면 undefined behavior입니다.
길이 감소와 용량 감소는 별개다
name.reserve(80);
name.clear();
// size() == 0
// capacity()는 0일 필요 없음
// 비구속 요청
name.shrink_to_fit();
현행 draft와 C++20 이후 reserve(n)은 더 작은 용량으로 줄이지 않습니다. 현행 shrink_to_fit()은 용량을 늘리지 않는 비구속 요청이며, 실제 재할당하면 모든 원소 관찰자가 무효화됩니다.
객체 길이와 C 문자열 길이가 다를 수 있다
const char bytes[]{
'A', '\0', 'B'
};
std::string packet(bytes, 3);
packet.size() // 3
널 종료만 아는 C 함수는 첫 널에서 멈춥니다. 포인터와 길이를 함께 받는 API인지 확인합니다.
변경 연산은 기존 관찰자를 무효화할 수 있다
대입·추가·삽입·삭제·교체와 비상수 표준 라이브러리 호출 뒤에는 원소 포인터, 참조와 반복자의 유효성을 다시 판단합니다.
값의 소유권은 객체에 있고, 내부 주소의 수명은 별도 계약입니다. 현행 draft에서 shrink_to_fit()이 재할당하지 않으면 기존 관찰자는 그대로 유효합니다.
읽는 순서: 소유한 문자 값 → 현재 size() → 재할당 여유 capacity() → 끝 널과 관찰자 수명. 구현의 저장 전략은 추측하지 않습니다.
길이, 용량, 끝의 널 문자
std::string은 개수가 바뀔 수 있는 char의 나열을 값으로 소유합니다. 위 그림처럼 std::string name = "Ada";에는 세 가지를 구별해야 합니다.
size()와 length()는 지금 들어 있는 글자 수인 3을 돌려주며, 두 함수는 같은 값입니다. capacity()는 저장 공간을 다시 할당하지 않고 담을 수 있는 글자 수로, 언제나 size() 이상이지만 정확한 값은 구현이 정합니다. 글자는 메모리에 이어서 저장되고 마지막 글자 뒤인 data()[size()]에는 널 문자가 있지만, 이 널 문자는 size()에 들어가지 않습니다.
여기서 글자 수는 char의 개수입니다. 영문자는 한 글자가 char 하나이지만, GCC가 쓰는 UTF-8에서 한글 한 글자는 char 세 개이므로 std::string("한").size()는 3입니다. 이 글의 길이와 인덱스는 모두 char 단위로 계산합니다.
용량은 길이를 따라 자동으로 줄지 않습니다. clear()나 erase()로 글자를 지우면 size()는 줄지만 capacity()는 그대로일 수 있습니다. 앞으로 들어올 글자 수를 안다면 reserve(n)으로 미리 공간을 확보해 재할당 횟수를 줄일 수 있고, C++20부터 reserve는 용량을 늘리기만 합니다. 줄이고 싶을 때 쓰는 shrink_to_fit()은 요청일 뿐이어서 실제로 줄어든다는 보장이 없습니다.
더 깊이: 짧은 문자열은 객체 안에 담깁니다
주요 표준 라이브러리는 짧은 문자열을 힙에 할당하지 않고 std::string 객체 안의 버퍼에 바로 담으며, 이를 짧은 문자열 최적화(SSO, small string optimization)라고 부릅니다. GCC의 libstdc++는 객체 안에 char 16개짜리 버퍼를 두어 널 문자를 뺀 15글자까지 담고, Microsoft의 MSVC STL도 char 기준으로 15글자까지를 객체 안에 둡니다. 그래서 두 구현에서는 빈 문자열의 capacity()가 15이고, 그보다 긴 내용을 담을 때 처음으로 메모리를 할당합니다. 이 수치는 표준이 정한 것이 아니므로 코드가 기대서는 안 되지만, 짧은 문자열을 만들고 복사하는 데 할당이 필요 없다는 점은 성능을 볼 때 알아 둘 만합니다.
예전 libstdc++의 std::string은 다른 방식을 썼습니다. 복사할 때 버퍼를 함께 쓰다가 한쪽을 바꿀 때에야 실제로 복사하는 쓸 때 복사(COW, copy-on-write)입니다. C++11 표준이 이런 문자열을 허용하지 않게 되자 libstdc++는 GCC 5.1에서 std::string과 std::list의 구현을 바꾼 새 ABI를 도입했고, 두 구현을 _GLIBCXX_USE_CXX11_ABI 매크로로 고를 수 있게 했습니다. libstdc++의 이중 ABI 문서에 따르면, 링크 오류에 std::__cxx11이나 [abi:cxx11]이 들어간 이름이 보이면 대개 이 매크로를 서로 다른 값으로 컴파일한 목적 파일이 섞인 것입니다.
만들기
std::string은 빈 값, 문자열 리터럴, 다른 문자열, 같은 글자의 반복, 다른 문자열의 일부로 만들 수 있습니다. 아래 다섯 줄이 그 순서이며, 줄 끝의 주석은 각 변수에 들어가는 글자를 보여 줍니다. 괄호 안에 수를 넘기는 생성자는 인자의 순서가 뜻을 정하므로, s4의 (5, 'A')는 글자 수가 먼저이고 글자가 나중입니다.
#include <string>
std::string s1; // ""
std::string s2 = "Hello C++"; // 리터럴의 글자를 복사
std::string s3 = s2; // 값 복사
std::string s4(5, 'A'); // "AAAAA"
std::string s5(s2, 6, 3); // 6번 위치부터 3글자, "C++"s3는 s2와 같은 글자를 가진 별도의 값이어서, 나중에 s3를 바꿔도 s2는 바뀌지 않습니다. s5는 "Hello C++"에서 인덱스 6의 C부터 세 글자를 복사합니다.
const char* 하나를 받는 생성자는 첫 널 문자 앞까지만 복사합니다. 데이터 안에 널 문자가 들어 있거나 길이를 따로 알고 있다면 길이를 함께 넘기는 생성자를 씁니다.
#include <cstring>
#include <iostream>
#include <string>
int main() {
const char raw[]{'A', '\0', 'B', '\0'};
std::string packet(raw, 3);
std::string copied = packet;
packet.at(2) = 'C';
std::cout << packet.size() << ' ' << std::strlen(packet.c_str())
<< ' ' << copied.at(2) << ' ' << packet.at(2) << '\n';
std::cout << std::boolalpha << (packet.capacity() >= packet.size()) << '\n';
}3 1 B C
truepacket은 A, 널 문자, B의 세 글자를 소유하므로 size()는 3입니다. 하지만 널 문자로 끝을 찾는 strlen은 A 다음의 널 문자에서 멈춰 1을 돌려줍니다. copied는 독립된 값이어서 packet의 마지막 글자를 C로 바꿔도 B를 유지하며, capacity()의 구체적인 값은 구현마다 다르므로 size() 이상인지만 출력했습니다.
대입, 연결, 비교
std::string은 기본 타입처럼 =로 대입하고, +로 이어 붙인 새 값을 만들고, +=로 자기 뒤에 덧붙이며, ==와 <로 비교합니다. 아래 코드는 +로 만든 sentence와 +=로 바꾼 greeting을 따로 출력하므로, 새 값을 만드는 연산과 원래 값을 바꾸는 연산을 출력에서 구별할 수 있습니다. 첫 줄의 sentence.size()는 이어 붙인 결과의 글자 수입니다.
#include <iostream>
#include <string>
int main() {
std::string greeting = "Hello";
std::string target = "World";
std::string sentence = greeting + ", " + target + "!";
greeting += " everyone";
std::cout << sentence << ' ' << sentence.size() << '\n';
std::cout << greeting << '\n';
std::cout << std::boolalpha << (target == "World") << ' '
<< (std::string("apple") < std::string("banana")) << '\n';
}Hello, World! 13
Hello everyone
true true==는 C 스타일 문자열의 포인터와 달리 주소가 아니라 글자의 나열을 비교하므로, target == "World"는 참입니다. <는 앞 글자부터 차례로 비교해 a가 b보다 앞서므로 참이며, compare() 멤버 함수도 같은 순서로 음수, 0, 양수를 돌려줍니다. 이 순서는 각 글자를 unsigned char로 바꾼 값의 순서이므로 대문자가 소문자보다 앞서고, 한국어 사전순이나 대소문자를 무시하는 정렬과는 다릅니다.
+를 쓸 때는 피연산자 중 하나가 std::string이어야 합니다. "Hello" + " World"는 두 문자열 리터럴, 즉 두 포인터를 더하려는 식이어서 컴파일 오류입니다. 연산자 우선순위에서 본 대로 +는 왼쪽부터 묶이므로, greeting + ", " + target은 (greeting + ", ") + target이 되어 매 단계에 std::string이 있습니다.
인덱스와 at()
글자는 []나 at()으로 인덱스를 주어 읽고 씁니다. 올바른 인덱스는 0부터 size() - 1까지입니다.
std::string word = "Example";
word[0] = 'e'; // 인덱스가 올바르다는 것을 아는 경우
char c = word.at(2); // 범위를 검사한 뒤 'a'[]는 배열처럼 범위를 검사하지 않으며, size()보다 큰 인덱스는 정의되지 않은 동작입니다. word[word.size()]는 끝의 널 문자를 가리키지만 다른 값으로 바꾸면 안 되므로, 글자를 다룰 때는 i < size()를 지킵니다. at(i)는 i가 size() 이상이면 std::out_of_range 예외를 던지므로, 사용자 입력처럼 믿을 수 없는 인덱스에는 at()을 쓰거나 미리 검사합니다. 첫 글자와 마지막 글자를 돌려주는 front()와 back()은 문자열이 비어 있지 않을 때만 씁니다.
검색과 편집
멤버 함수는 상태를 알려 주는 것, 새 값을 돌려주는 것, 원래 문자열을 바꾸는 것으로 나누어 읽으면 헷갈리지 않습니다.
C++ · STRING OPERATION LEDGER
문자열 연산은 새 값·원본 변경·빌린 포인터를 구분한다
함수 이름보다 먼저 결과가 새 값인지, 원본 변경인지, 잠시 빌린 포인터인지 확인합니다. 인덱스·검색 실패·재할당·C API precondition을 연산과 함께 읽어야 합니다.
| 목적과 대표 표현 | 유지할 계약 |
|---|---|
| 대입
|
+는 결과 값을 만들고 =·+=는 왼쪽 객체를 바꿉니다. |
| 비교
|
주소가 아닌 문자 시퀀스를 비교합니다. compare()는 음수·0·양수만 해석하며, 순서는 문자 traits 기반 lexicographical 비교입니다. |
| 접근
|
일반 문자 위치는 pos < size()입니다. 현행 draft에서 pos == size()는 끝 널 위치이고, at()은 이 위치부터 out_of_range를 던집니다. |
| 검색
|
검색 실패는 npos입니다. substr()는 새 값을 반환하고 pos > size()이면 예외를 던집니다. |
| 편집
|
객체를 바꾸며 기존 포인터·참조·반복자가 무효화될 수 있습니다. |
| C API
|
C++11 이후 c_str()와 data()는 같은 배열 시작을 가리킵니다. 내장 널·길이·쓰기 가능 여부를 API와 함께 명시합니다. |
새 값인가, 원본 변경인가
auto joined = left + right;
left += right;
첫 줄은 결과 값을 만들고, 둘째 줄은 left를 바꿉니다.
위치를 증명했는가
text[pos] // pos < size()
text.at(pos) // 검사 후 접근
find()의 결과는 npos인지 먼저 확인합니다. at()은 pos ≥ size()에서 예외를 던집니다. []의 끝 널 위치에는 다른 문자를 쓰지 않습니다.
내용이 같은가, 어느 쪽이 앞서는가
left == right
left.compare(right)
compare()의 음수·0·양수만 해석합니다. 문자 traits에 따른 순서이며 자연어 locale 정렬은 아닙니다.
부분 값과 원본 편집
substr(pos, count)는 새 값을 만들고 pos > size()에서 예외를 던집니다. insert()·erase()·replace()는 원본을 바꾸며 기존 관찰자를 무효화할 수 있습니다.
C API가 무엇을 받는가
널 종료 읽기 전용인지, 포인터와 길이를 함께 받는지, 쓰기 버퍼인지 구분합니다. C++11 이후 c_str()와 data()는 같은 배열 시작을 가리키며, c_str()는 읽기 전용 경계입니다.
실패 sentinel과 인덱스를 먼저 확인한다
std::string text =
"Learn C++ today.";
auto pos = text.find("C++");
if (pos != std::string::npos) {
auto topic =
text.substr(pos, 3);
}
text.replace(10, 5, "safely");
결과: Learn C++ safely.
필요한 순간에 포인터를 얻는다
void legacy_read_only(const char*);
std::string message = "ready";
legacy_read_only(message.c_str());
문자열을 바꾸거나 비상수 표준 라이브러리 호출에 넘긴 뒤에는 기존 포인터를 재사용하지 않고 다시 얻습니다.
내장 널은 길이와 함께 보존한다
const char bytes[]{
'A', '\0', 'B'
};
std::string packet(bytes, 3);
널 종료 생성자와 C 함수는 첫 널에서 멈춥니다. 값 전체가 필요하면 읽을 수 있는 범위의 길이를 함께 전달합니다.
c_str()에 쓰지 않는다
반환형은 const char*입니다. 출력용 C API에는 별도의 writable 범위와 호출 후 실제 문자열 길이를 반영하는 계약이 필요합니다.
C++17 이후 비상수 data()로는 [0, size())의 문자를 쓸 수 있지만, 끝 널을 다른 값으로 바꾸거나 용량만 확보한 영역을 문자열 크기처럼 취급하면 안 됩니다.
선택 순서: 소유 값인지 → 조회·새 값·원본 변경인지 → 위치와 실패 결과가 유효한지 → 관찰자 무효화가 가능한지 → C API의 포인터·길이·쓰기 계약이 무엇인지.
| 하는 일 | 대표 함수 | 결과 |
|---|---|---|
| 길이, 빈 문자열 확인 | size(), empty() | 원래 문자열은 그대로 |
| 검색 | find(needle, pos) | 찾은 위치, 없으면 std::string::npos |
| 부분 문자열 | substr(pos, count) | 새 문자열, pos > size()이면 예외 |
| 뒤에 덧붙이기 | +=, append(), push_back() | 원래 문자열을 바꿈 |
| 중간 편집 | insert(), erase(), replace() | 원래 문자열을 바꿈 |
| 모두 지우기 | clear() | size()는 0, 용량은 그대로일 수 있음 |
#include <iostream>
#include <string>
int main() {
std::string text = "Learn C++ today.";
const auto pos = text.find("C++");
if (pos != std::string::npos) {
std::cout << pos << ' ' << text.substr(pos, 3) << '\n';
}
text.replace(10, 5, "safely");
std::cout << text << '\n';
}6 C++
Learn C++ safely."C++"는 인덱스 6에서 시작하므로 find는 6을 돌려주고, substr(6, 3)은 그 위치의 세 글자로 새 문자열을 만듭니다. replace(10, 5, "safely")는 인덱스 10부터 다섯 글자인 today를 safely로 바꾸며, 바꾸는 글자 수가 달라도 문자열의 길이가 알맞게 바뀝니다.
find가 찾지 못했을 때 돌려주는 npos는 std::string::size_type의 가장 큰 값이어서, 확인하지 않고 인덱스로 쓰면 범위를 벗어납니다. 그래서 결과를 쓰기 전에 항상 npos와 비교합니다.
한 줄 입력과 std::getline
std::cin >> s는 공백 앞까지 단어 하나만 읽으므로, 공백이 들어간 이름이나 문장을 받으려면 std::getline(std::cin, s)로 줄바꿈 앞까지 한 줄을 읽습니다. 줄바꿈은 입력에서 꺼내 버리고 문자열에는 넣지 않으며, 길이는 std::string이 알아서 늘리므로 앞 글의 고정 배열처럼 넘칠 걱정이 없습니다.
#include <iostream>
#include <string>
int main() {
int age{};
std::string name;
std::cin >> age;
std::getline(std::cin >> std::ws, name);
std::cout << name << " (" << age << ")\n";
}이 코드는 나이를 >>로, 이름을 getline으로 읽습니다. 두 읽기 사이에 끼운 std::cin >> std::ws가 왜 필요한지가 이 예제에서 볼 부분입니다. 입력이 두 줄 30과 Kim Min이면 출력은 다음과 같습니다.
Kim Min (30)std::cin >> age는 30만 읽고 그 뒤의 줄바꿈은 입력에 남겨 둡니다. 여기서 바로 std::getline(std::cin, name)을 부르면 남은 줄바꿈 앞의 빈 줄을 읽어 name이 빈 문자열이 됩니다. std::cin >> std::ws는 앞에 남은 공백과 줄바꿈을 건너뛰므로, 다음 줄의 Kim Min을 읽게 됩니다. 빈 줄도 의미 있는 입력이라면 std::ws 대신 while과 do-while 반복문에서 본 ignore()로 첫 줄의 나머지만 버립니다.
std::getline은 읽기에 성공하면 참으로 해석되므로, while (std::getline(std::cin, line))처럼 쓰면 입력이 끝날 때까지 한 줄씩 읽을 수 있습니다. 한 줄 전체가 정수인지 검사하려면 이렇게 줄을 읽은 뒤 따로 변환해 확인합니다.
C API에 넘기기
읽기 전용 const char*를 받는 C API에는 c_str()을 넘깁니다. c_str()과 data()는 널 문자로 끝나는 내부 글자 배열의 시작을 가리킵니다.
#include <string>
void legacy_read_only(const char* text);
void send() {
std::string message = "ready";
legacy_read_only(message.c_str());
}이 포인터는 message가 살아 있고 바뀌지 않는 동안에만 쓸 수 있습니다. 글자를 덧붙이거나 지우거나 다른 값을 대입하면 내부 저장 공간이 옮겨 갈 수 있으므로, 포인터를 저장해 두지 말고 필요할 때 다시 얻습니다. 또 packet처럼 중간에 널 문자가 있는 문자열을 C 함수에 넘기면 C 함수는 첫 널 문자까지만 보므로, 길이도 함께 받는 API인지 확인합니다.
c_str()이 돌려준 포인터로 글자를 쓰면 안 됩니다. C++17부터는 const가 아닌 문자열의 data()가 쓰기 가능한 char*를 돌려주지만, 이때도 size() 범위 안의 글자만 바꿀 수 있고 끝의 널 문자는 건드리지 않습니다. 길이가 바뀌는 결과를 써 주는 C API라면, 호출 전에 resize()로 공간을 마련하고 호출 뒤 실제 길이에 맞게 다시 줄이는 식으로 따로 처리합니다.
정리
std::string은 글자의 나열을 값으로 소유하며,size()는 글자 수,capacity()는 재할당 없이 담을 수 있는 수이고, 끝의 널 문자는size()에 들어가지 않습니다.- 길이는
char단위이므로 UTF-8의 한글 한 글자는 3으로 셉니다. =,+,+=,==,<를 기본 타입처럼 쓰지만,+의 두 피연산자가 모두 문자열 리터럴이면 컴파일 오류입니다.[]는 범위를 검사하지 않고,at()은 범위 밖에서std::out_of_range를 던지며,find의 결과는npos인지 먼저 확인합니다.- 공백이 있는 한 줄은
std::getline으로 읽고,>>뒤에 남은 줄바꿈은std::ws나ignore()로 처리합니다. c_str()의 포인터는 문자열이 바뀌거나 사라지면 쓸 수 없으므로 보관하지 않습니다.
연습 문제
1. 길이를 주지 않은 생성자
string_from_bytes.cpp의 생성자만 std::string packet(raw);로 바꾸면 만든 직후의 size()는 무엇이고, 이어지는 packet.at(2) = 'C';는 어떻게 되나요?
해설 보기
길이를 받지 않는 생성자는 첫 널 문자 앞의 A만 복사하므로 size()는 1입니다. 인덱스 2는 범위 밖이어서 at(2)가 std::out_of_range 예외를 던지고, 예외를 잡지 않았으므로 출력 문장에 도달하지 못한 채 프로그램이 끝납니다.
2. 남은 줄바꿈
read_name.cpp에서 std::cin >> std::ws를 빼고 std::getline(std::cin, name);으로 바꾸면, 같은 두 줄 입력에 무엇이 출력되나요?
해설 보기
std::cin >> age가 30 뒤의 줄바꿈을 남기므로, getline은 그 줄바꿈 앞의 빈 줄을 읽어 name이 빈 문자열이 됩니다. 출력은 (30)이고, 둘째 줄의 Kim Min은 읽히지 않은 채 남습니다.
3. 컴파일되는 연결
std::string s = "C";일 때 "A" + "B", s + "!" + "?", "!" + "?" + s 가운데 컴파일되는 것은 무엇인가요?
해설 보기
s + "!" + "?"만 컴파일됩니다. +는 왼쪽부터 묶이므로 (s + "!") + "?"가 되어 두 단계 모두 std::string이 있고, 결과는 "C!?"입니다. "A" + "B"와 "!" + "?" + s는 먼저 두 리터럴끼리 더하게 되어 컴파일 오류이며, std::string("!") + "?" + s처럼 첫 피연산자를 std::string으로 만들면 됩니다.
이것으로 5장의 배열과 문자열을 마칩니다. 다음 장의 함수의 정의와 호출에서는 지금까지 main 안에 모두 적었던 코드를 이름 있는 단위로 나누고, 값을 넘겨 결과를 돌려받는 함수를 다룹니다.