본문 바로가기
프로그래밍/C & C++

[C++] 스트림 세부사항 (+ 스트림 버퍼, 스트림 성능 이슈)

by 별준 2022. 12. 19.

References

  • C++ Standard Library 2nd

Contents

  • Input/Output Operators for User-Defined Types
  • User-Defined Format Flags
  • Connecting Input and Output Streams
  • Stream Buffer Class
  • Performance Issues

[C++] 스트림(stream) 클래스 (1)

[C++] 스트림(stream) 클래스 (2)

[C++] 파일 & 문자열 스트림

위의 포스팅들을 통해 스트림 클래스에 대한 전반적인 내용에 대해서 살펴봤습니다. 이번 포스팅에서는 스트림에 대한 고급(?) 기법 및 세부사항에 대해서 살펴보도록 하겠습니다.

 


Input/Output Operator for User-Defined Types

스트림을 사용하면 스트림의 메커니즘을 사용자 정의 타입으로 확장할 수 있다는 점이 좋습니다. 이를 위해서는 << 연산자와 >> 연산자를 오버로딩해야 하는데, 어떻게 확장할 수 있는지 살펴보도록 하겠습니다. 여기서는 분수를 표현하는 클래스를 예시로 사용합니다.

class Fraction
{
public:
  Fraction(int n, int d) : m_numerator(n), m_denominator(d) {}

  int numerator() const { return m_numerator; }
  int denominator() const { return m_denominator; }

private:
  int m_numerator;
  int m_denominator;
};

 

Implementing Output Operators

출력 연산자의 표현식에서 왼쪽 피연산자는 스트림, 오른쪽 피연산자는 출력할 객체입니다.

stream << object

규칙에 의해서 위 코드는 두 가지 방법으로 해석할 수 있습니다.

  1. stream.operator<<(object)
  2. operator<<(stream, object)

첫 번째 방법은 내장 built-in 타입에서 사용됩니다. 사용자 정의 타입인 경우에는 스트림 클래스를 확장할 수 없기 때문에 두 번째 방법을 사용해야만 합니다. 따라서, 사용자 정의 타입을 위한 전역 << 연산자만 구현하면 됩니다. 객체의 private 멤버에만 접근하지 않는다면 구현은 상당히 쉬운데, 아래서 private 멤버에 접근하는 방법도 알아보도록 하겠습니다.

 

예를 들어, Fraction 클래스의 객체를 분자/분모 형태로 출력하고 싶다면 다음과 같이 함수를 작성할 수 있습니다.

#include <iostream>

inline
std::ostream& operator<<(std::ostream& strm, const Fraction& f)
{
  strm << f.numerator() << '/' << f.denominator();
  return strm;
}

위 함수에서 인자로 들어오는 스트림은 파일 스트림일 수도 있고, 문자열 스트림이나 다른 스트림이 될 수도 있습니다. 연산자의 chaining을 지원하기 위해서 함수는 스트림을 반환합니다.

 

위 방식은 간단하지만 두 가지 단점이 있습니다.

  1. 함수 시그니쳐에 ostream을 사용했기 때문에 문자의 타입이 char인 스트림에 대해서만 함수가 적용됩니다. 따라서 사용할 수 있는 범위가 제한되는데, 조금 더 제너럴하게 만드는 것이 그리 어렵지는 않기 때문에 최소한의 고려가 필요합니다.
  2. 만약 field width가 사용된다면, 문제가 발생합니다. 이 경우, 예상하는 것과 조금 다른 결과가 발생할텐데, field width는 다음 write에 바로 적용됩니다. 이 경우에는 분모에 적용되며, 예를 들면, 다음과 같습니다.

Fraction vat(19,100);
std::cout << "VAT: \"" << std::left << std::setw(8)
          << vat << '"' << std::endl;

결과는 다음과 같습니다.

 

위의 두 가지 문제는 아래와 같이 구현하면 모두 해결할 수 있습니다.

template<typename charT, typename traits>
inline
std::basic_ostream<charT, traits>&
operator<<(std::basic_ostream<charT,traits>& strm, const Fraction& f)
{
  // string stream
  // - with same format
  // - without special field width
  std::basic_ostringstream<charT, traits> s;
  s.copyfmt(strm);
  s.width(0);

  // fill string stream
  s << f.numerator() << '/' << f.denominator();

  // print string stream
  strm << s.str();

  return strm;
}

다시 출력시켜보면 아래와 같이 예상대로 출력되는 것을 확인할 수 있습니다.

 

참고로 std 네임스페이스의 타입에 대해 << 연산자의 사용자 정의 오버로딩에는 한계가 있습니다. ADL(argument-dependent lookup)을 사용하는 상황에서는 사용자 정의 오버로딩을 찾을 수 없기 때문인데, 아래와 같이 ostream iterator가 사용된 경우가 한 가지 예입니다.

template<typename T1, typename T2>
std::ostream& operator<<(std::ostream& strm, const std::pair<T1,T2>& p)
{
  return strm << "[" << p.first << "," << p.second << "]";
}

std::pair<int,long> p(42,77777);
std::cout << p << std::endl; // OK

std::vector<std::pair<int,long>> v;
...
std::copy(v.begin(), v.end(),       // ERROR: doesn't compile
          std::ostream_iterator<std::pair<int,long>>(std::cout, "\n"));

 

Implementing Input Operators

입력 연산자는 출력 연산자와 동일한 원칙에 따라 구현됩니다. 그러나 입력에는 read failure와 같은 문제가 발생하므로, 입력 함수에는 일반적으로 읽기를 실패했을 때 처리가 필요합니다.

 

읽기 함수를 구현할 때 간단한 접근 방식과 유연한 접근 방식 중 하나를 선택할 수 있습니다. 예를 들어, 아래 코드는 간단한 방식을 취했는데, 이 코드는 분수를 읽어 들이면서 에러 상황에 대해 체크하지 않습니다.

inline
std::istream& operator>>(std::istream& strm, Fraction& f)
{
  int n, d;
  strm >> n;     // read value of the numerator
  strm.ignore(); // skip '/'
  strm >> d;     // read value of the denominator

  f = Fraction(n,d); // assign the whole fraction

  return strm;
}

위의 구현 또한 문자 타입이 char인 스트림에서만 사용될 수 있다는 문제가 있고, 또한 두 숫자 사이의 문자가 진짜 '/' 인지 체크하지도 않습니다.

 

정의되지 않은 값을 읽으면 또 다른 문제가 발생합니다. 예를 들어, 분모값으로 0을 읽는 경우, 분수의 값은 정의되지 않습니다. 이 문제는 Fraction(n,d)로 클래스를 생성할 때 검출될 수 있습니다(여기서는 체크하지 않음). 하지만 클래스 Fraction에서 처리한다는 것은 Fraction 클래스의 에러 핸들링으로 format error를 처리한다는 것을 의미합니다. 사실 스트림에서 format error가 자주 발생하기 때문에 이런 경우에는 ios_base::failbit를 설정하는 것이 더 낫습니다.

 

마지막으로 읽기 연산이 성공하지 못하더라도 수정될 수 있습니다. 예를 들어, 분자는 성공적으로 읽었지만 분모는 제대로 읽지 못하는 경우가 발생할 수 있습니다. 이런 동작은 이미 정의되어 있는 입력 연산자들의 일반적인 관행과 배치되는 동작이므로 피하는 편이 좋습니다. 읽기 연산은 성공하거나 아니면 아무런 영향을 미치지 않아야 합니다.

 

위와 같은 문제들을 개선하기 위해 개선된 구현은 다음과 같습니다. 위에서 살펴본 출력 연산자와 마찬가지로 모든 타입에 적용할 수 있도록 템플릿으로 파라미터화되어 있습니다.

template<typename charT, typename traits>
inline
std::basic_istream<charT, traits>&
operator>>(std::basic_istream<charT, traits>& strm, Fraction& f)
{
  int n, d;

  // read value of numerator
  strm >> n;
  // if available, read '/' and value of denominator
  if (strm.peek() == '/') {
    strm.ignore();
    strm >> d;
  }
  else {
    d == 1;
  }
  // if denominator is zero
  // - set failbit as I/O format error
  if (d == 0) {
    strm.setstate(std::ios::failbit);
    return strm;
  }
  // if everything is fine so far
  // - change the value of the fraction
  if (strm) {
    f = Fraction(n, d);
  }
  return strm;
}

