전문가를 위한 C++/ C++ I/O 완전 분석

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

스트림 사용법

스트림의 정체

1장에서 cout 스트림을 소개할 때 공장의 컨베이어 벨트에 비유했다. 스트림에 변수를 올려 보내면 사용자의 화면인 콘솔에 표시된다. 이를 일반화해서 모든 종류의 스트림을 컨베이어 벨트로 표현할 수 있다.

스트림마다 방향과 소스(source) 또는 목적지(destination)을 지정할 수 있다. 예컨대 앞 장에서 본 cout 스트림은 출력 스트림이다. 그래서 나가는(out) 방향을 갖는다. cout은 데이터를 콘솔에 쓴다. 따라서 목적지는 ‘콘솔’이다. cout의 c는 console이 아니라 character를 의미한다. 즉, cout은 문자 기반 스트림이다. 이와 반대로 사용자의 입력을 받는 cin이란 스트림도 있다.

C++에서 기본으로 정의된 스트림을 간략히 정리하면 다음과 같다.

스트림 설명
cin 입력 스트림. ‘입력 콘솔’에 들어온 데이터를 읽는다.
cout 버퍼를 사용하는 출력 스트림. 데이터를 ‘출력 콘솔’에 쓴다.
cerr 버퍼를 사용하지 않는 출력 스트림. 데이터를 ‘에러 콘솔’에 쓴다. 에러 콘솔과 ‘출력 콘솔’이 같을 때가 많다.
clog 버퍼를 사용하는 cerr

여기서 버퍼를 사용하는 (buffered) 스트림은 받은 데이터를 버퍼에 저장했다가 블록 단위로 목적지를 보내고, 버퍼를 사용하지 않는 (unbuffered) 스트림은 데이터가 들어오자마자 목적지로 보낸다. 이렇게 버퍼에 잠시 저장(버퍼링, buffering) 하는 이유는 파일과 같은 대상에 입출력을 수행할 때는 블록 단위로 묶어서 보내는 것이 효율적이기 때문이다. 참고로 버퍼를 사용하는 스트림은 버퍼를 깨끗이 비우는 flush() 메서드로 현재 버퍼에 담긴 데이터를 목적지로 보낸다.

스트림에서 중요한 또 다른 사실은 데이터가 현재 가리키는 위치와 함께 담겨 있다는 것이다. 스트림에서 현재 위치란 다음번에 읽기 또는 쓰기 연산을 수행할 위치를 의미한다.

스트림의 출발지와 목적지

스트림이란 개념은 데이터를 입력 받거나 출력하는 객체라면 어떤 것에도 적용할 수 있다. 네트워크 관련 클래스를 스트림 기반으로 작성할 수도 있고, MIDI 장치에 접근하는 부분도 스트림으로 구현할 수 있다.

파일 스트림(file stream)은 파일 시스템에서 데이터를 읽고 쓰는 스트림이다. 스트링 스트림(string stream)은 스트링 타입에 스트림 개념을 적용한 것이다.

스트림을 이용한 출력

출력 스트림의 기초

출력 스트림은 <ostream> 헤더 파일에 정의돼 있다. 출력 스트림을 사용하는 가장 간편한 방법은 << 연산자를 이용하는 것이다.

(앞서 많이 다루었기 때문에 이하 cout에 대한 예시 생략)

출력 스트림에서 제공하는 메서드

출력 스트림에서 가장 대표적인 연산자는 << 다. 이 연산자는 단순히 출력하는 기능 외에도 여러 기능을 제공한다.

put()과 write()

put()과 write()는 저수준 출력 메서드에 속하며 출력 동작을 갖춘 객체나 변수가 아닌 문자 하나 (put()) 또는 문자 배열 하나 (write())을 인수로 받는다.

const char* test = "hello there\n";
cout.write(test, strlen(test));

cout.put('a');
flush()

출력 스트림에 데이터를 쓰는 즉시 목적지에 전달되지 않을 수 있다. 일반적으로 출력 스트림은 들어온 데이터를 곧바로 쓰지 않고 버퍼에 잠시 보관한다. 그렇게 하면 성능을 높일 수 있기 때문이다. 목적지가 파일과 같은 스트림일 떄는 한 문자씩 처리하기 보다 블록 단위로 묶어서 처리하는 것이 훨씬 효율적이다.

