std::string_view는 문자 버퍼를 들여다보는 창이다. 포인터와 길이, 딱 두 개로 이루어져 있고 문자 데이터를 소유하지 않는다. 그래서 복사해도 얕은 복사고, const std::string&나 const char*를 받던 자리에 놓으면 불필요한 문자열 복사가 사라진다.
여기까지는 잘 알려져 있다. 정작 필요한 건 세 가지다. std::string을 쓰는 것과 기계 수준에서 뭐가 다른지, 어떤 문법이 어느 표준부터 되는지, 그리고 소유하지 않는다는 말의 대가가 무엇인지다.
const std::string&과 무엇이 다른가
읽기만 하는 문자열 매개변수를 받는 방법은 셋이다.
void TakesCharStar(const char* s);
void TakesString(const std::string& s);
void TakesStringView(std::string_view s);호출하는 쪽이 이미 그 타입을 갖고 있으면 셋 다 공짜다. 문제는 타입이 어긋날 때 생긴다. 실무에서 문자열은 리터럴로, const char*로, std::string으로 제각각 들어온다.
const char*를 const std::string& 매개변수에 넘기면 어떻게 되는지, 컴파일러가 실제로 뭘 하는지 보면 확실하다. g++ 13.2 -O2로 두 함수를 컴파일해 어셈블리를 비교하면 이렇다.
void sink_string(const std::string&);
void sink_string_view(std::string_view);
void via_string(const char* p) { sink_string(p); }
void via_string_view(const char* p) { sink_string_view(p); }먼저 via_string_view다. 어셈블리 지시자를 걷어낸 전문이 이것뿐이다.
via_string_view(char const*):
pushq %rbx
subq $48, %rsp
movq %rcx, %rbx
call strlen # 길이를 잰다
leaq 32(%rsp), %rcx
movq %rbx, 40(%rsp) # 스택에 {포인터,
movq %rax, 32(%rsp) # 길이} 를 놓고
call sink_string_view # 넘긴다
nop
addq $48, %rsp
popq %rbx
ret길이를 재고, 포인터와 길이를 나란히 놓고, 호출한다. 분기도 할당도 소멸자도 없다. 끝이다.
via_string은 같은 처리에 112줄이 나온다. 전부 싣기엔 길어서 라벨·분기·호출만 남기면 이렇다.
via_string(char const*):
testq %rcx, %rcx
je .L16 # 널이면 예외
call strlen
cmpq $15, %rax # ← SSO 한계. 길이가 15를 넘는가?
ja .L17 # 넘으면 힙 경로로
cmpq $1, %rax
je .L18
testq %rax, %rax
jne .L19
.L6:
call sink_string
cmpq %rdi, %rcx
je .L1 # SSO였으면 해제를 건너뛴다
call operator delete
.L1:
ret
.L17:
call operator new # ← 힙 할당. 이 경로에만 있다
.L4:
call memcpy # ← 복사는 양쪽 경로가 공유한다
jmp .L6
.L19:
jmp .L4 # SSO 경로도 .L4의 memcpy로 들어간다
.L16:
call __throw_logic_error
via_string.cold: # 예외 되감기용 별도 섹션
call operator delete
call _Unwind_Resume읽을 곳은 세 군데다.
cmpq $15, %rax. SSO 한계가 어셈블리에 즉치 상수로 박혀 있다. 이 비교 하나가 뒤에 나올 측정값을 통째로 설명한다.
ja .L17 너머에만 operator new가 있다. 할당은 무조건 일어나는 게 아니라 저 분기를 탔을 때만이다. 해제도 je .L1 뒤라 SSO면 건너뛴다.
.L19와 .L17이 둘 다 .L4로 들어간다. 즉 memcpy는 양쪽 경로가 공유한다. SSO라고 복사를 안 하는 게 아니라 힙 대신 객체 안 버퍼로 복사할 뿐이다. string_view가 아끼는 건 복사와 할당 둘 다지만, std::string 안에서 SSO가 아끼는 건 할당뿐이다.
위는 g++ 13.2,
-O2, MinGW-W64 x64 기준이다. 첫 인자가%rcx인 건 Windows x64 ABI라서고, System V(리눅스)면%rdi다. 라벨 순서도 텍스트 배치일 뿐 실행 순서가 아니다 — 컴파일러가 자주 도는 경로를 앞에, 힙·예외 경로를 뒤나.cold섹션으로 뺀다.
크기도 다르다.
sizeof(std::string) = 32 (포인터 + 길이 + 용량/SSO 버퍼)
sizeof(std::string_view) = 16 (포인터 + 길이)
std::string이 32바이트인 건 짧은 문자열을 담을 버퍼를 자기 안에 갖고 있기 때문이다. 이게 **SSO(Small String Optimization)**고, libstdc++에서 한계는 15자다. 15자 이하면 힙에 안 가고 객체 안에 들어간다.
그래서 얼마나 차이 나는가
SSO 때문에 답이 하나가 아니다. 500만 번 호출로 재봤다(g++ 13.2, -O2, 함수는 별도 번역 단위에 두어 인라인·제거를 막았다).
| 인자 길이 | const std::string& | string_view |
|---|---|---|
| 5자 (SSO) | ~12ms | ~7ms |
| 15자 (SSO 경계) | ~15ms | ~8ms |
| 48자 (힙 할당) | ~127ms | ~13ms |
앞에서 본 분기가 그대로 숫자로 나타난다. 극적인 차이는 operator new가 걸리는 쪽으로 분기할 때만 난다. 15자 이하면 SSO 버퍼에 담기니 할당이 없고, 그래서 2배 남짓에 그친다. 그 선을 넘는 순간 할당과 해제가 붙으면서 10배로 벌어진다. 복사는 양쪽 다 하므로 이 차이의 원인이 아니다.
“const std::string&은 비효율적이다”는 말은 그래서 반쪽이다. 정확히는 SSO를 넘는 문자열이 변환을 거쳐 들어올 때 비싸다.
물론 이건 호출만 500만 번 돌린 극단적인 측정이다. 함수 본체가 하는 일이 있으면 비중은 줄어든다. 요점은 배수가 아니라 어디서 비용이 생기는지다 — 할당과 복사이고, string_view는 그 둘을 없앤다.
언제 쓰고 언제 쓰지 않는가
| 상황 | 선택 | 이유 |
|---|---|---|
| 읽기만 하는 함수 매개변수 | string_view | 호출자가 어떤 타입을 갖고 있든 변환 비용이 없다 |
| 문자열을 보관·수정해야 함 | std::string | 소유해야 한다 |
| 멤버 변수 | 대체로 std::string | 원본이 더 오래 산다는 근거가 있을 때만 string_view |
| C API에 넘겨야 함 | std::string의 c_str() | string_view는 NUL 종료를 보장하지 않는다 |
| 반환 타입 | 주의 | 무엇을 가리키는지 호출자가 알 수 있어야 한다 |
string_view는 작은 값 타입이다. int를 넘기듯 값으로 넘긴다. 참조로 받을 이유가 없다.
기존 코드베이스에 밀어 넣을 때는 조심할 게 있다. 매개변수를 string_view로 바꿨는데 그 값이 다시 std::string이나 NUL 종료 const char*를 요구하는 함수로 넘어간다면, 거기서 도로 변환이 일어난다. 이득이 없거나 오히려 손해다. 유틸리티 쪽부터 시작해 위로 올라가는 편이 낫다.
문법과 표준 버전
string_view는 C++17에 들어왔지만 그 뒤로도 계속 자랐다. 지금 쓸 수 있는 것과 아직 못 쓰는 것을 구분해두면 편하다.
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_string_view;타입 별칭은 string_view(char), wstring_view, u16string_view, u32string_view가 C++17이고, u8string_view만 C++20이다. char8_t가 C++20에 생겼기 때문이다.
생성자
| 생성자 | 표준 | 비고 |
|---|---|---|
basic_string_view() | C++17 | 기본 생성 |
basic_string_view(const basic_string_view&) | C++17 | 복사 |
basic_string_view(const CharT* s, size_type count) | C++17 | 길이를 직접 준다 |
basic_string_view(const CharT* s) | C++17 | strlen을 부르므로 O(n) |
basic_string_view(It first, End last) | C++20 | 반복자 쌍 |
explicit basic_string_view(R&& r) | C++23 | 범위에서 직접 |
basic_string_view(std::nullptr_t) = delete | C++23 | 삭제됨 |
std::string에서는 왜 공짜인가
여기서 헷갈리기 쉬운 게 있다. std::string을 string_view 자리에 그냥 넘길 수 있는데, 위 표에는 std::string을 받는 생성자가 없다. 방향이 반대이기 때문이다. string_view가 string을 받는 게 아니라, std::string 쪽이 변환 연산자를 갖고 있다.
// std::basic_string의 멤버 (C++17, constexpr since C++20)
operator std::basic_string_view<CharT, Traits>() const noexcept;하는 일은 basic_string_view(data(), size())와 같다. 그러면 왜 O(1)인지가 저절로 풀린다. std::string은 자기 길이를 이미 멤버로 들고 있다. 포인터와 길이 둘 다 이미 손에 있으니 꺼내 주기만 하면 된다.
반대로 const char*는 길이를 갖고 다니지 않는다. 그래서 basic_string_view(const CharT* s) 생성자가 Traits::length(s)(사실상 strlen)를 불러 NUL이 나올 때까지 훑는다. 이게 O(n)의 정체다.
즉 비대칭의 원인은 이동이냐 복사냐가 아니다. 누가 길이를 이미 알고 있느냐다. 어느 쪽이든 문자 데이터는 건드리지 않는다 — “복사가 없다”는 말은 문자열 내용을 복사하지 않는다는 뜻이지, 포인터와 길이(합쳐 16바이트 남짓)는 당연히 복사한다.
길이를 이미 알고 있다면 두 인자 생성자로 strlen을 건너뛸 수 있다.
const char* buf = /* ... */;
std::string_view sv(buf, len); // strlen을 부르지 않는다C++23에서 사라진 생성자
nullptr로 만드는 건 C++23에서 아예 삭제됐다. 그전에는 std::string_view sv = nullptr;이 컴파일은 되고 런타임에 터졌다. 이제는 컴파일이 안 된다. C++23으로 올리면 조용히 잡히는 버그가 하나 있는 셈이다.
표준별로 추가된 멤버 함수
| 멤버 | 표준 |
|---|---|
starts_with(), ends_with() | C++20 |
contains() | C++23 |
subview() | C++26 |
operator<=>와 추론 가이드도 C++20이다. 나머지(substr, remove_prefix, remove_suffix, find 계열, data, size)는 처음부터 있었다.
remove_prefix와 remove_suffix가 string_view다운 물건이다. 창의 시작과 끝을 옮길 뿐이라 문자열을 건드리지 않는다. 파싱할 때 앞에서부터 잘라먹기 좋다.
소유하지 않는다는 말의 대가
string_view가 가리키는 문자열은 string_view보다 오래 살아야 한다. 이 한 줄이 전부인데, 지키기가 생각보다 어렵다.
std::string_view sv = std::string("hello"); // 임시가 이 줄 끝에서 소멸한다
// sv는 이미 죽은 메모리를 가리킨다임시 std::string은 그 문장이 끝나면 사라진다. sv는 남는다. 다음 줄부터 sv를 읽으면 미정의 동작이다.
반환도 마찬가지다.
std::string_view Bad() {
std::string s = "world";
return s; // s는 함수를 나가며 소멸한다
}그래서 멤버 변수로 string_view를 두는 건 대체로 의심해봐야 한다. 객체가 살아 있는 동안 그 문자열이 계속 살아 있다는 근거가 있어야 하는데, 대개는 없다.
struct TestScore {
std::string_view username; // 이 구조체가 살아 있는 동안 원본이 살아 있는가?
double score;
};나중에 쓰거나 고쳐야 한다면 그냥 std::string으로 복사한다. std::string(sv)면 된다.
한 가지 더. string_view는 NUL로 끝난다는 보장이 없다. remove_suffix로 끝을 잘라낸 창을 생각해보면 당연하다. 그래서 이건 하면 안 된다.
printf("%s\n", sv.data()); // sv.data()는 NUL로 끝나지 않을 수 있다컴파일러는 생각보다 안 잡아준다
여기가 함정이다. string_view는 참조처럼 생겼지만 참조가 받는 안전망을 못 받는다.
g++ 13.2로 직접 확인해봤다. 먼저 진짜 참조를 지역 변수에 물리면:
const std::string& Ref() {
std::string s = "x";
return s;
}warning: reference to local variable 's' returned [-Wreturn-local-addr]
잘 잡는다. 그런데 똑같은 실수를 string_view로 하면:
std::string_view Immediate() {
std::string_view sv = std::string("hello");
return sv;
}
std::string_view ReturnLocal() {
std::string s = "world";
return s;
}g++ -std=c++20 -Wall -Wextra -Wdangling-reference -Wdangling-pointer=2 -O2 -c t.cpp아무 경고도 나오지 않는다. 켤 수 있는 걸 다 켰는데도 조용하다. 컴파일러 입장에서 string_view는 그냥 값 타입이고, 그 안에 든 포인터가 어디를 가리키는지는 알 바가 아니다. 참조였다면 문법 차원에서 보이는 게, string_view에서는 라이브러리 타입 안으로 숨어버린다.
clang은 이 부류를 위해 -Wdangling-gsl을 갖고 있다(-Wdangling 그룹). 표준 라이브러리가 string_view 같은 타입에 [[gsl::Pointer]]를 붙여두고, 컴파일러가 그 표시를 보고 진단하는 방식이다. 다만 이 글을 쓰며 확인한 환경에 clang이 없어 직접 돌려보지는 못했다. gcc 쪽 결과만 실제로 확인한 것이다.
결론은 단순하다. 수명은 컴파일러가 아니라 사람이 지켜야 한다. 적어도 gcc에서는 그렇다.
정리
string_view는 포인터 + 길이(16바이트)다. 값으로 넘긴다.const를 붙여도 가리키는 문자는 어차피 못 고친다.const std::string&매개변수에const char*를 넘기면operator new+memcpy+operator delete가 붙는다.string_view는 그게 없다. 단 SSO(libstdc++ 15자) 안에서는 힙 할당이 없어 차이가 작다 — 비용은 할당에서 온다.std::string에서 오는 변환이 O(1)인 건string이 길이를 이미 알기 때문이다.const char*가 O(n)인 건 모르기 때문이다. 길이를 안다면 두 인자 생성자를 쓴다.- C++23부터
nullptr로는 못 만든다. - 수명은 컴파일러가 안 지켜준다. 적어도 gcc는 아무 말도 안 한다.
- 저장할 거면
std::string으로 복사한다. 멤버 변수string_view는 근거가 있을 때만.
참고
- Abseil Tip of the Week #1: string_view —
string_view를 값으로 넘기는 이유, 수명이 왜 문제가 되는지, 기존 코드베이스에 유틸리티부터 점진 도입하는 전략을 참고했다. - cppreference — std::basic_string_view — 생성자·멤버 함수의 시그니처와 각각이 도입된 표준 버전을 확인했다.
- cppreference — std::basic_string::operator basic_string_view — 변환이
basic_string_view(data(), size())로 이루어진다는 것을 확인했다. - GCC Warning Options —
-Wdangling-pointer=2가-Wall에,-Wdangling-reference가-Wextra에 포함된다는 것을 확인했다. - Clang Diagnostic flags —
-Wdangling-gsl이-Wdangling그룹에 속한다는 것을 확인했다.