이 글은 C++20과 C++23에서 Peter Sommerlad의 제안 P0408R7과 P0448R4를 통해 std::basic_stringbuf의 무복사 접근과 새로운 <spanstream> 헤더를 소개하고, 고정 크기 사전 할당 버퍼를 사용하는 spanstream이 어떻게 strstream을 대체하는지 설명합니다.
이 글의 주된 목표는 새로운 <spanstream> 헤더를 소개하는 것이지만, 거기서 조금 더 나아가 보려고 합니다. 이 헤더를 도입한 제안서(P0448R4)의 배경만 다루는 것이 아니라, C++20에서 std::basic_stringbuf를 수정했던 Peter Sommerlad의 더 오래된 제안서(P0408R7)도 살펴볼 것입니다. 둘 다 다루는 이유는, 저자가 두 제안서로 겨냥한 목적이 동일하고 둘이 서로를 보완하기 때문입니다.
스트림은 C++ 표준 라이브러리에서 가장 오래된 구성요소 중 하나지만, C++11 이후 변화의 바람을 충분히 따라가지 못했고, 그래서 몇 가지 업데이트가 필요했습니다.
이 두 제안의 동기는 불필요한 버퍼 복사를 피하는 스트림을 제공하는 데 있습니다. 다시 말해, 다음을 목표로 하는 스트림이었습니다.
문제 중 하나는 P0408R7 이전까지 std::basic_stringbuf의 내부 버퍼에 복사 없이 접근할 방법이 없었다는 점입니다. 그래서 ostringstream의 결과를 얻으려면, 이후에 그 스트림을 더 이상 사용하지 않더라도 내부 버퍼를 항상 복사해야 했습니다. P0408R7이 이 문제를 해결했습니다.
또 다른 문서인 P0448R4가 채택됨에 따라, _spanstream_은 내부 저장소로 고정 크기의 사전 할당된 버퍼를 사용할 수 있는 스트림을 제공합니다. 예를 들어 스택에 있는 std::span<T> 같은 비소유 배열 뷰가 될 수 있습니다.
std::span<T>를 사용하면 새로운 _spanstream_에 버퍼를 제공하고, 사용 사례에 따라 받아들일 수 없을 수도 있는 동적 (재)할당을 피할 수 있습니다.
P0408R7에서 저자는 기능이 완전히 대체되는 즉시 basic_strbuf를 표준의 [depr.str.strstreams] 섹션에서 제거해야 한다는 견해를 밝힙니다.
strstream이 처음에 폐기(deprecate)된 이유는char*를 반환했기 때문인데, 그 포인터의 관리가 어렵고 따라서 메모리 누수를 유발하기 쉬웠기 때문입니다. 어디서 어떻게 할당되었는지 명시되어 있지 않다는 점이 어려움의 원인이었습니다. 만족스러운 해제 방법은std::strstream::freeze()함수를 통해서뿐이었는데, 이것이 그다지 명확하지 않아 많은 사람들이 실수하곤 했습니다. 반면에stringstream은 자체 메모리 할당을 관리하는std::string을 반환합니다.
그 제거를 향한 첫걸음은 basic_stringbuf의 API를 확장하는 것이었습니다(여기서 basic_strbuf와 basic_stringbuf의 차이에 주의하세요). std::string_view는 연속된 문자 시퀀스에 효율적인 읽기 전용 접근을 제공하기 위해 C++17에 도입되었습니다. basic_stringbuf도 유사한 특성을 가지므로, 그 내부 버퍼에 string_view와 유사한 접근을 제공하는 것은 자연스럽고 오랫동안 기다려온 발전이었습니다.
P0408R7는 C++20에서 basic_stringbuf에 아래와 같은 변경을 가져왔습니다.
약간 우연히, 할당자 인지(allocator-aware) 생성이 도입되었습니다. 여기서 "우연히"라는 표현을 쓰는 이유는, 이 논문들의 저자가 제게 이것이 원래 의도에 포함되어 있지 않았다고 알려주었기 때문입니다. 하지만 제안이 진행되던 시점에 표준 라이브러리 자체가 상태 있는 할당자와 std::pmr 등으로 할당자 지원을 더 유연하게 만들던 때였습니다.
동시에, basic_stringbuf는 rvalue-reference로 초기 값을 받는 새로운 생성자 오버로드의 혜택도 보게 되었는데, 이것 역시 불필요한 복사를 피하는 데 목적이 있습니다.
basic_stringbuf::str()도 여러 변경을 겪었습니다. 먼저, basic_stringbuf::str()는 반환 타입과 매개변수에 따라 getter이기도 하고 setter이기도 하다는 점을 상기해야 합니다.
getter인 str()은 이제 기본 객체가 lvalue일 때 사용하는 오버로드와 rvalue일 때 사용하는 오버로드가 생겼습니다. rvalue reference에서 str()을 호출하면, 내부 버퍼에서 기본 문자열을 이동시켜 반환합니다. 저자에 따르면, 이는 아마도 표준 라이브러리에서 최초의 ref-qualified 멤버 함수일 것입니다. 더 나아가 이 경우 이동된 객체가 어떤 모습이어야 하는지 표준이 명확히 규정합니다. 그 버퍼는 빈 상태가 됩니다.
str()은 또한 할당자를 받는 오버로드를 추가로 제공하는데, 반환될 문자열로 내부 버퍼를 복사할 때 어떤 할당자를 사용할지 설정합니다. 물론 이것은 이동이 아닌 오버로드에만 해당합니다.
또한 view()라는 새로운 메서드가 추가되었는데, 이는 const이면서 noexcept이고 string_view를 반환하여 내부 버퍼의 내용을 복사 없이, 비소유, 읽기 전용으로 접근할 수 있게 합니다.
1
2
3
4
5
6
basic_string<charT, traits, Allocator> str() const &; // & lvalue 한정자가 새로 추가!
template<class SAlloc>
basic_string<charT,traits,SAlloc> str(const SAlloc& sa) const; // 새로운 오버로드
basic_string<charT, traits, Allocator> str() &&; // 새로운 오버로드
basic_string_view<charT, traits> view() const noexcept; // 새로운 메서드
setter인 str() 메서드들도 두 개의 새로운 오버로드를 받았습니다. 기존 버전은 basic_stringbuf 클래스와 동일한 할당자를 사용하는 문자열을 const&로 받습니다. 새 오버로드 중 하나는 여전히 입력 string을 const&로 받되 다른 할당자를 사용하고, 다른 하나는 string을 rvalue reference로 받아 내부 버퍼로 이동시킵니다.
1
2
3
4
5
void str(const basic_string<charT, traits, Allocator>& s); // 기존에 있던 것
template<class SAlloc>
void str(const basic_string<charT, traits, SAlloc>& s); // 새로운 오버로드
void str(basic_string<charT, traits, Allocator>&& s); // 새로운 오버로드
이 외에도 여러 변경이 있지만, 자세히 들어가지는 않겠습니다. 다만 basic_stringbuf::swap()이 사용된 할당자에 따라 조건부로 noexcept가 되었다는 점은 짚고 넘어갈 가치가 있습니다.
아마 위 변경 중 가장 중요한 것은 이제 실제로 내부 버퍼를 복사하지 않고도 basic_stringbuf의 내용을 얻을 수 있다는 점일 것입니다. str() &&를 사용하면 이동되고, view를 사용하면 읽기 전용 뷰를 얻습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <sstream>
#include <iostream>
int main() {
std::stringbuf buf;
std::string temp {"Some content"};
buf.str(std::move(temp));
// 이제 temp는 이동된 상태. 내부에 뭐가 있을지는 모른다
std::cout << "temp: " << temp << '\n';
// 내부 버퍼에 접근하는 데 복사가 필요 없다
std::string_view bufview = buf.view();
std::cout << "bufview: " << bufview << '\n';
// 여전히 복사 없이 내부 버퍼에 접근. 우리가 넣은 데이터가 남아 있다
std::string_view anotherView = buf.view();
std::cout << "anotherView: " << anotherView << '\n';
// 여전히 복사 없음. buf를 rvalue-reference로 사용하여 내부 버퍼를 이동해 온다
std::string internalBufferMoved = std::move(buf).str();
std::cout << "internalBufferMoved: " << internalBufferMoved << '\n';
// 이제 buf는 이동된 상태. 내부에 뭐가 있을지는 모른다
std::string_view viewOnMovedObject = buf.view();
std::cout << "viewOnMovedObject: " << viewOnMovedObject << '\n';
return 0;
}
/*
temp:
bufview: Some content
anotherView: Some content
internalBufferMoved: Some content
viewOnMovedObject:
*/
위 예제에서 얼마나 효율적으로 뷰와 이동이 사용되어 값비싼 복사 연산을 피하는지 주목해 보세요.
두 번째 제안서 P0448R4는 주로 4개의 클래스 템플릿과 함께 완전히 새로운 헤더 <spanstream>을 도입합니다.
std::basic_spanbufstd::basic_ispanstreamstd::basic_ospanstreamstd::basic_spanstream요컨대, 우리가 얻는 것은 익숙한 3종의 스트림과, 그 스트림들이 사용할 외부 제공 메모리 버퍼입니다. _spanstream_은 내부 버퍼를 소유하지 않습니다. 이름에서 알 수 있듯 _span_은 항목 배열에 대한 비소유 뷰입니다. 따라서 재할당은 불가능합니다. 동적 재할당이 필요하다면 stringstream 등을 사용해야 합니다. stringstream은 C++20부터 그 내용에 복사 없이 접근할 수 있습니다.
이 새로운 헤더와 앞서 설명한 basic_stringbuf 변경의 결과로, 이미 폐기(deprecated)된 strstream 클래스들을 표준에 더 이상 남겨둘 이유가 없어졌고, [depr.str.strstreams] 섹션은 제거됩니다.
예상대로 basic_spanbuf는 내부 버퍼로 어떤 종류의 문자(charT)에 대한 span을 사용합니다. 데이터 복사가 필요 없으므로 여기에 접근 권한을 제공하는 것은 안전하고 비용도 적게 듭니다. 데이터의 소유 복사가 필요하다면, 언제든 span()의 결과를 basic_string<charT>로 되돌려 복사하면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <iostream>
#include <span>
#include <spanstream>
#include <cassert>
void printSpan(auto spanToPrint) {
for (size_t i = 0; i < spanToPrint.size(); ++i) {
std::cout << spanToPrint[i];
}
}
void useSpanbuf() {
std::array<char, 16> charArray;
std::span<char, 16> charArraySpan(charArray);
std::spanbuf buf;
char c = 'a';
for (size_t i = 0; i < 16; ++i) {
charArraySpan[i] = c;
++c;
}
buf.span(charArraySpan);
// 버퍼에서 얻은 span을 손쉽게 출력할 수 있다
std::span bufview = buf.span();
std::cout << "bufview: ";
for (size_t i = 0; i < 16; ++i) {
std::cout << bufview[i];
}
std::cout << '\n';
}
void useSpanstream() {
std::array<char, 16> charArray;
std::ospanstream oss(charArray);
oss << "Fortytwo is " << 42;
// 내용을 span으로 복사
std::string s{oss.span().data(),size_t(oss.span().size())};
assert(s == "Fortytwo is 42");
}
int main() {
useSpanbuf();
useSpanstream();
return 0;
}
이 글에서는 Peter Sommerlad와 그의 두 제안서 P0448R4, P0408R7 덕분에 C++20과 C++23에서 버퍼와 스트림의 세계가 어떻게 변했는지 간략히 살펴보았습니다.
P0408R7 덕분에 std::basic_stringbuf의 내부 버퍼에 복사 없이 접근할 수 있게 되었고, 이는 상당한 효율 향상입니다.
P0448R4이 채택되어 _spanstream_이 도입됨에 따라, 내부 저장소로 고정 크기의 사전 할당된 버퍼를 사용할 수 있는 스트림을 얻게 되었습니다.
글 초안에서 빠진 점과 오해를 지적해 주고 글을 더 알찬 내용으로 만드는 데 도움을 주신 Peter께 특별히 감사드립니다.
이 글이 마음에 드셨다면,