위 코드에서는 첫 번째 숫자 뒤에 '/'가 따라오는 경우에만 분모를 읽습니다. 그 이외의 경우에는 분모가 1이라고 가정하고 읽은 정수 하나를 전체 분수로 해석합니다. 또한, 분모의 값이 0이 아닌지도 검사하고, 0인 경우 failbit가 set되어 이에 대응하는 예외가 발생합니다. 마지막으로 스트림의 에러가 없는 경우에만 분수에 새 값을 할당합니다.

(물론 위 구현에서 개선되어야 하는 세부사항은 더 있을 수 있지만, 여기서는 깊게 다루지는 않도록 하겠습니다)

 

Input/Output Using Auxiliary Functions

I/O 연산자를 구현할 때 객체의 private 멤버에 접근해야 한다면 표준 연산자는 auxiliary 멤버 함수에 자신의 역할을 위임해야 합니다. 이 기법을 사용하면 다음과 같은 방식의 polymorphic read/write 함수를 허용합니다.

class Fraction
{
public:
  ...
  virtual void printOn(std::ostream& strm) const; // output
  virtual void scanFrom(std::istream& strm);      // input
  ...
};

std::ostream& operator<<(std::ostream& strm, const Fraction& f)
{
  f.printOn(strm);
  return strm;
}

std::istream& oeprator>>(std::istream& strm, Fraction& f)
{
  f.scanFrom(strm);
  return strm;
}

 

예를 들어, scanFrom 구현은 다음과 같이 멤버 변수에 직접 액세스할 수 있습니다.

void Fraction::scanFrom(std::istream& strm)
{
  ...
  // assign value directly to the components
  m_numerator = n;
  m_denumerator = d;
}

 

만약 클래스가 베이스 클래스로 사용되지 않는다면 I/O 연산자를 클래스의 friend로 만들어도 됩니다. 하지만 이를 사용하면 상속을 통한 확장성이 확연하게 줄어드는 문제가 있는데, 이는 friend 함수는 가상 함수가 될 수 없기 때문입니다.

 


User-Defined Format Flags

사용자 정의 I/O 연산자를 정의할 때, 이들에 대한 포맷 플래그가 필요한 경우가 있을 수 있습니다. 예를 들어, 위에서 살펴본 Fraction에 대한 출력 연산자에서 분자와 분모를 분리하기 위한 '/' 앞뒤로 공백을 주는 방식을 제공할 수 있으면 유용합니다.

 

스트림 객체는 데이터를 스트림에 연결시키는 메커니즘을 통해 이런 기능을 지원합니다. 예를 들어, 대응되는 데이터(ex, 조작자 manipulator 사용)와 뒤에 얻을 데이터를 연결시키는데 이 메커니즘을 사용할 수 있습니다.

클래스 ios_base는 iword()와 pword()라는 두 함수를 제공하는데, 각 함수는 인덱스를 int 인자로 받아서 지정된 long&(iword()) 또는 void*&(pword())를 액세스합니다. iword()와 pword()는 스트림 객체에 저장된 임의의 크기의 배열의 long 또는 void* 객체에 액세스합니다. 스트림에 대해 저장되는 formatting flags는 모든 스트림에서 동일한 인덱스에 위치합니다. ios_base 클래스의 static 멤버 함수 xalloc()을 사용하여 위와 같은 목적으로 사용되지만 아직 사용되지는 않은 인덱스를 얻어올 수 있습니다.

 

초기에는 iword()나 pword()로 액세스되는 객체는 0으로 설정되어 있습니다. 이 값은 default formatting을 나타내거나 해당 데이터가 아직 액세스되지 않았다는 것을 나타냅니다. 말로 전달해서 조금 이해하기 어려울 수 있는데 예제 코드를 보면 쉽게 이해가 될 것 입니다.

// get index for new ostream data
static const int iword_index = std::ios_base::xalloc();

// define manipulator that sets this data
std::ostream& fraction_space(std::ostream& strm)
{
  strm.iword(iword_index) = true;
  return strm;
}

std::ostream& operator<<(std::ostream& strm, const Fraction& f)
{
  // query the ostream data
  // - if true, use spaces between numerator and denominator
  // - if false, ues no spaces between numerator and denominator
  if (strm.iword(iword_index)) {
    strm << f.numerator() << " / " << f.denominator();
  }
  else {
    strm << f.numerator() << "/" << f.denominator();
  }
  return strm;
}

이렇게 정의한 뒤, 아래처럼 사용할 수 있습니다.

int main(int argc, char** argv)
{
  Fraction vat(19,100);
  std::cout << "VAT: \"" << fraction_spaces
            << vat << '"' << std::endl;
}

 

위 예제 코드는 출력 연산자를 구현하는 방법 중 가장 간단한 방식을 사용했는데, 외부에 iword() 함수가 노출됩니다. 포맷 플래그는 분자와 분모 사이의 공간을 두어야 하는지를 정의하는 불리언 값으로 간주됩니다. 첫 번째 구문에서 ios_base::xalloc()을 사용하여 포맷 플래그를 저장하는데 사용할 인덱스를 얻습니다. 이때 얻은 값은 변경될 일이 없기 때문에 상수로 지정됩니다. fraction_spaces() 함수는 strm 스트림과 연결된 정수 배열에서 인덱스 iword_index에 저장된 int 값을 true로 설정하는 조작자입니다. 출력 연산자는 이 배열에서 인덱스 iword_index에 저장된 값에 따라 분수를 출력합니다. 여기서는 true라면 '/' 앞뒤로 공백을 한 칸씩 추가하고, false 라면 공백없이 출력합니다.

 

iword()와 pword()가 호출되면, long이나 void* 객체에 대한 레퍼런스가 반환됩니다. 이들은 해당 스트림 객체에 대해 iword()와 pword()가 불리기 전까지 또는 스트림 객체가 소멸되기 전까지 유효합니다. 일반적으로 iword()나 pword()에서 얻은 레퍼런스는 저장하지 않는 것이 좋긴 합니다(객체의 생명주기가 언제인지 명확하지 않을 때).

 

copyfmt() 함수는 iword()와 pword()로 접근할 수 있는 배열들을 비롯한 모든 포맷 정보를 복사합니다. 이 때문에 pword()를 사용해 스트림과 저장된 객체에서 문제가 발생할 수 있습니다. 예를 들어, 만약 값이 객체의 주소라면 객체 대신 주소가 저장됩니다. 만약 주소만을 복사한다면, 어떤 스트림의 포맷을 변경했을 때 다른 스트림의 포맷도 같이 영향을 받을 수 있습니다. 또한, pword()를 사용하는 스트림과 연결된 객체는 스트림이 소멸될 때 같이 소멸될 수 있습니다. 따라서 이런 객체들에 대해서는 deep copy가 필요합니다.

 

ios_base는 필요하다면 deep copy나 스트림이 소멸될 때 객체가 같이 소멸되는 등의 동작을 지원하도록 콜백 메커니즘을 정의합니다. 만약 특정 연산이 ios_base 객체에서 수행되어야 할 때 호출되어야 하는 함수를 register_callback()으로 등록할 수 있습니다.

namespace std {
  class ios_base {
  public:
    // kinds of callback events
    enum event { erase_event, imbue_event, copyfmt_event };
    // type of callbacks
    typedef void(*event_callback)(event e, ios_base& strm, int arg);
    // function to register callbacks
    void register_callback(event_callback cb, int arg);
    ...
  };
}

register_callback()은 첫 번째 인자로 함수 포인터를, 두 번째 인자로 int 인자를 받습니다. int는 등록된 함수가 호출될 때 세 번째 인자로 전달되며, 예를 들어, pword()를 위한 인덱스를 식별하여 배열 내 어떤 멤버를 처리해야 하는지 알려주는데 사용될 수 있습니다. 콜백 함수로 전달된 인자인 strm은 콜백 함수를 호출하게 만든 ios_base 객체입니다. 각 이벤트가 호출되는 이유는 아래의 표에서 설명하고 있습니다.

만약 copyfmt()가 사용된다면, copyfmt()가 호출된 객체에 대해서 콜백 함수는 두 번 호출됩니다. 먼저 복사되기 전, erase_event라는 인자로 콜백이 호출되어 필요한 정리 작업(예를 들어, pword() 배열 내에 저장된 객체를 삭제하는 일)을 수행합니다. 그리고 포맷 플래그들이 복사되고 나면 콜백이 다시 호출되는데, 이번에는 copy_event라는 인자로 콜백이 호출됩니다. 이 콜백에서는 예를 들어, pword() 배열에 저장된 객체에 대한 deep copy와 같은 작업이 수행될 수 있습니다.

 

 


