C++ 문자열과 string_view - UTF-8 바이트 길이와 수명 함정 (C++ 객체와 자원 관리 8장)
이 장에서 배우는 것
std::string은 문자들의 메모리를 소유하는 RAII 객체다. C의 char*와 달리 크기를 알고, 늘어나면 스스로 메모리를 다시 얻고, 소멸할 때 해제한다. C++17에서 추가된 std::string_view는 반대로 소유하지 않는 문자열이다. 포인터와 길이 두 개만 들고 남의 문자열을 들여다본다. 복사가 없어 빠르지만, 들여다보는 대상이 먼저 사라지면 매달린 view가 된다.
- UTF-8 한글 문자열의
size()가 글자 수가 아니라 바이트 수라는 것을 확인한다. string_view로 복사 없이 문자열을 나누고, 매개변수로 받는다.string_view를 C 함수(printf,fopen)에 넘길 때의 널 종료 문제를 본다.- 임시 문자열, 지역 문자열, 멤버로 저장한 view가 만드는 세 가지 수명 버그를 컴파일러 경고와 실행 결과로 확인한다.
문제 상황
로봇 설정 파일에 motor_left,motor_right,imu,gps처럼 장치 이름이 쉼표로 이어져 있다. 이를 나누는 함수를 std::vector<std::string>으로 짜면 조각마다 새 문자열을 할당하고 복사한다. 설정 파일을 수천 줄 읽는 부팅 과정이나, 매 프레임 명령 문자열을 해석하는 게임 콘솔이라면 이 할당이 부담이 된다. string_view로 바꾸면 할당이 사라진다. 그런데 팀원이 같은 방식으로 "관절 이름을 view로 저장하는 클래스"를 만들었더니 이름이 깨져 나오기 시작했다.
완성 코드
strings.cpp로 저장한다.
#include <cstdio>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
std::vector<std::string_view> split(std::string_view text, char sep) {
std::vector<std::string_view> parts;
while (true) {
auto pos = text.find(sep);
parts.push_back(text.substr(0, pos));
if (pos == std::string_view::npos) break;
text.remove_prefix(pos + 1);
}
return parts;
}
bool is_motor(std::string_view name) { return name.starts_with("motor_"); }
int main() {
std::string word = "로봇";
std::cout << "1) \"로봇\".size() = " << word.size() << " (바이트 수)\n";
std::string line = "motor_left,motor_right,imu,gps";
auto parts = split(line, ',');
std::cout << "2) split 결과 " << parts.size() << "개:";
for (auto p : parts) std::cout << " [" << p << "]";
std::cout << '\n';
std::cout << " 첫 조각이 원본 안을 가리키나: " << std::boolalpha
<< (parts[0].data() == line.data()) << '\n';
std::cout << "3) is_motor: " << is_motor(parts[1]) << ", " << is_motor("imu") << '\n';
std::string_view first = parts[0];
std::printf("4) printf(\"%%s\", first.data()) -> %s\n", first.data());
std::printf(" printf(\"%%.*s\") -> %.*s\n", static_cast<int>(first.size()), first.data());
std::string owned(first);
std::printf(" std::string 으로 복사 후 c_str() -> %s\n", owned.c_str());
std::string report;
report.reserve(64);
for (auto p : parts) {
report += p;
report += ';';
}
std::cout << "5) " << report << " (capacity " << (report.capacity() >= 64 ? ">= 64" : "< 64") << ")\n";
}
줄별 해설
word.size() — 바이트 수
std::string은 바이트를 담는 상자다. 소스 파일이 UTF-8이면 "로봇"은 한 글자에 3바이트씩 6바이트로 저장되고, size()도 6을 돌려준다. 글자 수로 자르거나 화면 폭을 계산할 때 이 차이를 잊으면 한글이 반으로 잘린다. 표준 라이브러리에는 UTF-8 글자 단위로 다루는 기능이 거의 없어서, 실무에서는 ICU 같은 라이브러리나 직접 만든 디코더를 쓴다.
split(std::string_view text, char sep)
string_view는 복사가 싸므로(포인터 + 길이) 값으로 받는다. find로 구분자 위치를 찾고, substr로 앞 조각을 잘라 결과에 넣고, remove_prefix로 처리한 부분을 앞에서 떼어 낸다. string_view::substr은 새 문자열을 만들지 않고 같은 메모리의 다른 구간을 가리키는 view를 돌려준다. std::string::substr이 복사본을 만드는 것과 다르다. 결과의 첫 조각 주소가 원본 line의 주소와 같은 것(true)이 그 증거다.
bool is_motor(std::string_view name)
읽기만 하는 문자열 매개변수는 const std::string& 대신 std::string_view로 받을 수 있다. std::string, 문자열 리터럴, 다른 view를 모두 복사 없이 받는다. const std::string&에 리터럴을 넘기면 임시 std::string이 만들어지는데(1장 연습 3), view는 그것도 없다. starts_with는 C++20에 추가되었다.
printf("%s", first.data()) — 널 종료가 없다
%s는 널 문자('\0')를 만날 때까지 읽는다. first는 motor_left 열 글자만 가리키지만, 그 뒤에 널이 있는 것이 아니라 원본의 ,motor_right,...가 이어진다. 그래서 원본 끝까지 출력되었다. 원본이 std::string이라 끝에 널이 있었기에 망정이지, 버퍼 중간을 가리키는 view였다면 버퍼 밖까지 읽는다. 해결은 두 가지다. %.*s로 길이를 함께 넘기거나, std::string(first)로 복사해 c_str()을 넘긴다. fopen, open, 대부분의 C 라이브러리처럼 길이를 받지 않는 함수에는 후자밖에 없다.
report.reserve(64)
6장의 vector와 같다. 문자열을 계속 이어 붙이면 여러 번 재할당된다. 대략의 크기를 알면 미리 예약한다.
실제 실행 결과
$ clang++ -std=c++20 -Wall -Wextra strings.cpp -o strings && ./strings
1) "로봇".size() = 6 (바이트 수)
2) split 결과 4개: [motor_left] [motor_right] [imu] [gps]
첫 조각이 원본 안을 가리키나: true
3) is_motor: true, false
4) printf("%s", first.data()) -> motor_left,motor_right,imu,gps
printf("%.*s") -> motor_left
std::string 으로 복사 후 c_str() -> motor_left
5) motor_left;motor_right;imu;gps; (capacity >= 64)
4번 줄이 널 종료 문제다. 첫 printf는 view가 가리키는 열 글자를 넘어 원본 끝까지 출력했다.
수명 버그 1, 2: 컴파일러가 잡아 주는 경우
#include <iostream>
#include <string>
#include <string_view>
std::string make_label(int id) { return "joint_controller_" + std::to_string(id); }
std::string_view label_view(int id) {
std::string label = make_label(id);
return label;
}
int main() {
std::string_view a = make_label(7);
std::string_view b = label_view(8);
std::cout << a.size() << ' ' << b.size() << '\n';
}
$ clang++ -std=c++20 -Wall -Wextra -c sv_dangling.cpp
sv_dangling.cpp:9:12: warning: address of stack memory associated with local variable 'label' returned [-Wreturn-stack-address]
9 | return label;
| ^~~~~
sv_dangling.cpp:13:26: warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]
13 | std::string_view a = make_label(7);
| ^~~~~~~~~~~~~
2 warnings generated.
두 경고 모두 실제 버그다.
std::string_view a = make_label(7);—make_label이 돌려준std::string은 이름 없는 임시 객체라서 이 줄의 세미콜론에서 사라진다(2장 표).a는 줄이 끝나자마자 해제된 메모리를 가리킨다. clang은 이것을-Wdangling-gsl로 경고했다. 같은 코드를const std::string& a = make_label(7);로 쓰면 임시 객체의 수명이 참조만큼 늘어나 안전한데, view에는 이 규칙이 적용되지 않는다.label_view— 지역std::string을 view로 돌려준다. 1장의 "지역 변수의 참조 돌려주기"와 똑같은 버그다.
수명 버그 3: 컴파일러가 못 잡는 경우
view를 클래스 멤버로 저장하면 경고가 나오지 않는다.
#include <iostream>
#include <string>
#include <string_view>
class Joint {
public:
explicit Joint(std::string_view name) : name_(name) {}
std::string_view name() const { return name_; }
private:
std::string_view name_;
};
Joint make_joint(int id) {
std::string name = "shoulder_pitch_joint_" + std::to_string(id);
return Joint(name);
}
int main() {
Joint j = make_joint(3);
std::string filler(40, '#');
std::cout << "길이: " << j.name().size() << std::endl;
std::cout << "이름이 그대로인가: " << (j.name() == "shoulder_pitch_joint_3" ? "예" : "아니오") << std::endl;
}
$ clang++ -std=c++20 -Wall -Wextra sv_member.cpp -o sv_member && ./sv_member
길이: 22
이름이 그대로인가: 아니오
경고 없이 컴파일되었고 길이는 22로 맞게 나왔지만, 내용 비교는 실패했다. Joint는 make_joint의 지역 문자열을 가리키는 view를 저장했고, 함수가 끝나며 그 문자열이 사라졌다. main의 filler는 사라진 문자열이 있던 스택 자리를 다른 데이터로 덮게 하려고 일부러 넣은 줄이다. 짧은 문자열(검증 환경의 libc++는 22바이트까지, GCC의 libstdc++는 15바이트까지)은 힙이 아니라 std::string 객체 안(여기서는 스택)에 저장되기 때문에 이렇게 쉽게 덮인다. view가 들고 있는 길이(22)는 멀쩡하니 길이만 검사하는 테스트는 통과한다. 이 버그가 무서운 이유다.
표 8-1. 문자열 타입 선택
| 쓰임 | 권장 타입 |
|---|---|
| 읽기 전용 매개변수 | std::string_view(값으로) |
| 함수 안에서 잠깐 자르고 비교하기 | std::string_view |
| 클래스 멤버, 컨테이너 원소, 오래 보관 | std::string(소유) |
| 반환값 | 대개 std::string. view는 인자로 받은 문자열의 일부를 돌려줄 때만 |
| C 함수에 넘기기 | std::string의 c_str() |
표 8-2. std::string 과 std::string_view
| 성질 | std::string | std::string_view |
|---|---|---|
| 메모리 소유 | 소유(소멸 때 해제) | 소유하지 않음 |
| 복사 비용 | 글자 수만큼 | 포인터+길이 |
substr | 새 문자열 | 같은 버퍼의 다른 구간 |
| 널 종료 | 보장(c_str()) | 보장 안 됨 |
| 수정 | 가능 | 불가(보기만) |
실무에서 자주 틀리는 것
1. string_view를 멤버로 저장한다
위의 버그 3이다. 설정 파일 전체를 메모리에 올려 두고 프로그램 끝까지 해제하지 않는 것처럼 원본의 수명이 확실히 더 길 때만 view를 저장한다. 그렇지 않으면 std::string으로 복사해 소유한다.
2. view를 만든 뒤 원본 문자열을 늘린다
std::string s = "abc"; std::string_view v = s; s += 긴_문자열; — s가 재할당되면 v는 옛 버퍼를 가리킨다. 6장의 vector 무효화와 같은 원리다.
3. data()를 C 문자열로 믿는다
string_view::data()는 널 종료를 보장하지 않는다. std::string::data()와 c_str()은 C++11부터 널 종료를 보장한다. 둘을 헷갈리면 위의 4번 출력 같은 일이 생긴다.
4. size()를 글자 수로 쓴다
한글 이름을 10글자로 자르려고 substr(0, 10)을 하면 10바이트, 즉 세 글자와 네 번째 글자의 앞 1바이트가 남아 깨진 문자가 출력된다.
연습 문제
std::string s = "모터A";의s.size()는 몇인가? (소스 파일은 UTF-8)std::string base = "arm_left_joint"; std::string_view v = base; auto sub = v.substr(4, 4);에서sub의 내용과sub.data()가 가리키는 곳은? 같은 일을base.substr(4, 4)로 하면 무엇이 다른가?std::string grow = "abc"; std::string_view gv = grow;다음grow += std::string(100, 'x');를 했다.gv.data()는 어디를 가리키는가?gv를 읽어도 되는가?
정답
- 7이다. "모"와 "터"가 각각 3바이트, "A"가 1바이트다. 검증 프로그램에서 확인했다.
sub는"left"이고,sub.data()는base.data() + 4, 즉 원본 문자열 안을 가리킨다. 복사가 없다.base.substr(4, 4)는"left"를 담은 새std::string을 만든다(짧은 문자열은 객체 안에 저장되어 힙 할당이 없을 수도 있지만 복사는 일어난다). 검증 프로그램에서 주소가 같음을 확인했다.grow가 103바이트로 늘어나면서 새 버퍼로 옮겨 갔고,gv.data()는 여전히 옛 버퍼를 가리킨다. 검증 프로그램에서grow.data()는 바뀌고gv.data()는 옛 주소 그대로인 것을 확인했다. 옛 버퍼는 이미 해제되었을 수 있으므로gv를 읽으면 안 된다. 원본을 바꾼 뒤에는 view를 다시 만든다.
여기까지 참조, 컨테이너, 람다, view에서 같은 모양의 수명 버그를 반복해서 봤다. 다음 장에서는 방향을 바꿔, 오류가 났을 때 자원을 잃지 않고 빠져나오는 방법인 예외와 예외 안전성을 다룬다.