Understanding C++ ostream and istream with the implementation of std::stringbuf in glibc

C++에서 흔히 std::ostream과 std::istream 타입의 인스턴스 std::cout, std::cin 또는 std::fstream와 같은 파생 클래스의 인스턴스를 사용한다. 각각의 클래스의 생성자를 살펴보면 공통적으로 basic_streambuf 타입을 인자로 받는 것을 알 수 있다. 이는 C++에서 입출력을 제어하는데 사용하는 핵심적인 클래스로 stringstream이나 fstream은 이를 이용하여 입출력을 수행한다.

/// from glibc:ostream
typedef basic_streambuf<_CharT, _Traits> __streambuf_type;
/**
 *  @brief  Base constructor.
 *
 *  This ctor is almost never called by the user directly, rather from 
 *  derived classes' initialization lists, which pass a pointer to
 *  their own stream buffer.
 */
explicit basic_ostream(__streambuf_type* __sb) { this->init(__sb); }

streambuf에 대한 기본적인 작동 방식은 cppreference를 통해 이해할 수 있다. (해당 링크의 사진을 꼭 보는 것을 추천한다.) 기본적으로 가상 함수를 통해 다형성을 지원한다. 링크에 나와있듯이, 6개의 포인터를 사용해 입출력을 관리한다. 우리가 흔히 C언어에서 (혹은 메모리를 직접 관리하는 경우에) 동적 배열을 관리하기 위해서는 3개의 포인터 값을 통해 유지할 수 있다. 첫 번째로 필요한 것은 배열의 시작점이다. 두 번째 포인터는 배열의 길이를 나타낸다. 마지막 포인터는 배열의 실제 허용하는 메모리 공간의 끝 지점을 나타낸다. 시작 점을 제외한 나머지 두 개의 값은 포인터가 아니라 정수를 사용해 표현하는 경우가 더 일반적일 것이다. 실제로 glibc의 std::vector의 구현을 살펴보아도3개의 포인터를 사용하여 공간을 표현하고 있다.

///from glibc:stl_vector.h
struct _Vector_impl_data {
    pointer _M_start;
    pointer _M_finish;
    pointer _M_end_of_storage;
}

streambuf는 입력에 해당하는 영역과 출력에 해당하는 영역을 둘 다 표기해야 하므로 두 배인 6개의 포인터를 사용한다. 입력에 해당하는 영역은 get 영역이라고 부르고 출력에 해당하는 영역은 put 영역이라고 부른다. 이러한 6개의 포인터를 관리하면서 인터페이스(streambuf)에 맞는 함수를 오버라이드해서 구현하면 ostream/istream에서 사용할 입출력 처리 로직을 자요롭게 사용할 수 있다. 이 글에선 std::stringstream에서 사용하고 있는 std::stringbuf의 glibc 구현체를 살펴봄으로써 streambuf를 이해하는 것이 목표이다.

소스 코드는 gcc-mirror github의 release/gcc-11.1.0 태그에서 수집된 결과물이며, raw.githubusercontent.com을 임베드 하였다.

std::stringstream and std::stringbuf

ostream이 basic_ostream<char>의 aliasing 이듯이 std::stringbuf도 std::basic_streambuf<char>의 aliasing이다. std::stringbuf는 이름 그대로 내부에 유지하고 있는 입출력 영역은 std::string으로 관리하고 있다. std::string에 6개의 포인터를 어떻게 설정하는지 보기 전에 먼저 overflow에 대한 함수를 보고 가려고 한다. overflow는 이름 그대로 버퍼가 꽉 찬 경우 출력을 하기 위해 호출되는 함수이다. 정확히는 pptr이 nullptr이거나 pptr이 epptr보다 크거나 같을 때 출력된다. 후자가 버퍼가 꽉찼다고 인지하는 경우이다. 상위 클래스인 std::streambuf가 put 영역의 3개의 포인터를 임의로 사용하여 출력을 하다가 끝 지점에 도달했을 경우 이 virtual 함수를 호출하여 처리한다. overflow의 구현체는 sstream.tcc에 존재한다. (CXX11_ABI는 꺼져있는 것으로 간주한다.)

 