Connecting Input and Output Streams

종종 두 스트림을 연결하는게 필요한 경우가 있습니다. 예를 들어, 입력을 요청한 텍스트를 읽기 전에 스트린에 출력하고 싶을 수 있습니다. 또는 동일한 스트림에서 읽고, 쓰고 싶을 수 있습니다. 대체로 파일을 다룰 때 이런 작업이 필요할 수 있습니다. 또 다른 예시로 다른 포맷을 사용하여 동일한 스트림을 조작하고 싶을 수도 있습니다.

 

Loose Coupling Using tie()

스트림을 출력 스트림에 tie 할 수 있습니다. 즉, 두 스트림의 버퍼가 동기화되어 다른 스트림의 입력 또는 출력 전에 출력 스트림의 버퍼가 flush 된다는 의미입니다. 이때, 출력 스트림의 flush() 함수가 호출됩니다. 아래 표는 한 스트림을 다른 스트림에 tie할 때 사용할 수 있는 basic_ios의 멤버 함수를 보여줍니다.

인자없이 tie()를 호출하면 현재 스트림에 묶여 있는 출력 스트림에 대한 포인터를 반환합니다. 새로운 출력 스트림을 스트림에 묶고 싶다면 출력 스트림에 대한 포인터를 tie()의 인자로 전달하면 됩니다. 인자는 포인터로 정의되어 있어서 nullptr(or 0 or NULL)을 전달할 수 있습니다. nullptr을 전달하면 묶여있던 출력 스트림이 있을 때, 이를 풀어줍니다. 만약 묶여있던 출력 스트림이 없다면 nullptr 이나 0을 반환합니다. 각 스트림에 대해서는 하나의 스트림만을 묶을 수 있습니다. 하지만 출력 스트림을 다른 스트림들에 묶을 수는 없습니다.

 

기본적으로 표준 입출력은 다음과 같은 방식으로 표준 출력에 연결되어 있습니다.

// predefined connections
std::cin.tie(&std::cout);
std::wcin.tie(&std::wcout);

따라서, 입력을 요청하는 메세지는 입력을 요청하기 전에 flush된다는 것이 보장됩니다. 예를 들어, 아래와 같은 명령문을 쓰면 x를 읽기 전 cout에 대한 flush() 함수가 암묵적으로 호출됩니다.

std::cout << "Please enter x: ";
std::cin >> x;

 

두 스트림 사이의 연결을 제거하고 싶다면 tie()에 nullptr 또는 0을 전달합니다.

// decouple cin from any output stream
std::cin.tie(nullptr);

아마, 알고리즘 문제를 풀 때 입출력을 cin/cout으로 하신다면, 입출력에 대한 시간을 줄이기 위해 위와 같은 구문을 많이 사용했을 것이라 생각됩니다. 위와 같은 명령문을 사용하면 스트림이 불필요하고 flush되는 것을 막아 프로그램의 성능을 높일 수 있기 때문입니다.

 

한 출력 스트림을 다른 출력 스트림에 묶을 수도 있습니다. 예를 들어, 아래 명령문은 error 스트림에 무언가가 write 되면, 일반적인 출력도 flush 됩니다.

// tieing cout to cerr
std::cerr.tie(&std::cout);

 

Tight Coupling Using Stream Buffers

rdbuf() 함수를 사용하면 공통의 스트림 버퍼를 사용하여 스트림을 커플링시킬 수 있습니다. 이 함수들은 여러 가지 목적에 적합한데, 아래에서 살펴보도록 하겠습니다.

rdbuf() 멤버 함수를 사용하면 여러 스트림 객체가 I/O 순서를 왜곡하지 않으면서 같은 입력 채널을 읽거나 또는 같은 출력 채널에 쓸 수 있습니다. 다중 스트림 버퍼는 부드럽게 동작하지 않는데, I/O 연산이 버퍼되기 때문입니다. 따라서, 같은 I/O 채널에 대해 서로 다른 버퍼를 갖는 스트림을 사용한다면, I/O는 다른 I/O를 전달할 수도 있습니다.

basic_istream과 basic_ostream의 생성자를 사용하면 인자로 전달된 스트림 버퍼로 스트림을 초기화할 수 있습니다. 아래 예제 코드를 참조 바랍니다.

int main(int argc, char** argv)
{
  // stream for hexadecimal standard output
  ostream hexout(cout.rdbuf());
  hexout.setf(ios::hex, ios::basefield);
  hexout.setf(ios::showbase);

  // switch between decimal and hexadecimal output
  hexout << "hexout: " << 177 << " ";
  cout   << "cout: "   << 177 << " ";
  hexout << "hexout: " << -49 << " ";
  cout   << "cout: "   << -49 << " ";
  hexout << endl;
}

 

참고로 basic_istream과 basic_ostream의 소멸자는 해당하는 스트림 버퍼를 삭제하지 않는다는 점에 주의해야 합니다. 따라서 스트림 버퍼에 대한 스트림 레퍼런스 대신 포인터를 사용해 스트림 장치를 열 수 있습니다.

void hexMultiplicationTable(std::streambuf* buffer, int num)
{
  ostream hexout(buffer);
  hexout << hex << showbase;

  for (int i = 1; i <= num; i++) {
    for (int j = 1; j <= 10; j++) {
      hexout << i*j << ' ';
    }
    hexout << endl;
  }
  // does NOT close buffer
}

int main(int argc, char** argv)
{
  int num = 5;

  cout << "We print " << num
       << " lines hexadecimal\n";

  hexMultiplicationTable(cout.rdbuf(), num);

  cout << "That was the output of " << num
       << " hexadecimal lines\n";
}

위와 같은 방식을 사용하면 포맷을 수정한 뒤, 원래 상태로 복원시키지 않아도 되기 때문에 편리합니다. 포맷은 스트림 객체에 적용되는 것이지 스트림 버퍼에 적용되는 것이 아니기 때문입니다.

 

하지만 위의 방식을 사용하면 스트림 객체를 생성하고 소멸시킬 때 몇몇 포맷 플래그를 설정하고 복원시키는 것 이상의 cost가 필요합니다. 또한 스트림 객체를 소멸시킨다 하더라도 버퍼가 flush되는 것은 아니며, 직접 flush해야 합니다.

또한 basic_istream과 basic_ostream에서만 스트림 버퍼가 소멸되지 않으며 다른 스트림 클래스에서는 원래 할당했던 스트림 버퍼를 삭제합니다. 단 rdbuf()로 설정한 스트림 버퍼는 삭제하지 않습니다.

 

Redirecting Standard Streams

IOStream 라이브러리의 이전 구현에서는 전역 스트림인 cin, cout, cerr, clog는 istream_withassign과 ostream_withassign 클래스의 객체였고, 한 스트림을 다른 스트림에 할당하면 쉽게 스트림이 리다이렉트됩니다. 하지만 C++ 표준 라이브러리에서는 이러한 방법을 사용하지는 못합니다. 하지만, 방법은 존재하며 다른 모든 스트림에도 적용할 수 있는데, 스트림 버퍼를 설정하여 한 스트림을 리다이렉트할 수 있습니다.

 

스트림 버퍼를 설정한다는 것은 I/O 스트림을 OS의 도움없이 리다이렉트한다는 의미입니다. 예를 들어, 아래의 명령문은 out으로 써지는 출력을 표준 출력 채널이 아닌 cout.txt로 전달하도록 환경을 설정합니다.

std::ofstream file("cout.txt");
std::cout.rdbuf(file.rdbuf());

주어진 스트림의 모든 포맷 정보를 다른 스트림 객체에 전달하기 위해 copyfmt()를 사용할 수도 있습니다.

std::ofstream file("cout.txt");
file.copyfmt(std::cout);
std::cout.rdbuf(file.rdbuf);

주의할 점은 객체인 file이 지역 변수라면 블록의 끝에서 소멸됩니다. 그러면 그게 해당하는 스트림 버퍼도 함께 소멸됩니다. 파일 스트림은 자신의 스트림 버퍼 객체를 생성 시에 할당하고 소멸 시에 삭제하기 때문에 일반적인 스트림과는 다르며, 따라서, 위 예제 코드에서 cout은 더 이상 write에 사용할 수 없습니다. 따라서 예전 버퍼를 항상 저장해두었다가 뒤에 복원시켜야 합니다.