스트림은 다음과 같은 조건을 만족할 떄 그동안 쌓아둔 데이터를 모두 내보내고 버퍼를 비운다.

  • endl과 같은 경곗값에 도달할 떄
  • 스트림이 스코프를 벗어나 소멸될 때
  • 출력 스트림에 대응되는 입력 스트림으로부터 요청이 들어올 때(예컨대 cin으로 입력 받으면 cout의 버퍼를 비움)
  • 스트림 버퍼가 가득 찼을 때
  • 스트림 버퍼를 비우기 위해 명시적으로 flush()를 호출할 때

 flush() 메서드를 호출해서 스트림 버퍼를 명시적으로 비우려면 다음과 같이 작성한다.

cout << "abc";
cout.flush();
cout << "def";
cout.flush();

Note) 모든 출력 스트림이 버퍼를 사용하는 것은 아니다. cerr 스트림은 버퍼를 사용하지 않고 출력한다.

출력 에러 처리하기

good() 메서드는 스트림을 정상적으로 사용할 수 있는 상태인지 확인한다. 사용법은 다음과 같이 스트림에 대해 곧바로 호출하면 된다.

if (cout.good())
{
cout << "All good" << endl;
}

good() 메서드를 이용하면 스트림의 상태 정보를 조회할 수 있다. 하지만 사용할 수 없는 상태일 때는 그 원인을 구체적으로 알려주지 않는다. 이런 정보는 bad() 메서드로 자세히 볼 수 있다. bad() 메서드가 true를 리턴한다는 말은 심각한 에러가 발생했다는 뜻이다. (반면 파일의 끝에 도달했는지 확인하는 eof()가 true라는 것은 심각한 상태가 아니다) 

또한 fail() 메서드를 사용하면 최근 수행한 연산에 오류가 발생했는지 확인할 수 있다. 그러나 그 뒤에 일어날 연산의 상태는 알려주지 않기 때문에 fail()의 리턴값에 관계 없이 후속 연산이 성공적으로 수행할 수도 있고 아닐 수도 있다. 예컨대 출력 스트림에 대해 flush()를 호출할 뒤 fail()을 호출하면 바로 직전의 flush() 연산이 성공했는지 확인할 수 있다.

cout.flush();

if (cout.fail())
{
cerr << "Unable to flush to standard out" << endl;
}

스트림을 bool 타입으로 변환하는 연산자도 있다. 이 연산자는 !fail()을 호출할 떄와 똑같은 결과를 리턴한다. 따라서 앞에 나온 코드를 다음과 같이 작성해도 된다.

cout.flush();

if (!cout)
{
cerr << "Unable to flush to standard out" << endl;
}

여기서 주의할 점은 good()과 fail()은 스트림이 파일 끝에 도달할 때도 false를 리턴한다는 것이다. 이 관계를 코드로 표현하면 다음과 같다.

good() == (!fail() && !eof())

스트림에 문제가 있으면 익셉션을 발생하도록 만들 수 있다. ios_base::failure 익셉션을 처리하도록 catch 구문을 작성하면 된다. 이 익셉션에 대해 what() 메서드를 호출하면 발생한 에러에 대한 정보를 볼 수 있다. 또한 code()를 호출하면 에러 코드를 볼 수 있다. 하지만 이 정보가 얼마나 쓸모 있는지는 표준 라이브러리의 구현마다 다르다.

cout.exceptions(ios::failbit | ios::badbit | ios::eofbit);

try
{
cout << "Hello World" << endl;
}
catch (const ios_base::failure& ex)
{
cerr << "Caught exception: " ex.what() << ", error code = " << ex.code() << endl;
}

스트림의 에러 상태를 초기화하려면 clear() 메서드를 호출한다.

cout.clear();

출력 매니퓰레이터

C++의 스트림은 단순히 데이터를 전달하는데 그치지 않고, 매니퓰레이터(manipulator, 조종자)라는 객체를 받아서 스트림의 동작을 변경할 수도 있다. 이때 스트림의 동작을 변경하는 작업만 할 수도 있고, 스트림에 데이터를 전달하면서 동작도 변경할 수 있다.