먼저 현재 스트림의 모드가 out이거나 입력으로 준 값이 EOF(end of file) 값일 경우는 스펙에 맞는 값을 즉시 리턴한다. __testput 변수는 스트림의 pptr값과 epptr값을 비교한다. pptr 값은 put area 배열의 현재 끝 점을 나타내고, epptr은 put area의 capacity 관점에서 끝을 나타낸다. pptr이 epptr 미만이라면, 즉 __testput이 true 라는 것은 capacity를 늘리지 않아도 입력 값 c 를 put area에 넣을 수 있다는 것이다. 따라서 함수 하단 부의 else 영역을 보면 단순히 현재 pptr에 입력 값을 넣고 있다. 만약, false라면 capacity를 늘릴 필요가 있다. 일반적으로 배열을 늘리는 경우와 유사하게 새로운 배열 영역의 길이를 계산하고 그 길이의 string객체를 __tmp 로생성한다. 그 후 assign을 통해 pbase부터 pptr까지 다시말해 기존 버퍼에 있던 내용을 복사한다. 그 다음 새로운 버퍼에 입력 값 c 를 넣고 _M_sync함수를 통해 pbase, pptr, 그리고 epptr 같은 값을 동기화를 시킨다. 위 두 경우에 대해 출력을 처리한 후에는 pbump를 통해 epptr 값을 한 칸 전진시킨다.

다음으로 확인할 것은 overflow와 반대 개념인 underflow 함수이다. 마찬가지로입력 버퍼가 비어있을 경우 호출된다. 여기서도 정확히는 gptr 값이 nullptr이거나 gptr이 egptr보다 크거나 같을 때 호출되고 후자의 경우가 입력 버퍼가 비어있을 경우이다. underflow 구현체 역시 sstream.tcc에 존재한다.

 

underflow 자체는 단순하다. 현재 스트림의 모드가 in일 경우 진짜로 string의 끝에도달하였는지 확인한다. _M_update_egptr 함수를 통해 string의 내부 배열 구간과 get area를 동기화 시킨 뒤 get area에 내용이 더 있으면 해당 값을 반환한다.

지금까지 입출력에서 중요한 역할을 하는 두 함수를 살펴보았다. 여기서 더 나아가 두 함수에서 사용하고 있는 내부 함수들에 대해서도 볼 가치가 있다. 먼저 _M_sync 이다. 이 함수는 overflow에서 기존 string객체의 공간이 부족해 새로운 string 객체를 만들어 그 것을 내부 버퍼로 사용할 때 불린 함수이다. 또한 setbuf 함수나 객체의 생성자에서 초기화 할 때 불리는 함수이기도 하다. 한마디로 하자면 6개의 포인터를 내부 버퍼에 맞추는 작업을 하는 함수이다. _M_sync 구현체는 다음과 같다.

 

overflow에서는 첫 번째 인자로 버퍼 string의 data를, 두 번째 인자로 gptr - eback 값을, 세 번째 인자로 pptr - pbase 값을 인자로 넣었다. 두 번째 인자 __i 는 get area의 현재 위치와 버퍼 시작 지점의 차이의 길이를 나타내고 세 번째 인자 __o 는 put area의 길이를 나타낸다. 따라서 get area의 세 포인터 eback, gptr, egptr을 각각 string의 data, 입력으로 준 기존 gtpr - eback 값 그리고 string의 size 값으로 설정한다. put area의 경우 세 포인터 pbase, pptr, epptr을 각각 string의 data, string의 capacity 그리고 string의 size 값으로 설정한다.  setg와 달리 setp의 경우 pbase와 pptr만 지정할 수 있고 pptr은 pbump를 통해서 전진해야하는데 basic_streambuf의 pbump의 인자가 int 여서 발생하는 문제를 해결하기 위한 또다른 내부 함수가 _M_pbump__size_type이다.

두 번째로 볼 내부 함수는 underflow에서 사용했던 _M_update_egptr 이다. 이 함수는 sstream에 직접 들어있다.

 

pptr이 egptr보다 크다면, 즉 get area의 버퍼의 끝 지점이 put area의 길이의 끝 지점보다 작다면 get area에서 고려하지 못한 내용이 string에 더 있다는 것이다. 그렇다면 egptr 값을 pptr 값으로 바꿔 더 읽을 내용이 있다는 것을 나타낸다. stream의 시작 시점의 내부 string 버퍼의 길이가 10이라고 가정해서 egptr을 그 당시의 string 길이의 끝 지점으로 설정 한 뒤 출력을 진행하여 실제 string 버퍼에추가 내용을 넣을 때 egptr 값을 바로 바꾸는 것이 아니라, egptr은 여전히 길이가 10인 시점의 값으로 유지하고 있다가 필요할 때 확인하여 본래 값으로 맞춰 주는 작업을 하는 것이다.