아래 예제 코드에서는 redirect() 함수 내에서 필요한 동작들을 처리하고 있습니다.

#include <iostream>
#include <fstream>
#include <memory>
using namespace std;

void redirect(ostream& strm)
{
  // save output buffer of the stream
  // - use unique pointer with deleter that ensures to restore
  //   the original output buffer at the end of function
  auto del = [&](streambuf* p) {
    strm.rdbuf(p);
  };
  unique_ptr<streambuf, decltype(del)> orgBuffer(strm.rdbuf(), del);

  // redirect output into the file redirect.txt
  ofstream file("redirect.txt");
  strm.rdbuf(file.rdbuf());

  file << "one row for the file\n";
  strm << "one row for the stream\n";
  // close file AND its buffer automatically
}

int main(int argc, char** argv)
{
  cout << "the first row\n";

  redirect(cout);

  cout << "the last row\n";
}

위 코드에서 unique_ptr을 사용하기 때문에 orgBuffer에 저장된 원래의 출력 버퍼는 복원된다는 것이 보장됩니다.

이 프로그램의 출력은 다음과 같고,

redirect.txt의 내용은 다음과 같습니다.

 

Streams for Reading and Writing

이번에는 읽기 및 쓰기를 위해 같은 스트림을 사용하는 방법에 대해 살펴보겠습니다. 일반적으로 읽기, 쓰기 모두를 위한 파일은 fstream 클래스를 사용해 다음과 같이 오픈합니다.

std::fstream file("example.txt", std::ios::in | std::ios::out);

읽기를 위한 스트림 객체와 쓰기를 위한 스트림 객체를 따로 사용할 수도 있습니다. 예를 들어, 다음과 같이 선언하여 두 개의 스트림 객체를 만들 수 있습니다.

std::ofstream out("example.txt", std::ios::in | std::ios::out);
std::istream in(out.rdbuf());

위 코드에서 out을 선언하면서 파일을 열고, in에서는 out의 스트림 버퍼를 읽는다고 선언합니다. 이때, out은 읽기와 쓰기 모두를 위해 열어둔 것에 유의합니다. 만약 쓰기만을 위해 오픈한다면 해당 스트림에서의 읽기 작업은 undefined 입니다. 또한 in은 ifstream이 아닌 istream 이어야만 합니다. 파일은 이미 열려있기 때문에 필요한 것은 스트림 객체일 뿐입니다.

 

파일 스트림 버퍼를 만든 후 두 스트림 객체에서 사용할 수도 있습니다.

std::filebuf buffer;
std::ostream out(&buffer);
std::istream in(&buffer);
buffer.open("example.txt", std::ios::in | std::ios::out);

filebuf는 char 타입에 대한 클래스 basic_filebuf<>의 특수화이며, 이 클래스는 파일 스트림에서 사용되는 스트림 버퍼 클래스를 정의합니다.

 

아래의 예제 코드는 루프 내에서 4개의 행을 사용해 파일에 씁니다. 각 행을 쓸 때마다 파일의 내용을 표준 출력으로 씁니다.

int main(int argc, char** argv)
{
  // open file "example.dat" for reading and writing
  filebuf buffer;
  ostream output(&buffer);
  istream input(&buffer);
  buffer.open("example.dat", ios::in | ios::out | ios::trunc);

  for (int i = 1; i <= 4; i++) {
    // write one line
    output << i << ". line" << endl;

    // print all file contents
    input.seekg(0); // seek to the beginning
    char c;
    while (input.get(c)) {
      cout.put(c);
    }
    cout << endl;
    input.clear(); // clear eofbit and failbit
  }
}

위 코드의 출력은 다음과 같습니다.

서로 다른 두 개의 스트림 객체를 사용해서 읽고 쓰지만, 읽고 쓰는 위치는 결합됩니다. seekg()와 seekp()는 모두 스트림 버퍼의 동일한 멤버 함수를 호출하며, 따라서, 읽는 위치는 항상 파일 전체 내용을 쓰기 위해 파일의 시작으로 설정됩니다. 그러면 읽기/쓰기 위치가 다시 파일의 끝으로 바뀌고, 새로운 문장을 덧붙일 수 있습니다.

 

 


The Stream Buffer Classes

읽기 및 쓰기는 스트림에서 직접 처리하는 것이 아닌 스트림 버퍼에서 처리합니다. 이때, 스트림 버퍼를 처리하는 인터페이스는 상당히 간단합니다.

  • rdbuf() 는 스트림의 스트림 버퍼에 대한 포인터를 반환합니다.
  • 스트림의 생성자와 rdbuf()를 사용해 생성 시, 스트림 버퍼를 설정하거나 스트림이 있는 상황에서 스트림 버퍼를 바꿀 수 있습니다. 두 경우 모두 rdbuf()가 반환하는 것과 같은 스트림 버퍼에 대한 포인터를 전달해야 합니다.

위와 같은 기능 덕분에 위에서 살펴본 기능과 같이 스트림이 같은 출력 장치를 쓰게 한다든지, 스트림을 리다이렉트한다든지, 같은 버퍼에서 읽고 쓴다든지, 또는 다른 문자 인코딩을 입력과 출력 포맷으로 사용할 수 있습니다.

 

마지막으로 스트림 버프 클래스가 어떻게 동작하고 I/O 스트림을 사용할 때 어떤 일이 발생하는지, 새로운 I/O 채널을 정의하기 위한 기본적인 지식에 대해 알아보겠습니다.

 

The Stream Buffer Interfaces

스트림 버퍼의 사용자에게 basic_streambuf<>는 문자를 보내거나 추출할 수 있는 것에 불과합니다. 아래의 함수는 문자를 쓰는데 사용할 수 있는 public 함수입니다.

sputc() 함수는 에러가 발생할 때, traits_type::eof()를 반환합니다. 여기서 traits_type은 basic_streambuf에 있는 타입 정의입니다. 함수 sputn()은 스트림 버퍼에 쓸 수 있다면 두 번째 인자로 전달된 수만큼의 문자를 씁니다. 이 함수는 NULL 문자를 신경쓰지 않으며, 쓴 문자의 수를 반환합니다.

 

스트림 버퍼에서 문자를 읽는 인터페이스는 조금 더 복잡합니다. 입력의 경우, 문자를 소비하기 전에 먼저 문자를 살펴봐야하기 때문입니다. 또한, 구문을 분석하는 동안 문자를 스트림 버퍼로 돌려보내야 할 수도 있기 때문입니다. 따라서 스트림 버퍼 클래스는 다음과 같은 함수들을 제공합니다.

in_avail() 함수는 읽을 수 있는 문자가 최소 몇 개나 있는지 결정하는데 사용됩니다. 예를 들어, 이 함수는 키보드에서 읽는 동안 읽기가 멈추지 않도록 하기 위해 사용될 수 있는데, 실제로 사용할 수 있는 문자는 더 있을 수 있습니다.

 

스트림 버퍼에서 스트림의 끝에 도달하기 전까지, 현재 문자(current character)가 있습니다. sgetc() 함수는 다음 문자로 이동하지 않으면서 현재 문자를 얻는데 사용됩니다. sbumpc() 함수는 현재 문자를 읽은 후 다음 문자로 이동하여 새로운 현재 문자로 인식합니다. snextc()는 현재 문자를 다음 문자로 만들면서 이 문자를 읽습니다. 이 세 함수는 모두 실패하는 경우, tratis_type::eof()를 반환합니다. 함수 sgetn()는 버퍼에서 문자의 시퀀스를 읽으며, 한 번에 읽을 최대 문자 수가 인자로 전달되며, 읽은 문자의 수를 반환합니다.

 

sputbackc()sungetc() 함수는 뒤로 되돌아가기 위한 함수이며 이전 문자를 현재 문자로 만듭니다. sputbackc() 함수는 이전 문자를 다른 문자로 바꿀 때 사용하며, 이 함수들을 사용할 때는 주의를 기울여야 하며 대체로 한 문자 뒤로만 되돌아갈 수 있습니다.

 

아래 표의 함수들은 마지막으로 주입된 로케일 객체에 액세스하거나 위치를 바꾸거나 버퍼링에 영향을 주는 함수들입니다.

pubimbue()getloc() 모두 internationalization을 위해 사용됩니다. pubimbue() 함수는 스트림 버퍼에 새로운 로케일 객체를 설치하고 이전에 설치된 로케일 객체를 반환합니다. getloc()는 현재 설치된 로케일 객체를 반환합니다.

 