앞서 본 endl이 바로 스트림 매니퓰레이터다. endl은 데이터와 동작을 모두 담고 있다. 그래서 스트림에 전달될 때 줄끝(end-of-line) 문자를 출력하고 버퍼를 비운다. 몇 가지 유용한 스트림 매니퓰레이터를 소개하면 다음과 같다. 대부분 <ios>나 <iomanip> 헤더 파일에 정의돼 있다.

  • boolalpha와 noboolalpha: 스트림에 bool 값을 true나 false로 출력하거나(boolalpha), 1이나 0으로 출력하도록(noboolalpha) 설정한다. 기본값은 noboolalpha다.
  • hex, oct, dec: 각각 숫자를 16진수, 8진수, 10진수로 출력한다.
  • setprecision: 분숫값을 표현할 때 적용할 소수점 자리수를 지정한다. 이를 위해 자릿수를 표현하는 인수를 받는다.
  • setw: 숫자 데이터를 출력할 필드의 너비를 지정한다. 이 매니퓰레이터도 인수를 받는다.
  • setfill: 지정된 너비보다 숫자가 작을 때 빈 공간을 채울 문자를 지정한다. 이 매니퓰레이터도 인수를 받는다.
  • showpoint와 noshowpoint: 소수점 아래의 수가 없는 부동소수점수를 스트림에서 표현할 때 소수점의 표시 여부를 설정한다.
  • put_money: 스트림에서 화폐 금액을 일정한 형식에 맞게 표현할 때 사용하는 매니퓰레이터로서 인수를 받는다.
  • put_time: 스트림에서 시간을 일정한 형식에 맞게 표현할 때 사용하는 매니퓰레이터로서 인수를 받는다.
  • quoted: 지정한 스트링을 인용부호(따옴표)로 감싸고 스트링 안에 있던 인용부호를 이스케이프 문자로 변환한다. 이 매니퓰레이터도 인수를 받는다.

(예시 코드 생략)

스트림을 이용한 입력

입력 스트림의 기초

입력 스트림으로부터 데이터를 읽는 두 가지 방법이 있다. 하나는 출력 연산자 <<로 데이터를 출력하는 방법과 비슷하며 << 대신 입력 연산자 >>를 사용한다. 이때 >> 연산자로 입력 스트림에서 읽은 데이터를 변수에 저장할 수 있다. 예컨대 사용자로부터 단어 하나를 받아서 스트링에 저장한 뒤 콘솔에 출력하려면 다음과 같이 작성한다.

string userInput;
cin >> userInput;
cout << "User input was " << userInput << endl;

>> 연산자의 기본 설정에 따르면 공백을 기준으로 입력된 값을 토큰 단위로 나눈다(토큰화 한다) 예컨대 앞서 나온 코드를 실행한 뒤 콘솔에서 ‘hello there’를 입력하면 첫 번쨰 공백 문자(스페이스) 이전의 문자들만 userInput 변수에 담긴다. 출력 결과는 다음과 같다.

User input was hello

또 다른 방법은 get()을 사용하는 것이다. 그러면 입력값에 공백을 담을 수 있다.

>> 연산자는 <<와 마찬가지로 다양한 타입을 지원한다. 예컨대 정숫값을 읽으려면 다음처럼 변수의 타입만 바꾸면 된다.

int userInput;
cin >> userInput;
cout << "User input was " << userInput << endl;

또한 타입이 다른 값을 동시에 받을 수 있다. 

(예시 코드 생략)

입력 에러 처리하기

입력 스트림은 비정상적인 상황을 감지하는 여러 메서드를 제공한다. 입력 스트림의 에러는 대부분 읽을 데이터가 없을 때 발생한다. 예컨대 스트림의 끝(파일 끝 end-of-file)에 도달할 때가 있다. 이에 대처하는 가장 흔한 방법은 입력 스트림에 접근하기 전에 조건문으로 스트림의 상태를 확인하는 것이다. 예컨대 다음 반복문은 cin이 정상 상태일 때만 진행한다.

while (cin) { ... }

이때 데이터 입력을 받아도 된다.

while (cin >> ch) { ... }

출력 스트림과 마찬가지로 입력 스트림에 대해서도 good(), bad(), fail() 메서드를 호출할 수 있다. 또한 스트림이 끝에 도달하면 true를 리턴하는 eof() 메서드도 사용할 수 있다. 

입력 스트림의 good()과 fail()은 출력 스트림과 마찬가지로 파일 끝에 도달하면 false를 리턴하며 출력 스트림처럼 다음과 같은 관계가 성립한다.

good() == (!fail() && !eof())

따라서 데이터를 읽을 때마다 항상 스트림 상태를 검사하는 습관을 들인다. 그래야 잘못된 값이 입력될 때 적절히 대처할 수 있다.

(이하 예시 생략)

입력 메서드

출력 스트림과 마찬가지로 입력 스트림도 >> 연산자보다 저수준으로 접근하는 메서드를 제공한다.

get()

get() 메서드는 스트림 데이터를 저수준으로 읽는다. get()의 가장 간단한 버전은 스트림의 다음 문자를 리턴한다. 물론 여러 문자를 한 번에 읽는 버전도 있다. get()은 주로 >> 연산자를 사용할 떄 자동으로 토큰 단위로 잘리는 문제를 피하는 용도로 사용한다. 예컨대 다음 함수는 입력 스트림에서 이름 하나를 받는다. 이때 이름이 여러 단어로 구성될 수 있으므로 스트림의 끝에 도다할 때까지 이름을 계속 읽는다.