std::istream and std::ostream

std::stringstream은 std::string을 std::streambuf로 사용하는 부분에 대한 추가적인 로직을 담당하고 실제로 입출력을 사용할 때 호출하는 함수 operator<<와 operator>>는 각각 std::basic_ostream과 std::basic_istream에 선언 및 정의되어 있다. 여러가지 에러 처리나 동기화 등을 위한 추가 함수가 많은데, 먼저 ostream에 문자열을 출력하는 함수를 보자.

 

__ostream_insert 는 bits/ostream_insert.h에 정의되어 있고 내용은 다음과 같다.

 

여러 에러 케이스를 처리하는 코드를 제외하면 실졸 사용하는 함수는 __ostream_write 이고, 해당 함수는 단순히 rdbuf()->sputn(str, legnth) 를 호출할 뿐이다. 해당 함수는 basic_streambuf에 정의되어 있고, xputn에 대한 NVI(non-virtual interface)이다. basic_streambuf는 xputn을 따로 구현하지 않았으므로 basic_streambuf의 xputn을 살펴보면 다음과 같다.

 

여기서 부터는 위에서 다루었던 개념을 사용하여 출력을 처리한다. epptr과 pptr을 통해 남아있는 버퍼의 영역을 확인하고, 버퍼의 영역에 가능한 만큼 출력한다. 그 후 양이 남아있다면 overflow함수를 사용한다. 더이상 출력할 수 없다면 출력한 만큼의 길이를 반환하고, 그 위의 함수에서 badbit 설정하는 등 출력에 실패한 경우에 대해 처리한다.

버퍼로 부터 문자열을 입력받는 경우는 약간 더 복잡하니 코드는 생략하고 핵심이 되는 함수만 살펴보자. 실제로 입력을 얻어오는 함수는 snextc 이다.  snextc는 여러가지 경우에 따라 작동한다. 먼저 sbumpc의 반환값이 EOF가 아닐 경우 sgetc를 호출한다. sgetc 함수는 gptr이 egptr보다 작다면 get area에 더 읽을 내용이 남아 있다는 뜻이므로 gptr을 접근하여 그 값을 반환한다. 만약 아니라면 위에서 설명한 underflow를 호출하고 그 값을 반환한다. sbumpc는 마찬가지로 gptr이 egptr보다 작다면 gptr를 읽고 그 값을 반환하고, gbump를 통해 gptr값을 1만큼 증가시킨다. 만약 아니라면 uflow 함수를 호출한다. uflow함수는 underflow를 호출하여 그 값이 EOF가 아니라면 gptr에서 값을 읽고 반환한 뒤 gptr값을 1만큼 증가시킨다. 위 함수들이 비슷하다는 사실을 알아 차렸을 것이다. uflow는 underflow를 호출한 뒤 gptr을 증가시키는 차이가 있고, sbumpc는 sgetc와 달리 gptr을 접근 한 뒤 gptr 값을 증가시키는 차이가 역시 존재한다.

실제로 stream들은 단순히 데이터를 읽고 출력하는 것이 아니라 포맷에 맞춰 출력을 하거나 문자열이 아닌 정수 등을 출력하는 역할도 한다. 근본적으로 해당 기능들은 가장 기본적인 데이터를 읽고 쓰는 과정을 포함하기 때문에 일반적으로 상상하는 과정들이 있을 것이라 추측할 수 있다. 위에서 코드를 보여주지 않고 넘어간 버퍼로 부터문자열을 입력받는 경우를 예시로 살펴보면, 동기화에 관련된 코드width에 관한 코드 등이 있다. locale 기능의 std::num_get 같은 클래스를 살펴봐도 좋다. 실제로 stream의 모든 문자열 처리를 locale을 바탕으로 하고 있기 때문에 이를 사실 지나칠 수 없다.

결론

이 글에서 C++의 기본 입출력 컨셉인 stream에서 어떻게 실제로 데이터 값을 읽고 쓰는지 살펴보았다. boost의 iostreams 역시 overflow와 underflow를 포함한 내용을 구현하여 std::iostream의 테두리 안에서 작동하고 있다. boost는 더 나아가 컨셉을 새로 만들어 그 컨셉을 사용한 adpater를 바탕으로 커스텀한 streambuf가 구현되어 있다. 이 개념들을 활용하여 커스텀한 streambuf를 사용하여 나만의 입출력 처리 방법을 구현할 수 있을 것이다.