pubsetbuf() 함수는 스트림 버퍼의 버퍼 전략을 제어하는 방법을 제공합니다. 어떤 전략을 취할 것인지는 실제 스트림 버퍼 클래스에 따라 달라지는데, 예를 들어, 문자열 스트림 버퍼에 pubsetbuf()를 사용하는 것은 의미가 없습니다. 파일 스트림 버퍼라고 하더라도 첫 번째 I/O 연산이 수행되기 전에 이 함수를 호출하고, 사용할 버퍼가 없다는 것을 의미하는 pubsetbuf(nullptr, 0)으로 호출할 때만 유효합니다. 이 함수는 실패하면 nullptr을 그렇지 않으면 스트림 버퍼를 반환합니다.

 

pubseekoff()pubseekpos()로 읽기와 쓰기를 위해 사용되는 현재 위치를 조작할 수 있습니다. 조작되는 위치는 마지막 인자에 영향을 받습니다. 마지막 인자의 타입은 ios_base::openmode 이고, 명시되지 않는다면 ios_base::in | ios_base::out을 기본값으로 사용합니다. ios_base::in 이라면 read-position을 수정하고, ios_base::out 이라면 write-position이 수정됩니다.

putseekpos()는 첫 번째 인자로 명시된 절대 위치로 이동합니다. 반면 putseekoff()는 특정 지점에 대한 상대적인 위치로 이동하며, 첫 번째 인자는 오프셋입니다. 시작 지점으로 사용되는 위치는 두 번째 인자로 전달됩니다. 두 함수는 스트림에서 새로운 위치 또는 유효하지 않은 스트림 위치를 반환합니다. 유효하지 않은 위치는 pos_type과 비교하여 알아낼 수 있습니다.

 

Stream Buffer Iterators

스트림 버퍼의 반복자 클래스는 unformatted I/O를 위한 멤버 함수를 사용하는 또 다른 방법입니다. 이들은 입력 반복자 및 출력 반복자 요구사항을 만족시키며 스트림 버퍼에서 개별 문자를 읽거나 쓰는 반복자를 제공합니다. 이들은 C++ 표준 라이브러리의 알고리즘 라이브러리 내에서 문자 수준 I/O를 할 때 적합합니다.

 

istreambuf_iterator<>와 ostreambuf_iterator<> 클래스 템플릿은 basic_streambuf<> 타입에서 개별 문자를 읽거나 쓰는데 사용됩니다. 이 클래스들은 <iterator>에 아래와 같이 정의되어 있습니다.

namespace std {
  template<typename charT,
           typename traits = char_tratis<charT>>
           class istreambuf_iterator;
  
  template<typename charT,
           typename traits = char_tratis<charT>>
           class ostreambuf_iterator;
}

 

Output Stream Buffer Iterators

아래 코드에서 ostreambuf_iterator를 사용해 문자열을 스트림 버퍼에 쓰는 방법을 보여줍니다.

// create iterator for buffer of output stream cout
std::ostreambuf_iterator<char> bufWriter(std::cout);

std::string hello("hello, world\n");
std::copy(hello.begin(), hello.end(), // source: string
          bufWriter);                 // destination: output buffer of cout

위 코드에서 첫 번째 구문은 cout에서 ostreambuf_iterator 타입의 출력 반복자를 생성합니다. 출력 스트림 대신 스트림 버퍼의 포인터를 직접 전달해도 됩니다.

 

아래 표는 출력 스트림 버퍼 반복자의 모든 연산을 나열하고 있습니다. 구현은 ostream 반복자와 유사합니다.

failed()를 통해 쓰기가 가능한지 확인할 수 있으며, 이전 문자에 대한 write가 실패했다면 failed()는 true를 반환합니다. 이 경우, = 연산자를 사용한 write는 아무런 동작도 하지 않습니다.

 

Input Stream Buffer Iterators

아래 표는 입력 스트림 버퍼 반복자의 모든 연산을 나열합니다.

 

Example

아래 예제 코드는 전형적인 필터 프레임워크이며, 단순히 스트림 버퍼 반복자로 읽은 모든 문자를 출력합니다.

#include <iostream>
#include <iterator>
using namespace std;

int main(int argc, char** argv)
{
  // input stream buffer iterator for cin
  istreambuf_iterator<char> inpos(cin);

  // end-of-stream iterator
  istreambuf_iterator<char> endpos;

  // output stream buffer iterator for cout
  ostreambuf_iterator<char> outpos(cout);

  // while input iterator is valid
  while (inpos != endpos) {
    *outpos = *inpos; // assign its value to the output iterator
    ++inpos;
    ++outpos;
  }
}

 

User-Defined Stream Buffers

스트림 버퍼는 I/O를 위한 버퍼입니다. 인터페이스는 basic_streambuf<> 클래스에 정의되어 있으며, char과 wchar_t 타입에 대한 streambuf와 wstreambuf가 미리 정의되어 있습니다. 이 클래스들은 특수한 I/O 채널을 통한 통신을 구현하기 위해 기본 클래스로 사용됩니다. 먼저 이들 클래스를 기반으로 스트림 버퍼의 연산에 대해 이해해보도록 하겠습니다.

 

버퍼에 대한 핵심 인터페이스는 읽기/쓰기 버퍼 각각에 대한 3개의 포인터로 구성됩니다. 버퍼를 읽기 위한 인터페이스는 eback(), gptr(), egptr() 함수에서 반환한 포인터들로 구성되며, 쓰기를 위한 인터페이스는 pbase(), pptr(), epptr() 함수에서 반환한 포인터들로 구성됩니다. 이 포인터들은 읽기와 쓰기 연산을 할 때 조작되며, 그 결과에 따라 해당 읽기 또는 쓰기 채널에서 원하는 효과가 나타납니다.

 

User-Defined Output Buffers

문자를 쓰는데 사용되는 버퍼는 pbase(), pptr(), epptr() 이라는 3개의 함수로 접근할 수 있는 포인터로 관리됩니다.

  • bpase() ("put base") : 출력 버퍼의 시작
  • pptr() ("put pointer") : 현재 write-position
  • epptr() ("end put pointer") : 출력 버퍼의 끝. 버퍼할 수 있는 마지막 위치의 다음을 가리킴

pbase()에서부터 pptr() 사이의 문자(pptr()이 가리키는 문자 제외)는 이미 쓰여졌지만 출력 채널로 flush되지 않았다는 것을 의미합니다.

 

문자를 쓸 때는 sputc()라는 멤버 함수를 사용합니다. 만약 공간이 있다면 현재 write-position에 이 문자를 복사합니다. 그리고 현재 위치에 대한 포인터가 증가됩니다. 만약 버퍼가 가득찼다면(pptr() == epptr()) 가상 함수인 overflow()를 호출하여 대응하는 출력 채널로 출력 버퍼의 내용을 전달합니다. 이 함수는 문자들을 어떤 'external representation'으로 문자를 전달하는 역할을 맡습니다. basic_streambuf 기본 클래스 내 overflow()의 구현은 단순히 파일의 끝만을 반환하여 더 이상 문자를 쓸 수 없다는 것만을 나타냅니다.

sputn()은 여러 문자를 한꺼번에 쓸 때 사용할 수 있는데, 이 함수의 실제 동작은 가상 함수 xsputn()에 위임하는데, 이 함수는 여러 문자들을 조금 더 효율적으로 쓸 수 있도록 구현될 수 있습니다. 따라서, xsputn()을 오버라이딩할 필요는 없습니다. 하지만 한 번에 한 문자를 읽는 것보다 여러 문자를 한 번에 쓰면 더 효율적으로 처리할 수 있으므로, 문자 시퀀스의 출력을 최적화할 때 사용될 수 있습니다.

 

스트림 버퍼에 쓴다고 해서 반드시 버퍼를 써야만 하는 것은 아니며, 받자마자 바로 쓸 수도 있습니다. 이 경우, 쓰기 버퍼를 관리하는 포인터에 nullptr이 할당되어야 합니다 (기본 생성자가 동작하는 방식).

 

아래 예제 코드를 통해 간단한 스트림 버퍼를 사용하는 방법을 확인할 수 있습니다. 이 스트림 버퍼는 버퍼를 사용하지 않고, 모든 문자에 대해 overflow() 함수를 호출합니다. 이 함수에는 꼭 필요한 구현만 포함되어 있습니다.

#include <iostream>
#include <locale>
#include <cstdio>