string readName(istream& stream)
{
string name;

while(stream) // 또는 while(!stream.fail())
{
int next = stream.get();
if (!stream || next == std::char_traits<char>::eof())
{
break;
}
name += static_cast<char>(next);
}

return name;
}

readName() 함수를 구현하는 과정에서 몇 가지 주목할 점이 있다.

  • 매개변수의 타입은 non-const istream 레퍼런스다. 스트림에서 데이터를 읽는 메서드는 실제 스트림을 (그중에서도 특히 위치를) 변경하기 때문에 const로 지정하지 않았다. 따라서 const 레퍼런스에 대해 호출할 수 없다.
  • get()의 리턴값은 char가 아닌 int 타입 변수에 저장했다. get()은 EOF에 해당하는 std::char_traits<char>::eof()를 비롯한 문자가 아닌 특수한 값을 리턴할 수 있기 떄문이다.

여기 나온 readName() 코드는 반복문을 끝내는 방법이 두 가지라는 점이 특이하다. 하나는 스트림이 오류 상태에 빠질 떄고, 다른 하나는 스트림의 끝에 도달할 때다. 일반적으로 스트림에서 데이터를 읽는 부분을 구현할 때는 여기 나온 방식보다는 문자에 대한 레퍼런스를 받아서 스트림에 대한 레퍼런스를 리턴하는 버전의 get()을 이용하는 방식을 많이 사용한다. 이렇게 작성하면 입력 스트림이 에러 상태가 아닐 때만 조건문에서 true를 리턴한다는 점을 활용할 수 있다. 즉, 스트림에 에러가 발생하면 조건문으로 적은 표현식의 결과는 false가 된다. 이렇게 하면 코드를 다음과 같이 훨씬 간결하게 작성할 수 있다.

string readName(istream& stream)
{
string name;
char next;

while(stream.get(next))
{
name += next;
}

return name;
}
unget()

일반적으로 입력 스트림은 한 방향으로만 진행하는 컨베이어 벨트와 같다. 여기에 올린 데이터는 변수로 전달된다. 그런데 unget() 메서드는 데이터를 다시 입력 소스 방향으로 보낼 수 있다는 점에서 이 모델을 따르지 않는다.

unget()을 호출하면 스트림이 한 칸 앞으로 거슬러 올라간다. 그래서 이전에 읽은 문자를 스트림으로 되돌린다. unget() 연산의 성공 여부는 fail() 메서드로 확인한다. 예컨대 현재 위치가 스트림의 시작점이면 unget()에 대한 fail()의 리턴값은 false다.

앞서 예시 함수는 공백이 담긴 이름을 입력 받을 수 없었다. unget()을 이용하면 공백을 담을 수 있다.

(예시 코드 생략)

putback()

putback() 메서드도 unget()과 마찬가지로 입력 스트림을 한 문자만큼 되돌린다. unget()과 달리 putback()은 스트림에 되돌릴 문자를 인수로 받는다.

char ch1;
cin >> ch1;
cin.putback('e');
// 이 스트림에서 다음 번에 읽어올 문자는 'e'가 된다.
peek()

peek()은 ‘힐끗 본다’는 의미대로 get()을 호출할 떄 리턴될 값을 미리 보여준다.

(예시 코드 생략)

getline()

프로그램을 작성하다 보면 입력 스트림에서 데이터를 한 줄씩 읽을 일이 많다. 이를 위해 getline()이란 메서드를 별도로 제공한다. 이 메서드는 미리 설정한 버퍼가 가득 채워질 때까지 문자 한 줄을 읽는다. 이때 한 줄의 끝을 나타내는 \0(EOL, end-of-line) 문자도 버퍼의 크기에 포함된다.

다음 코드는 cin으로부터 kbufferSize-1개의 문자를 읽거나 EOL 문자가 나올 때까지 읽기 연산을 수행한다.

