Devin.KR

C++ 문자열과 string_view - UTF-8 바이트 길이와 수명 함정 (C++ 객체와 자원 관리 8장)

개발자 조회 1

이 장에서 배우는 것

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";
}

std::string line 이 문자 버퍼를 소유하고, split 이 돌려준 string_view 네 개는 각각 그 버퍼 안의 시작 위치와 길이만 가진다. 새 문자열은 하나도 만들어지지 않는다.

줄별 해설

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')를 만날 때까지 읽는다. firstmotor_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가 가리키는 열 글자를 넘어 원본 끝까지 출력했다.

view 0 은 앞 10바이트만 가리키지만 printf 의 %s 는 널 문자를 만날 때까지 읽으므로 원본 끝까지 출력한다. %.*s 로 길이를 넘기거나 std::string 으로 복사해야 한다.

수명 버그 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장의 "지역 변수의 참조 돌려주기"와 똑같은 버그다.

std::string_view a = make_label(7); 에서 임시 문자열은 세미콜론에서 사라지고 a 는 해제된 버퍼를 가리킨다. const std::string& 로 받으면 임시 객체의 수명이 참조만큼 늘어난다.

수명 버그 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로 맞게 나왔지만, 내용 비교는 실패했다. Jointmake_joint의 지역 문자열을 가리키는 view를 저장했고, 함수가 끝나며 그 문자열이 사라졌다. mainfiller는 사라진 문자열이 있던 스택 자리를 다른 데이터로 덮게 하려고 일부러 넣은 줄이다. 짧은 문자열(검증 환경의 libc++는 22바이트까지, GCC의 libstdc++는 15바이트까지)은 힙이 아니라 std::string 객체 안(여기서는 스택)에 저장되기 때문에 이렇게 쉽게 덮인다. view가 들고 있는 길이(22)는 멀쩡하니 길이만 검사하는 테스트는 통과한다. 이 버그가 무서운 이유다.

표 8-1. 문자열 타입 선택

쓰임권장 타입
읽기 전용 매개변수std::string_view(값으로)
함수 안에서 잠깐 자르고 비교하기std::string_view
클래스 멤버, 컨테이너 원소, 오래 보관std::string(소유)
반환값대개 std::string. view는 인자로 받은 문자열의 일부를 돌려줄 때만
C 함수에 넘기기std::stringc_str()

표 8-2. std::string 과 std::string_view

성질std::stringstd::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바이트가 남아 깨진 문자가 출력된다.

연습 문제

  1. std::string s = "모터A";s.size()는 몇인가? (소스 파일은 UTF-8)
  2. std::string base = "arm_left_joint"; std::string_view v = base; auto sub = v.substr(4, 4);에서 sub의 내용과 sub.data()가 가리키는 곳은? 같은 일을 base.substr(4, 4)로 하면 무엇이 다른가?
  3. std::string grow = "abc"; std::string_view gv = grow; 다음 grow += std::string(100, 'x');를 했다. gv.data()는 어디를 가리키는가? gv를 읽어도 되는가?

정답

  1. 7이다. "모"와 "터"가 각각 3바이트, "A"가 1바이트다. 검증 프로그램에서 확인했다.
  2. sub"left"이고, sub.data()base.data() + 4, 즉 원본 문자열 안을 가리킨다. 복사가 없다. base.substr(4, 4)"left"를 담은 std::string을 만든다(짧은 문자열은 객체 안에 저장되어 힙 할당이 없을 수도 있지만 복사는 일어난다). 검증 프로그램에서 주소가 같음을 확인했다.
  3. grow가 103바이트로 늘어나면서 새 버퍼로 옮겨 갔고, gv.data()는 여전히 옛 버퍼를 가리킨다. 검증 프로그램에서 grow.data()는 바뀌고 gv.data()는 옛 주소 그대로인 것을 확인했다. 옛 버퍼는 이미 해제되었을 수 있으므로 gv를 읽으면 안 된다. 원본을 바꾼 뒤에는 view를 다시 만든다.

여기까지 참조, 컨테이너, 람다, view에서 같은 모양의 수명 버그를 반복해서 봤다. 다음 장에서는 방향을 바꿔, 오류가 났을 때 자원을 잃지 않고 빠져나오는 방법인 예외와 예외 안전성을 다룬다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.