class outbuf : public std::streambuf
{
protected:
  // central output function
  // - print characters in uppercase mode
  virtual int_type overflow(int_type c) {
    if (c != EOF) {
      // convert lowercase to uppercase
      c = std::toupper(c, getloc());
      // and write the character to the standard output
      if (std::putchar(c) == EOF) {
        return EOF;
      }
    }
    return c;
  }
};

위 코드에서 스트림 버퍼로 전달된 문자를 C 함수 putchar()를 사용하여 씁니다.

 

위에서 출력 버퍼는 char 타입에 맞추어 구현되어 있습니다. 만약 다른 문자 타입을 사용한다면 character traits를 사용하도록 구현해야 합니다. 이 경우, c를 EOF와 비교하는 코드가 달라져야 하는데, EOF 대신 traits::eof()를 사용해야 하고, c가 EOF라면 traits::not_eof(c)가 반환되어야 합니다.

#include <iostream>
#include <locale>
#include <cstdio>

template<typename charT, typename traits = std::char_traits<charT>>
class basic_outbuf : public std::basic_streambuf<charT, traits>
{
protected:
  // central output function
  // - print characters in uppercase mode
  virtual typename traits::int_type
  overflow(typename traits::int_type c) {
    if (!traits::eq_int_type(c, traits::eof())) {
      // convert lowercase to uppercase
      c = std::toupper(c, this->getloc());
      // convert the character into a char (default: '?')
      char cc = std::use_facet<std::ctype<charT>>(this->getloc()).narrow(c,'?');
      // and write the character to the standard output
      if (std::putchar(cc) == EOF) {
        return traits::eof();
      }
    }
    return traits::not_eof(c);
  }
};

typedef basic_outbuf<char> outbuf;
typedef basic_outbuf<wchar_t> woutbuf;

이렇게 정의한 클래스는 아래와 같이 사용할 수 있습니다.

int main(int argc, char** argv)
{
  outbuf ob;
  std::ostream out(&ob);

  out << "31 hexadecimal: " << std::hex << 31 << std::endl;
}

위 코드의 출력은 아래와 같습니다.

어떤 임의의 출력 장치에 대해서도 위와 같은 방식을 사용할 수 있습니다.

 

스트림 버퍼를 편리하게 생성하기 위해 특별한 스트림 클래스를 구현하여 해당 스트림 버퍼에 생성자 인자를 전달하는 것도 의미가 있습니다. 아래 예제 코드가 이를 어떻게 적용하는지 보여주는데, 예제 코드에서는 먼저 스트림 버퍼 클래스를 문자를 어디에 쓸 지 나타내는 file descriptor로 초기화합니다. 이 클래스에서는 UNIX와 같은 OS에서 사용되는 low-level I/O 함수인 write()를 사용하여 문자를 씁니다. 또한, ostream에서 파생된 클래스는 file desceriptor가 전달된 스트림 버퍼를 갖도록 정의됩니다.

#include <iostream>
#include <streambuf>
#include <cstdio>

#ifdef _MSC_VER
#include <io.h>
#else
#include <unistd.h>
#endif

class fdoutbuf : public std::streambuf
{
protected:
  int fd; // file descriptor

public:
  // construct
  fdoutbuf(int _fd) : fd(_fd) {}
protected:
  // write one character
  virtual int_type overflow(int_type c)
  {
    if (c != EOF) {
      char z = c;
      if (write(fd, &z, 1) != 1) {
        return EOF;
      }
    }
    return c;
  }
  // write multiple characters
  virtual std::streamsize xsputn(const char* s, std::streamsize num)
  {
    return write(fd,s,num);
  }
};

class fdostream : public std::ostream
{
protected:
  fdoutbuf buf;

public:
  fdostream(int fd) : std::ostream(0), buf(fd)
  {
    rdbuf(&buf);
  }
};

위 스트림 버퍼에서도 문자 시퀀스가 이 스트림 버퍼에 전달되었을 때 각 문자에 대해 overflow()를 호출하지 않는 방식으로 함수 xsputn()을 구현했습니다. 따라서, fd로 식별되는 파일에 대해 단 한 번만 이 함수를 호출해도 전체 문자 시퀀스를 쓸 수 있습니다.

다음과 같이 위 클래스를 사용할 수 있습니다.

int main(int argc, char** argv)
{
  fdostream out(1); // stream with buffer writing to file descriptor 1

  out << "31 hexadecimal: " << std::hex << 31 << std::endl;
}

이 프로그램은 file descriptor 1로 초기화된 출력 스트림을 생성합니다. 이 fd는 관용적으로 표준 출력 채널을 의미합니다. 따라서 이 예제에서는 위와 같이 문자를 그저 출력할 뿐입니다.

 

 

버퍼할 스트림 버퍼를 구현하기 위해서 출력 버퍼는 함수 setp()를 사용하여 write buffer를 초기화해야만 합니다. 아래 예제 코드를 참조바랍니다.

#include <iostream>
#include <streambuf>

#ifdef _MSC_VER
#include <io.h>
#else
#include <unistd.h>
#endif

class outbuf : public std::streambuf
{
protected:
  static const int bufferSize = 10; // size of data buffer
  char buffer[bufferSize];          // data buffer

public:
  // constructor
  // - initialize data buffer
  // - one character less to let the bufferSize th character cuase a call of overflow()
  outbuf() {
    setp(buffer, buffer+(bufferSize - 1));
  }

  // destrctor
  // - flush data buffer
  virtual ~outbuf() {
    sync();
  }

protected:
  // flush the characters in the buffer
  int flushBuffer() {
    int num = pptr() - pbase();
    if (write(1, buffer, num) != num) {
      return EOF;
    }
    pbump(~num); // reset put pointer accordingly
    return num;
  }
  
  // buffer full
  // - write c and all previous characters
  virtual int_type overflow(int_type c) {
    if (c != EOF) {
      // insert character into the buffer
      *pptr() = c;
      pbump(1);
    }
    // flush the buffer
    if (flushBuffer() == EOF) {
      // ERROR
      return EOF;
    }
    return c;
  }

  // syncrhonize data with file/destination
  // - flush the data in the buffer
  virtual int sync() {
    if (flushBuffer() == EOF) {
      // ERROR
      return -1;
    }
    return 0;
  }
};

위 클래스의 생성자는 setp()를 사용해 write buffer를 초기화합니다.

write buffer는 단 하나의 문자에 대한 공간이 있을 때 미리 overflow()를 호출하도록 설정됩니다. 만약 overflow()의 인자가 EOF가 아니라면, 해당 문자는 write-position에 써질 수 있습니다.

멤버 함수 flushBuffer()가 바로 이런 일을 맡습니다. 이 함수는 write()를 사용해 표준 출력 채널(1)에 문자를 씁니다. 스트림 버퍼의 멤버 함수인 pbump()는 write-position을 버퍼의 시작으로 되돌리기 위해 사용됩니다.

 

위 클래스는 스트림 버퍼의 현재 상태를 해당 저장 장치와 동기화하기 위해 쓰는 가상 함수 sync()도 제공합니다. 일반적으로 실제로 필요한 일은 버퍼를 flush하는 것뿐입니다. 스트림 버퍼에 버퍼하지 않는다면 이 함수의 오버라이딩은 필요없는데, flush할 버퍼가 없기 때문입니다.

 

위에서 구현된 대부분의 함수들은 스트림 버퍼를 위해 오버라이딩된 것이며 만약 외부 표현 장치가 특별한 구조를 가지고 있다면 추가로 함수를 오버라이딩하면 좋습니다. 예를 들어, seekoff()와 seekpos() 함수들을 오버라이딩하여 쓰기 위치를 조작할 수 있게 할 수도 있습니다.

 

 

User-Defined Input Buffers

입력 메커니즘은 기본적으로 출력 메커니즘과 동일하게 동작합니다. 하지만 입력의 경우 마지막으로 쓴 것을 취소할 수도 있습니다. 입력 스트림의 unget()이 호출하는 함수 sungetc()나 입력 스트림의 putback()이 호출하는 sputbackc()는 마지막 읽기를 하기 전 상태로 스트림 버퍼를 되돌리는데 사용될 수 있습니다. 또한 현재 읽기 위치를 움직이지 않고 다음 문자를 읽을 수도 있습니다. 따라서, 스트림 버퍼에서 읽기를 구현할 때는 쓰기를 구현할 때보다 더 많은 함수를 오버라이딩해야 합니다.

 