char buffer[kBufferSize = { 0 };
cin.getline(buffer, kBufferSize);

getline()이 호출되면 입력 스트림에서 EOL이 나올 때까지 문자 한 줄을 읽는다. EOL 문자는 스트링에 담기지 않는다. 참고로 EOL 문자는 플랫폼마다 다를 수 있는데, 어떤 것은 \r\n을 사용하고 어떤 것은 \n이나 \n\r을 사용한다.

get() 함수 중에서 getline()과 똑같이 작동하는 버전도 있다. 단, 이 함수는 입력 스트림에서 줄바꿈 문자를 가져오지 않는다.

C++의 string에서 사용할 수 있는 std::getline()이란 함수도 있다. 이 함수는 <string> 헤더파일의 std 네임스페이스 아래에 정의돼 있다. 이 함수는 스트림 레퍼런스와 string 레퍼런스를 받고, 옵션으로 구분다(delimiter)도 받는다. std::getline()을 사용하면 버퍼의 크기를 지정하지 않아도 된다는 장점이 있다.

string myString;
std::getline(cin, myString);

입력 매니퓰레이터

C++는 다음과 같은 입력 매니퓰레이터를 기본으로 제공한다. 이를 입력 스트림에 적절히 지정하면 데이터를 읽는 방식을 원하는대로 설정할 수 있다.

  • boolalpha와 noboolalpha: boolalpha를 지정하면 ‘false’란 스트링값을 부울 타입인 false로 해석하고 나머지 스트링을 true로 처리한다. noboolalpha를 지정하면 0을 부울값 false로 해석하고, 0이 아닌 나머지 값을 true로 처리한다. 기본적으로 noboolalpha로 설정돼 있다.
  • hex, oct, dec: 각각 숫자를 16진수, 8진수, 10진수로 읽도록 지정한다.
  • skipws와 noskipws: skipws를 지정하면 토큰화할 떄 공백을 건너뛰고, noskpws를 지정하면 공백을 하나의 토큰으로 취급한다. 기본적으로 skipws로 지정되어 있다.
  • ws: 스트림의 현재 위치부터 연달아 나온 공백 문자를 건너뛴다.
  • get_money: 스트림에서 화폐 금액을 표현한 값을 읽는 매개변수 방식의 매니퓰레이터
  • get_time: 스트림에서 일정한 형식으로 표현된 시각 정보를 읽는 매개변수 방식의 매니퓰레이터다.
  • quoted: 인용부호(따옴표)로 묶은 스트링을 읽는 매니퓰레이터로서 인수를 받는다. 이스케이프 문자로 입력된 따옴표는 스트링에 포함된다.

입력은 로케일 설정에 영향을 받는다.

(이하 설명 생략)

객체에 대한 입력과 출력

string은 C++ 언어의 기본 타입은 아니지만 << 연산자로 출력할 수 있다. C++에서는 객체가 입력되거나 출력되는 방식을 정의할 수 있다. << 나 >> 를 오버로딩하면 이 연산자가 특정한 타입이나 클래스를 처리하게 만들 수 있다.

(예시 생략)

스트링 스트림

스트링 스트림이란 string에 스트림 개념을 추가한 것이다. 이렇게 하면 텍스트 데이터를 메모리에서 스트림 형태로 표현하는 인메모리 스트림(in-memory stream)을 만들 수 있다.

예컨대 GUI 애플리케이션에서 콘솔이나 파일이 아닌 스트림으로부터 텍스트 데이터를 구성한 뒤 이를 메시지 박스나 편집 컨트롤과 같은 GUI 요소로 결과를 출력할 수 있다. 또 다른 예로 스트링 스트림을 현재 위치에 대한 정보와 함께 여러 함수에 전달해서 다양한 작업을 연속적으로 처리할 수 있다. 스트링 스트림은 기본적으로 토큰화(tokenizing) 기능을 제공하기 때문에 텍스트 구문 분석(파싱, parsing) 작업에 활용해도 편하다.

string에 데이터를 쓸 때는 std::ostringstream 클래스를, 반대로 string에서 데이터를 읽을 때는 std::istringstream 클래스를 사용한다. 둘 다 <sstream> 헤더 파일에 정의돼 있다.

다음 코드는 사용자로부터 받은 단어들을 탭 문자로 구분해서 ostringstream에 쓴다. 다 쓰고 나면 str() 메서드를 이용하여 스트림 전체를 string 객체로 변환한 뒤 콘솔에 쓴다. 입력값 ‘done’이란 단어를 입력할 때까지 토큰 단위로 입력 받거나, 유닉스라면 Ctrl+D, 윈도우라면 Ctrl+Z를 입력해서 입력 스트림을 닫기 전까지 입력 받는다.

cout << "Enter tokens. Control+D (Unix) or Control+Z (Windows) to end" << endl;
ostringstream outStream;

while(cin)
{
string nextToken;
cout << "Next token: ";
cin >> nextToken;

if (!cin || nextToken == "done")
{
break;
}

outStream << nextToken << "\t";
}

cout << "The end result is: " << outStream.str();

스트링 스트림에서 데이터를 읽는 방법도 비슷하다. 다음 함수는 스트링 입력 스트림으로부터 Muffin 객체를 생성한 뒤 속성을 설정한다. 이때 받은 스트림 데이터는 일정한 포맷을 따르기 떄문에 이 함수는 Muffin 세터를 호출하는 방식으로 입력된 값을 간단히 변환할 수 있다.

Muffin createMuffin(istringstream& stream)
{
Muffin muffin;

// 데이터가 다음과 같은 형식에 맞게 들어온다고 가정한다. Description, size, chips

string description;
int size;
bool hasChips;

// 세 값 모두 읽는다. 이때 chips는 'true'나 'false'란 스트링으로 표현한다.
stream >> descrption >> size >> boolalpha >> hasChips;

if (stream)
{
muffin.setSize(size);
muffin.setDescription(description);
muffin.setHasChocolateChips(hasChips);
}
return muffin;
}

Note) 객체를 스트링처럼 일렬로 나열하는 것을 마셜링(marshalling)이라 부른다. 마셜링은 객체를 디스크에 저장하거나 네트워크로 전송할 때 유용하다.

파일 스트림

파일은 스트림 개념과 정확히 일치한다. 파일을 읽고 쓸 때 항상 현재 위치를 추적하기 때문이다. C++는 파일 출력과 입력을 위해 std::ofstream과 std::ifstream 클래스를 제공한다. 둘 다 <fstream> 헤더 파일에 정의돼 있다.

파일시스템을 다룰 때는 에러 처리가 특히 중요하다. 네트워크로 연결된 저장소에 있던 파일을 다루던 중 갑자기 네트워크 연결이 끊길 수 있고, 로컬 디스크에 파일을 쓰다가 디스크가 가득 찰 수도 있다. 또는 현재 사용자에게 권한이 없는 파일을 열 수도 있다. 이런 에러 상황을 제때 감지해서 적절히 처리하려면 표준 에러 처리 메커니즘을 이용하면 된다.

파일 출력 스트림과 다른 출력 스트림의 가장 큰 차이점은 파일 스트림 생성자는 파일의 이름과 파일을 열 때 적용할 모드에 대한 인수를 받는다는 점이다. 출력 스트림의 디폴트 모드는 파일을 시작 지점부터 쓰는 ios_base::out이다. 이때 기존 데이터가 있으면 덮어쓴다. 또는 파일 스트림 생성자의 두 번째 인수로 ios_base::app(추가모드)를 지정하면 파일 스트림을 기존 데이터 뒤에 추가할 수 있다. 파일 스트림의 모드로 지정할 수 있는 값은 다음과 같다.

상수 설명
ios_base::app 파일을 열고, 쓰기 연산을 수행하기 전에 파일 끝으로 간다.
ios_base::ate 파일을 열고 즉시 파일 끝으로 간다.
ios_base::binary 입력 또는 출력을 텍스트가 아닌 바이너리 모드로 처리한다.
ios_base::in 입력할 파일을 열고 시작 지점부터 읽는다.
ios_base::out 출력할 파일을 열고 시작 지점부터 쓴다. 기존 데이터를 덮어쓴다.
ios_base::trunc 출력할 파일을 열고 기존 데이터를 모두 삭제한다.(truncate)

여기 나온 모드를 조합해서 지정할 수도 있다.

(이하 설명 생략)

텍스트 모드와 바이너리 모드

파일 스트림은 기본적으로 텍스트 모드로 연다. 파일 스트림을 생성할 때 ios_base::binary 플래그를 지정하면 파일을 바이너리 모드로 연다.

바이너리 모드로 열면 정확히 바이트 단위로 지정한 만큼만 파일에 쓴다. 파일을 읽을 때는 파일에서 읽은 바이트 수를 리턴한다.

텍스트 모드로 열면 파일에서 \n이 나올 때마다 한 줄씩 읽거나 쓴다. 이떄 파일에서 줄 끝(EOL)을 나타내는 문자는 OS마다 다르다.

seek()과 tell() 메서드로 랜덤 액세스하기

입력과 출력 스트림은 모두 seek()와 tell() 메서드를 갖고 있다.

seek() 메서드는 입력 또는 출력 스트림에서 혀냊 위치를 원하는 지점으로 옮긴다. seek()은 여러 버전이 있다. 입력 스트림에 대한 seek() 메서드를 seekg() 라 부른다. 여기서 g는 ‘get’을 의미한다. 출력 스트림에 대한 seek()는 seekp()라 부르는데, 여기서 p는 ‘put’을 의미한다. 