스트림 버퍼는 eback(), gptr(), egptr()을 통해 접근할 수 있는 3개의 포인터로 입력 버퍼를 관리합니다.

  • eback() ("end put") - 입력 버퍼의 시작, 또는, 이름에서 알 수 있듯이 putback area의 끝을 의미합니다. 특별한 동작을 취하지 않는다면, 이 위치까지만 문자를 돌려놓을 수 있습니다.
  • gptr() ("get pointer") - 현재 read position을 의미합니다.
  • egptr() ("end get pointer") - 입력 버퍼의 끝을 나타냅니다.

read position과 end position 사이의 문자는 외부 표현장치에서 프로그램의 메모리로 전송되기는 했지만, 아직 프로그램에서 처리하지 않은 문자들입니다.

 

sgetc()sbumpc() 함수를 사용해 하나의 문자를 읽을 수 있습니다. 이때, sbumpc()는 문자를 읽고 난 뒤, 포인터를 하나 증가시키지만 sgetc()는 증가시키지 않습니다. 만약 버퍼를 완전히 읽었다면(gptr()==egptr()), 더 이상 문자는 사용할 수 없고 가상 함수 underflow()를 호출하여 버퍼를 다시 채워야 합니다. underflow() 함수는 데이터를 읽는 역할을 합니다. 만약 사용할 수 있는 문자가 없다면 sbumpc() 함수는 가상 함수 uflow()를 대신 호출합니다. uflow()는 기본적으로 underflow()를 호출한 뒤, read pointer를 증가시키도록 구현됩니다. basic_streambuf에서 underflow()는 EOF를 반환하도록 구현되며, 이는 문자를 읽어올 수 없다는 것을 의미합니다.

 

sgetn() 함수는 여러 문자를 한 번에 읽는데 사용됩니다. 이 함수의 실제 처리는 가상 함수 xsgetn()에 위임됩니다. xsgetn()의 기본 구현은 단순히 각 문자에 대해 sbumpc()를 호출하여 여러 문자를 추출합니다. 이 함수 역시 최적화 여지가 있습니다.

 

입력 스트림 버퍼는 출력처럼 하나의 함수를 오버라이딩하는 것만으로는 충분하지 않습니다. 버퍼를 설정하든지, 아니면 최소한 underflow()uflow()를 구현해야 합니다. underflow()는 다음 문자로 넘어가지는 않지만 sgetc()에서 호출될 수 있기 때문입니다. 다음 문자로 이동하는 것은 버퍼를 조작하거나 uflow() 호출해야 합니다. 어떤 경우에서든 underflow()는 문자를 읽을 수 있는 스트림 버퍼에 대해서 구현을 해야 합니다. 만약 underflow()와 uflow()를 모두 구현한다면, 버퍼를 설정할 필요는 없습니다.

 

read buffer는 setg() 멤버 함수로 설정됩니다. 이 함수는 아래의 순서대로 3개의 인자를 받습니다.

  • 버퍼의 시작을 나타내는 포인터 (eback())
  • 현재 read position을 나타내는 포인터 (gptr())
  • 버퍼의 끝을 나타내는 포인터 (egptr())

setp()와 달리 setg()는 스트림의 끝에 문자를 저장할 공간을 정의할 수 있도록 3개의 인자를 받습니다.

 

sputbackc()sungetc() 함수를 사용하면 이미 읽은 문자를 read buffer에 다시 돌려놓을 수 있습니다. sputbackc()는 돌려놓을 문자를 인자로 받고, 이 문자가 실제로 읽었던 문자인지 확인합니다. 두 함수는 read pointer의 위치를 감소시킬 수 있다면 감소시키는데, 이는 read pointer가 버퍼의 시작이 아닌 경우에만 가능합니다. 만약 버퍼의 시작에서 문자를 돌려놓으려고 한다면 가상 함수 pbackfail()은 실패합니다. 만약 pbackfail()을 오버라이딩한다면 이런 경우에도 이전의 read position을 복구할 순 있습니다.

베이스 클래스인 basic_streambuf에는 이러한 동작이 정의되어 있지 않기 때문에 실제로 임의 개수의 문자를 되돌릴 수는 없습니다. 버퍼를 사용하지 않는 스트림이라면 pbackfail()을 구현해야 하는데, 이 함수는 일반적으로 스트림에 되돌릴 문자가 적어도 하나는 있다고 가정하기 때문입니다.

 

만약 새로운 버퍼를 이제 막 읽기 시작했다면, 또 다른 문제가 발생할 수 있습니다. 버퍼에 이전 데이터가 저장해두지 않았을 때 단 하나의 문자로 되돌릴 수 없습니다. 그래서 underflow()는 먼저 현재 버퍼의 마지막 문자들을 버퍼의 시작으로 옮긴 다음, 새로 읽은 문자를 덧붙입니다. 이렇게 하면 pbackfail()을 호출하기 전에 미리 문자를 앞으로 옮길 수 있습니다.

 

아래 예제 코드는 입력 버퍼를 실제로 어떻게 구현하는지 보여줍니다. 아래에서 정의된 inbuf 클래스에는 10개의 문자를 갖는 입력 버퍼를 구현했으며, 이 버퍼는 되돌릴 영역을 위해 최대 4개의 문자를 지정하고, 일반 입력 버퍼를 위한 6개의 문자를 저장할 영역으로 나눕니다.

#include <streambuf>
#include <cstdio>
#include <cstring>

#ifdef _MSC_VER
#include <io.h>
#else
#include <unistd.h>
#endif

class inbuf : public std::streambuf
{
protected:
  // data buffer
  // - at most, 4 characters in putback area plus
  // - at most, 6 characters in ordinary raed buffer
  static const int bufferSize = 10; // size of the data buffer
  char buffer[bufferSize];          // data buffer

public:
  // constructor
  // - initialize empty data buffer
  // - no putback area
  // => force underflow()
  inbuf() {
    setg(buffer+4, // beginning of putback area
         buffer+4, // read position
         buffer+4  // end position
    );
  }

protected:
  // insert new characters into the buffer
  virtual int_type underflow() {
    // is read position before end of buffer?
    if (gptr() < egptr()) {
      return traits_type::to_int_type(*gptr());
    }
    // process size of putback area
    // - use number of characters read
    // - but at most 4
    int numPutback;
    numPutback = gptr() - eback();
    if (numPutback > 4) {
      numPutback = 4;
    }

    // copy up to four character previously read into
    // the putback buffer (area of first four characters)
    std::memmove(buffer+(4-numPutback), gptr()-numPutback, numPutback);

    // read new characters
    int num;
    num = read(0, buffer+4, bufferSize-4);
    if (num <= 0) {
      // ERROR or EOF
      return EOF;
    }

    // reset buffer pointers
    setg(buffer+(4-numPutback), // beginning of putback area
         buffer+4,              // read position
         buffer+4+num           // end of buffer
    );
    // return next character
    return traits_type::to_int_type(*gptr());
  }
};

위 코드의 생성자는 아래 그림과 같이 모든 포인터를 초기화하여 버퍼가 완전히 비어있도록 합니다.

만약 한 문자를 이 스트림 버퍼에서 읽는다면, 함수 underflow()를 호출합니다. 이 함수는 먼저 다음 문자를 읽기 위해 스트림 버퍼가 사용하는 입력 버퍼에 읽을 수 있는 문자가 있는지 확인합니다. 만약 문자가 있다면 memcpy()를 사용하여 putback 영역으로 이들을 옮깁니다. 이 문자들은 입력 버퍼의 마지막 4개의 문자가 됩니다. 그런 다음 POSIX의 low-level I/O 함수인 read()를 호출하여 표준 입력 채널에서 다음 문자를 읽습니다. 

 

예를 들어, read()를 호출하여 'H', 'a', 'l', 'l', 'o', 'w'를 읽었다면, 입력 버퍼의 상태는 아래 그림과 같습니다. 버퍼가 처음으로 채워졌고, 되돌릴 문자는 없기 때문에 putback 영역은 비어 있습니다.

문자들을 추출한 뒤, 마지막 4개의 문자를 putback 영역으로 이동시키고, 새로운 문자를 읽습니다. 예를 들어, 다음 번 read()에서 'e', 'e', 'n', '\n'을 읽었다면 그 결과는 아래와 같습니다.

위의 구현 클래스는 다음와 같이 사용할 수 있습니다.