seek()를 하나로 표현하지 않고 seekg(), seekp()로 구분한 이유는 파일 스트림처럼 입력과 출력을 모두 가질 때가 있기 때문이다. 이럴 때는 읽는 위치와 쓰는 위치를 별도로 관리해야 한다. 이를 양방향(bidrectional) I/O라 부른다.

seekg()와 seekp()는 각각 두 가지 버전이 있다. 하나는 절대 위치를 나타내는 인수 하나만 받아서 그 위치로 이동한다. 다른 하나는 오프셋(offset)과 위치에 대한 인수를 받아서 지정한 위치를 기준으로 떨어진 거리(오프셋)로 이동한다. 이때 위치는 std::streampos로 오프셋은 std::streamoff로 표현한다. C++ 에 미리 정의된 위치는 다음과 같다.

위치 설명
ios_base::beg 스트림의 시작점
ios_base::end 스트림의 끝점
ios_base::cur 스트림의 현재 위치

예컨대 다음과 같이 매개변수가 하나인 seekp()에 ios_base::beg 상수를 지정하면 출력 스트림의 위치를 절대 위치로 지정할 수 있다.

outStream.seekp(ios_base::beg);

입력 스트림의 위치를 지정하는 방법도 seekp()가 아닌 seekg()라는 점만 빼면 같다.

inStream.seekg(ios_base::beg);

인수가 두 개인 버전은 스트림의 위치를 상대적으로 지정한다. 첫 번째 인수는 이동할 위치의 양을 지정하고, 두 번째 인수는 시작점을 지정한다. 파일의 시작점을 기준으로 위치를 이동하려면 ios_base::beg 상수를 지정한다. ios_base::end를 사용하면 파일의 끝점을 기준으로 위치를 이동할 수 있다. 또한 현재 위치를 기준으로 이동하고 싶다면 ios_base::cur를 사용한다. 예컨대 다음 코드는 스트림의 시작점에서 두 바이트만큼 이동한다. 여기서 인수로 지정한 정숫값은 자동으로 streampos나 streamoff로 변환된다.

outStream.seekp(2, ios_base::beg);

다음 코드는 입력 스트림의 끝에서 세 번째 바이트로 이동한다.

inStream.seekg(-3, ios_base::end);

tell() 메서드를 이용하면 스트림의 현재 위치를 알아낼 수 있다. 이 메서드는 현재 위치를 streampos 타입의 값으로 리턴한다. seek()을 호출하거나 tell()을 다시 호출하기 전에 현재 위치를 기억하고 싶다면 앞서 tell()에서 리턴한 값을 저장해 둔다. seek()와 마찬가지로 tell()도 입력과 출력에 대해 서로 다른 버전(tellg(), tellp())를 제공한다.

다음 코드는 입력 스트림의 위치가 스트림의 시작점인지 확인한다.

std::streampos curPos = inStream.tellg();

if (ios_base::beg == curPos)
{
cout << "We're at the beginning" << endl;
}

(예시 코드 생략)

스트림끼리 서로 연결하기

입력 스트림과 출력 스트림은 언제든지 접근할 떄 내보내기(flush-on-access) 방식으로 서로 연결될 수 있다. 다시 말해 입력 스트림을 출력 스트림에 연결한 뒤 입력 스트림에서 데이터를 읽으면 즉시 출력 스트림으로 내보낸다. 이러한 동작은 모든 종류의 스트림에서 가능하며 파일 스트림끼리 연결할 때 특히 유용하다.

스트림을 연결하는 작업은 tie() 메서드로 처리한다. 출력 스트림을 입력 스트림에 연결하려면 입력 스트림에 대해 tie()를 호출한다. 이때 연결할 출력 스트림의 주소를 인수로 전달한다. 연결을 끊으려면 tie()에 nullptr를 전달해서 호출한다.

다음 코드는 한 파일에 대한 입력 스트림을 전혀 다른 파일에 대한 출력 스트림에 연결하는 예를 보여준다. 이때 같은 파일에 대한 출력 스트림을 연결해도 되지만, 이렇게 같은 파일에 읽고 쓸 때는 양방향 I/O를 이용하는 것이 낫다.

ifstream inFile("input.txt");
ofstream outFile("output.txt");

// inFile과 outFile을 연결한다.
inFile.tie(&outFile);

// outFile에 텍스트를 쓴다. std::endl이 입력되기 전까지는 내보내지 않는다.
outFile << "Hello there!";

// outFile을 아직 내보내지 않은 상태다.
// inFile에서 텍스트를 읽는다. 그러면 outFile에 대해 flush()가 호출된다.
string nextToken;
inFile >> nextToken;

// 이제 outFile이 내보내졌다.

여기서 사용한 flush() 메서드는 ostream 베이스 클래스에 정의돼 있다. 따라서 다음과 같이 출력 스트림을 다른 출력 스트림에 연결할 수도 있다.

outFile.tie(&anotherOutputFile);

이렇게 하면 한 파일에 뭔가 쓸 때마다 버퍼에 저장된 데이터를 다른 파일에 내보낸다. 이렇게 하면 서로 관련된 두 파일을 동기화시킬 수 있다.

스트림 연결의 대표적인 예로 cout과 cin을 연결해서 cin에 데이터를 입력할 때마다 cout을 자동으로 내보내게 만드는 경우가 있다. cerr와 cout도 서로 연결할 수 있다. 반면 clog 스트림은 cout에 연결될 수 없다. 와이드 문자 버전의 스트림도 같은 방식으로 연결한다.

양방향 I/O

지금까지 살펴본 입력과 출력 스트림은 기능상 서로 관련이 있지만 별도의 클래스로 존재한다. 이와 달리 입력과 출력을 모두 처리하는 스트림도 있는데, 이를 양방향 스트림(bidirectional stream)이라 한다.

양방향 스트림은 iostream을 상속한다. 다시 말해 istream과 ostream을 동시에 상속하기 때문에 다중 상속의 대표적인 예이기도 하다. 양방향 스트림은 입력과 출력 스트림의 메서드 뿐만 아니라 >>와 << 연산자를 동시에 제공한다.

fstream 클래스는 양방향 파일시스템을 표현한다. fstream은 파일 안에서 데이터를 교체할 때 유용하다. 정확한 위치를 발견할 때까지 데이터를 읽다가 필요한 시점에 즉시 쓰기 모드로 전환할 수 있기 때문이다. 예컨대 ID와 전화번호 매핑 정보를 관리하는 프로그램을 보자. 이때 데이터는 다음과 같은 포맷으로 파일에 저장된다고 가정한다.

123 408-555-0394
124 415-555-3422
263 585-555-3490
100 650-555-3434

파일을 열고 데이터 전체를 읽고 나서 적절히 내용을 수정한 뒤 프로그램을 종료하기 전에 파일 전체를 다시 쓰는 방식으로 구현하는 경우가 많다. 그런데 데이터 양이 엄청나게 많다면 모든 내용을 메모리에 담을 수 없다. iostream을 이용하면 이런 문제를 피할 수 있다. 팡리에서 데이터를 검색하다가 적절한 지점을 발견하면 추가 모드(append mode)로 열고 원하는 내용을 추가하면 된다. 

다음 예는 특정한 ID에 대한 전화번호를 변경하는데, 이렇게 기존 데이터를 수정할 떄는 양방향 스트림을 활용한다.

bool changeNumberForID(string_view filename, int id, string_view newNumber)
{
fstream ioData(filename.data());

if (!ioData)
{
cerr << "Error while opening file " << filename << endl;
return false;
}

// 파일 끝까지 반복한다.
while(ioData)
{
int idRead;
string number;

// 다음 ID를 읽는다.
ioData >> idRead;

if (!ioData)
{
break;
}

// 현재 레코드가 수정할 대상인지 확인한다.
if (idRead == id)
{
// 쓰기 위치를 현재 읽기 위치로 이동한다.
ioData.seekp(ioData.tellg());

// 한 칸 띄운 뒤 새 번호를 쓴다.
ioData << " " << newNumber;
break;
}

// 현재 위치에서 숫자를 읽어서 스트림의 위치를 다음 레코드로 이동한다.
ioData >> number;
}
return true;
}

물론 이 방법은 데이터의 크기가 일정할 때만 적용할 수 있다. 앞의 예제에서 읽기 모드를 쓰기 모드로 전환하는 순간 기존 파일에 있던 데이터를 덮어쓴다. 파일 포맷을 그대로 유지하면서 다음 레코드를 덮어쓰지 않게 하려면 데이터(레코드)의 크기가 모두 같아야 한다.

stringstream 클래스를 이용하면 스트링 스트림도 양방향 스트림처럼 다룰 수 있다.

Note) 양방향 스트림은 읽기 위치와 쓰기 위치에 대한 포인터를 별도로 사용한다. 읽기와 쓰기 모드를 전환할 때마다 seek() 메서드로 각각의 위치를 적절히 설정해야 한다.

 

 

 

 

 

[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.