int main(int argc, char** argv)
{
  inbuf ib;             // create special stream buffer
  std::istream in(&ib); // initialize input stream with that buffer

  char c;
  for (int i = 1; i <= 20; i++) {
    // read next character (out of the buffer)
    in.get(c);

    // print that character (and flush)
    std::cout << c << std::flush;

    // after eight characters, put two characters back into the stream
    if (i == 8) {
      in.unget();
      in.unget();
    }
  }
  std::cout << std::endl;
}

위 코드는 루프 내에서 문자를 읽고 쓰는 작업을 수행하는데, 여덟 번째 문자를 읽고 난 후 두 문자가 되돌려집니다. 따라서 일곱 번째와 여덟 번째 문자는 두 번 출력됩니다.

 


Performance Issues

마지막으로 스트림에 대한 성능에 관련된 문제들을 살펴보겠습니다. 일반적으로 기존 스트림 클래스도 꽤 효율적이지만, I/O가 성능에 큰 영향을 미치는 경우 성능을 더 개선시킬 방법이 존재합니다.

 

제일 먼저 할 수 있는 것은 컴파일할 때 꼭 필요한 헤더를 include 하는 것입니다. 특히 표준 스트림 객체를 사용할 것이 아니라면 <iostream>은 include하지 않는 것이 좋습니다.

 

Synchronization with C's Strandard Streams

기본적으로 C++ 표준 스트림에는 8개가 있고(char과 wchar_t 대해 각각 4개씩), 이들은 C 표준 라이브러리에서 stdin, stdout, stderr 중 대응되는 파일과 동기화됩니다. 기본적으로 clog와 wclog는 cerr와 wcerr와 각각 같은 스트림 버퍼를 사용합니다. 따라서 이들은 직접적으로 C 표준 라이브러리와 대응되는 파일은 없지만 기본적으로 stderr와 동기화됩니다.

 

구현에 따라 이 동기화에 필요하지 않은 부하가 발생합니다. 예를 들어, 표준 C 파일을 사용해 표준 C++ 표준 스트림을 구현한다면 스트림 버퍼 쪽에서 버퍼를 해야 합니다. 그러나 스트림 버퍼의 버퍼는 몇몇 최적화가 필요한데, 특히, formatted reading을 위해서 최적화가 필요합니다. 더 나은 구현으로 스위칭하기 위해서 ios_base 클래스에 static 멤버 함수로 sync_with_stdio()가 제공됩니다.

sync_with_stdio()는 불리언 값을 optional 인자로 받아서 표준 C 스트림과 동기화가 필요한지 여부를 결정합니다. 따라서 동기화를 끄고 싶다면 인자로 false를 전달하면 됩니다. 어떤 I/O 연산을 수행하기 전에 동기화를 꺼야하며, I/O를 한 뒤 이 함수를 호출하는 것은 undefined 동작 입니다. 이 함수는 이전에 호출되었을 때의 값을 반환하며, 이전에 호출된 적이 없다면 표준 스트림의 기본 설정에 따라 항상 true를 반환합니다.

 

C++11부터 표준 C 스트림과의 동기화를 비활성화하면 동시성 지원도 같이 비활성화됩니다.

 

Buffering in Stream Buffers

효율성을 위해 I/O를 버퍼링하는 것이 중요합니다. 일반적으로 시스템 콜은 코스트가 상당히 크기 때문에 최대한 호출하지 않는 편이 좋기 때문입니다. 또한 C++에서 스트림 버퍼에 버퍼링하는 또 다른 이유가 있습니다(적어도 입력에 대해). Formatted I/O를 위한 함수는 스트림에 접근하기 위해 스트림 버퍼 반복자를 사용하는데, 스트림 버퍼 반복자로 연산하는 것은 포인터 연산보다 느립니다. 차이는 크지 않지만 숫자 값에 대한 formatted reading과 같이 자주 사용되는 작업은 성능을 개선해야할 정도로 충분합니다. 하지만 이를 위해서는 스트림 버퍼가 버퍼링해야 합니다.

 

따라서 모든 I/O는 버퍼를 위한 메커니즘으로 구현한 스트림 버퍼를 통해 이루어집니다. 하지만 이런 버퍼링에만 의존하는 것만으로 충분하지 않는데, 효과적인 버퍼링이란 측면에서 아래의 3가지 사항이 서로 충돌하기 때문입니다.

  1. 대체로 버퍼링없이 스트림 버퍼를 구현하는 것이 훨씬 간단합니다. 만약 해당 스트림이 자주 사용되지 않거나 단순 출력을 위해서만 사용된다면 버퍼링은 크게 중요하지 않을 수 있습니다 (출력의 경우 스트림 버퍼 반복자와 포인터 연산의 코스트 차이가 크지 않으며, 성능이 문제가 되는 주요 연산은 스트림 버퍼 반복자들 사이의 비교 연산임). 하지만, 자주 사용되는 스트림 버퍼라면 버퍼링을 구현하는 것이 절대적으로 좋습니다.
  2. unitbuf 플래그는 각 출력 연산 후 출력 스트림을 스트림으로 flush 합니다. 이에 맞추어 flush와 endl 조작자도 스트림을 flush 합니다. 최고의 성능을 위해서는 3가지 모두 발생하지 않는 편이 좋습니다. 예를 들어, 콘솔에 쓴다고 하면 한 줄을 완성한 다음 스트림에 flush 하는 것이 합리적입니다. 만약 unitbuf, flush, endl을 많이 사용하는 프로그램을 만든다면, 스트림 버퍼를 flush하기 위한 sync()를 사용하지 않는 대신 적절한 때에 다른 함수를 사용하는 특별한 스트림 버퍼를 사용하는 편이 좋습니다.
  3.  스트림을 tie()로 묶으면, 스트림의 추가적인 flushing이 발생하게 됩니다. 따라서, 꼭 필요한 경우에만 스트림을 묶어주어야 합니다.

 

Using Stream Buffers Directly

basic_istream과 basic_ostream 클래스의 모든 문자 read/write 멤버 함수는 동일한 방식으로 동작합니다. 먼저 해당 sentry 객체가 생성되고, 연산이 수행됩니다. sentry 객체의 생성으로 잠재적으로 묶여 있던 객체가 flushing 되고, 입력에 대해 공백을 스킵하고, 멀티스레드 환경을 위한 lock과 같은 구현별 연산이 발생합니다.

 

unformatted I/O에 대해서, 대부분의 연산들은 쓸모가 없습니다. 따라서 unformatted I/O를 한다면 스트림 버퍼를 직접 사용하는 편이 좋습니다. 이러한 기능을 지원하기 위해 스트림 버퍼에서 다음과 같이 << 연산자나 >> 연산자를 사용할 수 있습니다.

  • 스트림 버퍼에 대한 포인터를 << 연산자로 전달해 각 장치의 모든 입력을 출력할 수 있습니다. C++ I/O 스트림을 사용해 가장 빠르게 파일을 복사하는 방법은 아래와 같습니다. 여기서 rdbuf()는 cin의 버퍼를 반환하며, 이 프로그램은 모든 표준 입력을 표준 출력으로 복사합니다.
#include <iostream>

int main()
{
  std::cout << std::cin.rdbuf();
}
  • 스트림 버퍼에 대한 포인터를 >> 연산자로 전달하여 스트림 버퍼로 직접 읽을 수 있습니다. 예를 들어, 표준 입력을 표준 출력으로 복사할 때 다음과 같이 작성할 수도 있습니다. 여기서 skipws 플래그를 삭제해야 하는데, 그러지 않는다면 앞선 공백 부분이 생략됩니다.
#include <iostream>

int main()
{
  std::cin >> std::noskipws << std::cout.rdbuf();
}

 

formatted I/O에서도 스트림 버퍼를 직접 사용하는 편이 합리적이기도 한데, 예를 들어, 많은 숫자값들을 루프를 통해 읽는다면 단 하나의 sentry 객체를 생성하여 루프가 실행되는 동안 사용하는 것만으로 충분합니다. 루프 내에서 공백은 직접 건너뛰고 num_get()은 숫자 값을 읽는데 사용됩니다.

'프로그래밍 > C & C++' 카테고리의 다른 글

[C++] Class Templates  (0) 2022.12.29
[C++] Function Templates  (0) 2022.12.28
[C++] 파일 & 문자열 스트림  (0) 2022.12.16
[C++] 스트림(stream) 클래스 (2)  (0) 2022.12.15
[C++] 스트림(stream) 클래스 (1)  (0) 2022.12.13

댓글